udata 14.0.3.dev1__py3-none-any.whl → 14.7.3.dev4__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.
Files changed (151) hide show
  1. udata/api/__init__.py +2 -0
  2. udata/api_fields.py +120 -19
  3. udata/app.py +18 -20
  4. udata/auth/__init__.py +4 -7
  5. udata/auth/forms.py +3 -3
  6. udata/auth/views.py +13 -6
  7. udata/commands/dcat.py +1 -1
  8. udata/commands/serve.py +3 -11
  9. udata/core/activity/api.py +5 -6
  10. udata/core/badges/tests/test_tasks.py +0 -2
  11. udata/core/csv.py +5 -0
  12. udata/core/dataservices/api.py +8 -1
  13. udata/core/dataservices/apiv2.py +3 -6
  14. udata/core/dataservices/models.py +5 -2
  15. udata/core/dataservices/rdf.py +2 -1
  16. udata/core/dataservices/tasks.py +6 -2
  17. udata/core/dataset/api.py +30 -4
  18. udata/core/dataset/api_fields.py +1 -1
  19. udata/core/dataset/apiv2.py +1 -1
  20. udata/core/dataset/constants.py +2 -9
  21. udata/core/dataset/models.py +21 -9
  22. udata/core/dataset/permissions.py +31 -0
  23. udata/core/dataset/rdf.py +18 -16
  24. udata/core/dataset/tasks.py +16 -7
  25. udata/core/discussions/api.py +15 -1
  26. udata/core/discussions/models.py +6 -0
  27. udata/core/legal/__init__.py +0 -0
  28. udata/core/legal/mails.py +128 -0
  29. udata/core/organization/api.py +16 -5
  30. udata/core/organization/api_fields.py +3 -3
  31. udata/core/organization/apiv2.py +3 -4
  32. udata/core/organization/mails.py +1 -1
  33. udata/core/organization/models.py +40 -7
  34. udata/core/organization/notifications.py +84 -0
  35. udata/core/organization/permissions.py +1 -1
  36. udata/core/organization/tasks.py +3 -0
  37. udata/core/pages/models.py +49 -0
  38. udata/core/pages/tests/test_api.py +165 -1
  39. udata/core/post/api.py +25 -70
  40. udata/core/post/constants.py +8 -0
  41. udata/core/post/models.py +109 -17
  42. udata/core/post/tests/test_api.py +140 -3
  43. udata/core/post/tests/test_models.py +24 -0
  44. udata/core/reports/api.py +18 -0
  45. udata/core/reports/models.py +42 -2
  46. udata/core/reuse/api.py +8 -0
  47. udata/core/reuse/apiv2.py +3 -6
  48. udata/core/reuse/models.py +1 -1
  49. udata/core/spatial/forms.py +2 -2
  50. udata/core/topic/models.py +8 -2
  51. udata/core/user/api.py +10 -3
  52. udata/core/user/api_fields.py +3 -3
  53. udata/core/user/models.py +33 -8
  54. udata/features/notifications/api.py +7 -18
  55. udata/features/notifications/models.py +59 -0
  56. udata/features/notifications/tasks.py +25 -0
  57. udata/features/transfer/actions.py +2 -0
  58. udata/features/transfer/models.py +17 -0
  59. udata/features/transfer/notifications.py +96 -0
  60. udata/flask_mongoengine/engine.py +0 -4
  61. udata/flask_mongoengine/pagination.py +1 -1
  62. udata/frontend/markdown.py +2 -1
  63. udata/harvest/actions.py +20 -0
  64. udata/harvest/api.py +24 -7
  65. udata/harvest/backends/base.py +27 -1
  66. udata/harvest/backends/ckan/harvesters.py +21 -4
  67. udata/harvest/backends/dcat.py +4 -1
  68. udata/harvest/commands.py +33 -0
  69. udata/harvest/filters.py +17 -6
  70. udata/harvest/models.py +16 -0
  71. udata/harvest/permissions.py +27 -0
  72. udata/harvest/tests/ckan/test_ckan_backend.py +33 -0
  73. udata/harvest/tests/test_actions.py +46 -2
  74. udata/harvest/tests/test_api.py +161 -6
  75. udata/harvest/tests/test_base_backend.py +86 -1
  76. udata/harvest/tests/test_dcat_backend.py +68 -3
  77. udata/harvest/tests/test_filters.py +6 -0
  78. udata/i18n.py +1 -4
  79. udata/mail.py +14 -0
  80. udata/migrations/2021-08-17-harvest-integrity.py +23 -16
  81. udata/migrations/2025-10-31-create-membership-request-notifications.py +55 -0
  82. udata/migrations/2025-12-04-add-uuid-to-discussion-messages.py +28 -0
  83. udata/migrations/2025-12-16-create-transfer-request-notifications.py +69 -0
  84. udata/migrations/2026-01-14-add-default-kind-to-posts.py +17 -0
  85. udata/mongo/slug_fields.py +1 -1
  86. udata/rdf.py +65 -11
  87. udata/routing.py +2 -2
  88. udata/settings.py +11 -0
  89. udata/tasks.py +2 -0
  90. udata/templates/mail/message.html +3 -1
  91. udata/tests/api/__init__.py +7 -17
  92. udata/tests/api/test_activities_api.py +36 -0
  93. udata/tests/api/test_datasets_api.py +69 -0
  94. udata/tests/api/test_organizations_api.py +0 -3
  95. udata/tests/api/test_reports_api.py +157 -0
  96. udata/tests/api/test_user_api.py +1 -1
  97. udata/tests/apiv2/test_dataservices.py +14 -0
  98. udata/tests/apiv2/test_organizations.py +9 -0
  99. udata/tests/apiv2/test_reuses.py +11 -0
  100. udata/tests/cli/test_cli_base.py +0 -1
  101. udata/tests/dataservice/test_dataservice_tasks.py +29 -0
  102. udata/tests/dataset/test_dataset_model.py +13 -1
  103. udata/tests/dataset/test_dataset_rdf.py +164 -5
  104. udata/tests/dataset/test_dataset_tasks.py +25 -0
  105. udata/tests/frontend/test_auth.py +58 -1
  106. udata/tests/frontend/test_csv.py +0 -3
  107. udata/tests/helpers.py +31 -27
  108. udata/tests/organization/test_notifications.py +67 -2
  109. udata/tests/search/test_search_integration.py +70 -0
  110. udata/tests/site/test_site_csv_exports.py +22 -10
  111. udata/tests/test_activity.py +9 -9
  112. udata/tests/test_api_fields.py +10 -0
  113. udata/tests/test_discussions.py +5 -5
  114. udata/tests/test_legal_mails.py +359 -0
  115. udata/tests/test_notifications.py +15 -57
  116. udata/tests/test_notifications_task.py +43 -0
  117. udata/tests/test_owned.py +81 -1
  118. udata/tests/test_transfer.py +181 -2
  119. udata/tests/test_uris.py +33 -0
  120. udata/translations/ar/LC_MESSAGES/udata.mo +0 -0
  121. udata/translations/ar/LC_MESSAGES/udata.po +309 -158
  122. udata/translations/de/LC_MESSAGES/udata.mo +0 -0
  123. udata/translations/de/LC_MESSAGES/udata.po +313 -160
  124. udata/translations/es/LC_MESSAGES/udata.mo +0 -0
  125. udata/translations/es/LC_MESSAGES/udata.po +312 -160
  126. udata/translations/fr/LC_MESSAGES/udata.mo +0 -0
  127. udata/translations/fr/LC_MESSAGES/udata.po +475 -202
  128. udata/translations/it/LC_MESSAGES/udata.mo +0 -0
  129. udata/translations/it/LC_MESSAGES/udata.po +317 -162
  130. udata/translations/pt/LC_MESSAGES/udata.mo +0 -0
  131. udata/translations/pt/LC_MESSAGES/udata.po +315 -161
  132. udata/translations/sr/LC_MESSAGES/udata.mo +0 -0
  133. udata/translations/sr/LC_MESSAGES/udata.po +323 -164
  134. udata/translations/udata.pot +169 -124
  135. udata/uris.py +0 -2
  136. udata/utils.py +23 -0
  137. udata-14.7.3.dev4.dist-info/METADATA +109 -0
  138. {udata-14.0.3.dev1.dist-info → udata-14.7.3.dev4.dist-info}/RECORD +142 -135
  139. udata/core/post/forms.py +0 -30
  140. udata/flask_mongoengine/json.py +0 -38
  141. udata/templates/mail/base.html +0 -105
  142. udata/templates/mail/base.txt +0 -6
  143. udata/templates/mail/button.html +0 -3
  144. udata/templates/mail/layouts/1-column.html +0 -19
  145. udata/templates/mail/layouts/2-columns.html +0 -20
  146. udata/templates/mail/layouts/center-panel.html +0 -16
  147. udata-14.0.3.dev1.dist-info/METADATA +0 -132
  148. {udata-14.0.3.dev1.dist-info → udata-14.7.3.dev4.dist-info}/WHEEL +0 -0
  149. {udata-14.0.3.dev1.dist-info → udata-14.7.3.dev4.dist-info}/entry_points.txt +0 -0
  150. {udata-14.0.3.dev1.dist-info → udata-14.7.3.dev4.dist-info}/licenses/LICENSE +0 -0
  151. {udata-14.0.3.dev1.dist-info → udata-14.7.3.dev4.dist-info}/top_level.txt +0 -0
@@ -5,6 +5,8 @@ import pytest
5
5
  from flask import url_for
6
6
  from pytest_mock import MockerFixture
7
7
 
8
+ from udata.core.dataservices.factories import DataserviceFactory
9
+ from udata.core.dataset.factories import DatasetFactory
8
10
  from udata.core.organization.factories import OrganizationFactory
9
11
  from udata.core.user.factories import AdminFactory, UserFactory
10
12
  from udata.harvest.backends import get_enabled_backends
@@ -18,10 +20,11 @@ from ..models import (
18
20
  VALIDATION_ACCEPTED,
19
21
  VALIDATION_PENDING,
20
22
  VALIDATION_REFUSED,
23
+ HarvestItem,
21
24
  HarvestSource,
22
25
  HarvestSourceValidation,
23
26
  )
24
- from .factories import HarvestSourceFactory, MockBackendsMixin
27
+ from .factories import HarvestJobFactory, HarvestSourceFactory, MockBackendsMixin
25
28
 
26
29
  log = logging.getLogger(__name__)
27
30
 
@@ -383,7 +386,7 @@ class HarvestAPITest(MockBackendsMixin, PytestOnlyAPITestCase):
383
386
  assert source["config"] == {"custom": "value"}
384
387
 
385
388
  def test_update_source(self):
386
- """It should update a source if owner or orga member"""
389
+ """It should update a source if owner or orga admin"""
387
390
  user = self.login()
388
391
  source = HarvestSourceFactory(owner=user)
389
392
  new_url = faker.url()
@@ -398,8 +401,8 @@ class HarvestAPITest(MockBackendsMixin, PytestOnlyAPITestCase):
398
401
  assert200(response)
399
402
  assert response.json["url"] == new_url
400
403
 
401
- # Source is now owned by orga, with user as member
402
- source.organization = OrganizationFactory(members=[Member(user=user)])
404
+ # Source is now owned by orga, with user as admin
405
+ source.organization = OrganizationFactory(members=[Member(user=user, role="admin")])
403
406
  source.save()
404
407
  api_url = url_for("api.harvest_source", source=source)
405
408
  response = self.put(api_url, data)
@@ -473,8 +476,8 @@ class HarvestAPITest(MockBackendsMixin, PytestOnlyAPITestCase):
473
476
  assert404(response)
474
477
 
475
478
  def test_source_preview(self):
476
- self.login()
477
- source = HarvestSourceFactory(backend="factory")
479
+ user = self.login()
480
+ source = HarvestSourceFactory(backend="factory", owner=user)
478
481
 
479
482
  url = url_for("api.preview_harvest_source", source=source)
480
483
  response = self.get(url)
@@ -648,3 +651,155 @@ class HarvestAPITest(MockBackendsMixin, PytestOnlyAPITestCase):
648
651
 
649
652
  source.reload()
650
653
  assert source.periodic_task is not None
654
+
655
+ def test_list_items(self):
656
+ """It should fetch the harvest items list from the API for a specific job"""
657
+ job = HarvestJobFactory(
658
+ items=[
659
+ HarvestItem(dataset=DatasetFactory()),
660
+ HarvestItem(dataservice=DataserviceFactory()),
661
+ HarvestItem(dataset=DatasetFactory(), remote_url="https://my.remote.example.com"),
662
+ ],
663
+ )
664
+ response = self.get(url_for("api.harvest_job", ident=str(job.id)))
665
+ assert200(response)
666
+ assert len(response.json["items"]) == 3
667
+ assert set(response.json["items"][0].keys()) == set(
668
+ [
669
+ "created",
670
+ "started",
671
+ "ended",
672
+ "dataset",
673
+ "dataservice",
674
+ "remote_url",
675
+ "remote_id",
676
+ "args",
677
+ "errors",
678
+ "kwargs",
679
+ "logs",
680
+ "status",
681
+ ]
682
+ )
683
+ # Make sure appropriate dataset or dataservice is set
684
+ assert response.json["items"][0]["dataset"] is not None
685
+ assert response.json["items"][0]["dataservice"] is None
686
+ assert response.json["items"][1]["dataset"] is None
687
+ assert response.json["items"][1]["dataservice"] is not None
688
+ # Make sure remote_url is exposed if exists
689
+ assert response.json["items"][1]["remote_url"] is None
690
+ assert response.json["items"][2]["remote_url"] == "https://my.remote.example.com"
691
+
692
+ def test_get_source_permissions_as_anonymous(self):
693
+ """It should return all permissions as False for anonymous users"""
694
+ source = HarvestSourceFactory()
695
+
696
+ url = url_for("api.harvest_source", source=source)
697
+ response = self.get(url)
698
+ assert200(response)
699
+
700
+ assert "permissions" in response.json
701
+ permissions = response.json["permissions"]
702
+ assert permissions["edit"] is False
703
+ assert permissions["delete"] is False
704
+ assert permissions["run"] is False
705
+ assert permissions["preview"] is False
706
+ assert permissions["validate"] is False
707
+ assert permissions["schedule"] is False
708
+
709
+ def test_get_source_permissions_as_owner(self):
710
+ """It should return owner permissions as True for source owner"""
711
+ user = self.login()
712
+ source = HarvestSourceFactory(owner=user)
713
+
714
+ url = url_for("api.harvest_source", source=source)
715
+ response = self.get(url)
716
+ assert200(response)
717
+
718
+ permissions = response.json["permissions"]
719
+ assert permissions["edit"] is True
720
+ assert permissions["delete"] is True
721
+ assert permissions["run"] is True
722
+ assert permissions["preview"] is True
723
+ assert permissions["validate"] is False
724
+ assert permissions["schedule"] is False
725
+
726
+ def test_get_source_permissions_as_org_admin(self):
727
+ """It should return owner permissions as True for org admins"""
728
+ user = self.login()
729
+ member = Member(user=user, role="admin")
730
+ org = OrganizationFactory(members=[member])
731
+ source = HarvestSourceFactory(organization=org)
732
+
733
+ url = url_for("api.harvest_source", source=source)
734
+ response = self.get(url)
735
+ assert200(response)
736
+
737
+ permissions = response.json["permissions"]
738
+ assert permissions["edit"] is True
739
+ assert permissions["delete"] is True
740
+ assert permissions["run"] is True
741
+ assert permissions["preview"] is True
742
+ assert permissions["validate"] is False
743
+ assert permissions["schedule"] is False
744
+
745
+ def test_get_source_permissions_as_org_editor(self):
746
+ """It should return only preview permission as True for org editors"""
747
+ user = self.login()
748
+ member = Member(user=user, role="editor")
749
+ org = OrganizationFactory(members=[member])
750
+ source = HarvestSourceFactory(organization=org)
751
+
752
+ url = url_for("api.harvest_source", source=source)
753
+ response = self.get(url)
754
+ assert200(response)
755
+
756
+ permissions = response.json["permissions"]
757
+ assert permissions["edit"] is False
758
+ assert permissions["delete"] is False
759
+ assert permissions["run"] is False
760
+ assert permissions["preview"] is True
761
+ assert permissions["validate"] is False
762
+ assert permissions["schedule"] is False
763
+
764
+ def test_get_source_permissions_as_superadmin(self):
765
+ """It should return all permissions as True for admin users"""
766
+ self.login(AdminFactory())
767
+ source = HarvestSourceFactory()
768
+
769
+ url = url_for("api.harvest_source", source=source)
770
+ response = self.get(url)
771
+ assert200(response)
772
+
773
+ permissions = response.json["permissions"]
774
+ assert permissions["edit"] is True
775
+ assert permissions["delete"] is True
776
+ assert permissions["run"] is True
777
+ assert permissions["preview"] is True
778
+ assert permissions["validate"] is True
779
+ assert permissions["schedule"] is True
780
+
781
+ def test_get_source_permissions_as_other_user(self):
782
+ """It should return all permissions as False for non-owner users"""
783
+ self.login()
784
+ source = HarvestSourceFactory() # owned by another user
785
+
786
+ url = url_for("api.harvest_source", source=source)
787
+ response = self.get(url)
788
+ assert200(response)
789
+
790
+ permissions = response.json["permissions"]
791
+ assert permissions["edit"] is False
792
+ assert permissions["delete"] is False
793
+ assert permissions["run"] is False
794
+ assert permissions["preview"] is False
795
+ assert permissions["validate"] is False
796
+ assert permissions["schedule"] is False
797
+
798
+ def test_preview_source_require_permission(self):
799
+ """It should not allow preview if not the owner"""
800
+ self.login()
801
+ source = HarvestSourceFactory() # owned by another user
802
+
803
+ url = url_for("api.preview_harvest_source", source=source)
804
+ response = self.get(url)
805
+ assert403(response)
@@ -6,15 +6,23 @@ from voluptuous import Schema
6
6
 
7
7
  from udata.core.dataservices.factories import DataserviceFactory
8
8
  from udata.core.dataservices.models import Dataservice
9
+ from udata.core.dataservices.models import HarvestMetadata as HarvestDataserviceMetadata
9
10
  from udata.core.dataset import tasks
10
11
  from udata.core.dataset.factories import DatasetFactory
12
+ from udata.core.dataset.models import HarvestDatasetMetadata
11
13
  from udata.harvest.models import HarvestItem
12
14
  from udata.models import Dataset
13
15
  from udata.tests.api import PytestOnlyDBTestCase
14
16
  from udata.tests.helpers import assert_equal_dates
15
17
  from udata.utils import faker
16
18
 
17
- from ..backends import BaseBackend, HarvestExtraConfig, HarvestFeature, HarvestFilter
19
+ from ..backends import (
20
+ BaseBackend,
21
+ HarvestExtraConfig,
22
+ HarvestFeature,
23
+ HarvestFilter,
24
+ get_all_backends,
25
+ )
18
26
  from ..exceptions import HarvestException
19
27
  from .factories import HarvestSourceFactory
20
28
 
@@ -63,6 +71,11 @@ class FakeBackend(BaseBackend):
63
71
  setattr(dataset, key, value)
64
72
  if self.source.config.get("last_modified"):
65
73
  dataset.last_modified_internal = self.source.config["last_modified"]
74
+ if not dataset.harvest:
75
+ dataset.harvest = HarvestDatasetMetadata()
76
+ dataset.harvest.remote_url = (
77
+ f"http://www.example.com/records/dataset-url-{len(self.job.items)}"
78
+ )
66
79
  return dataset
67
80
 
68
81
  def inner_process_dataservice(self, item: HarvestItem):
@@ -73,6 +86,11 @@ class FakeBackend(BaseBackend):
73
86
  setattr(dataservice, key, value)
74
87
  if self.source.config.get("last_modified"):
75
88
  dataservice.last_modified_internal = self.source.config["last_modified"]
89
+ if not dataservice.harvest:
90
+ dataservice.harvest = HarvestDataserviceMetadata()
91
+ dataservice.harvest.remote_url = (
92
+ f"http://www.example.com/records/dataservice-url-{len(self.job.items)}"
93
+ )
76
94
  return dataservice
77
95
 
78
96
 
@@ -177,6 +195,21 @@ class BaseBackendTest(PytestOnlyDBTestCase):
177
195
  backend = FakeBackend(source)
178
196
  assert backend.get_extra_config_value("test_str") == "test"
179
197
 
198
+ def test_harvest_item_remote_url(self):
199
+ n = 3
200
+ source = HarvestSourceFactory(
201
+ config={
202
+ "dataset_remote_ids": gen_remote_IDs(n),
203
+ "dataservice_remote_ids": gen_remote_IDs(n),
204
+ }
205
+ )
206
+ backend = FakeBackend(source)
207
+
208
+ job = backend.harvest()
209
+
210
+ assert len(job.items) == 2 * n
211
+ assert all([item.remote_url for item in job.items])
212
+
180
213
  def test_harvest_source_id(self):
181
214
  nb_datasets = 3
182
215
  source = HarvestSourceFactory(config={"dataset_remote_ids": gen_remote_IDs(nb_datasets)})
@@ -421,6 +454,42 @@ class BaseBackendTest(PytestOnlyDBTestCase):
421
454
  assert dataset_reused_uri.harvest.domain == source.domain
422
455
  assert dataset_reused_uri.harvest.source_id == str(source.id)
423
456
 
457
+ def test_duplicate_remote_ids(self):
458
+ dataset_remote_ids = [
459
+ "dataset-id-1",
460
+ "dataset-id-2",
461
+ "dataset-id-3",
462
+ "dataset-id-3",
463
+ "dataset-id-1",
464
+ ]
465
+ dataservice_remote_ids = [
466
+ "dataservice-id-1",
467
+ "dataservice-id-2",
468
+ "dataservice-id-2",
469
+ ]
470
+ source = HarvestSourceFactory(
471
+ config={
472
+ "dataset_remote_ids": dataset_remote_ids,
473
+ "dataservice_remote_ids": dataservice_remote_ids,
474
+ }
475
+ )
476
+ backend = FakeBackend(source)
477
+
478
+ job = backend.harvest()
479
+
480
+ assert job.status == "done-errors"
481
+ assert len(job.items) == len(dataset_remote_ids) + len(dataservice_remote_ids)
482
+ assert Dataset.objects.count() == len(set(dataset_remote_ids))
483
+ assert Dataservice.objects.count() == len(set(dataservice_remote_ids))
484
+ seen = set()
485
+ for job in job.items:
486
+ if job.remote_id not in seen:
487
+ assert job.status == "done"
488
+ seen.add(job.remote_id)
489
+ else:
490
+ assert job.status == "failed"
491
+ assert job.remote_id in job.errors[0].message
492
+
424
493
 
425
494
  class BaseBackendValidateTest(PytestOnlyDBTestCase):
426
495
  @pytest.fixture
@@ -497,3 +566,19 @@ class BaseBackendValidateTest(PytestOnlyDBTestCase):
497
566
  assert "[nested.0.other-bad-value] expected int: wrong" in msg
498
567
  assert "[nested.1.bad-value] expected str: 43" in msg
499
568
  assert "[nested.1.other-bad-value] expected int: bad" in msg
569
+
570
+
571
+ class AllBackendsTest:
572
+ def test_all_backends_have_unique_display_name(self):
573
+ """Ensure all harvest backends have unique display_name values."""
574
+ backends = get_all_backends()
575
+
576
+ display_names = {}
577
+ for name, backend in backends.items():
578
+ display_name = backend.display_name
579
+ assert display_name is not None, f"Backend '{name}' has no display_name"
580
+ assert display_name not in display_names, (
581
+ f"Duplicate display_name '{display_name}' found in backends "
582
+ f"'{display_names[display_name]}' and '{name}'"
583
+ )
584
+ display_names[display_name] = name
@@ -4,6 +4,7 @@ import xml.etree.ElementTree as ET
4
4
  from datetime import date
5
5
 
6
6
  import pytest
7
+ import requests
7
8
  from flask import current_app
8
9
  from lxml import etree
9
10
  from rdflib import Graph
@@ -729,7 +730,10 @@ class DcatBackendTest(PytestOnlyDBTestCase):
729
730
  assert dataset.contact_points[0].email == "sav.bd@ign.fr"
730
731
  assert dataset.contact_points[0].role == "rightsHolder"
731
732
 
732
- assert dataset.contact_points[1].name == "Administrateur de Données"
733
+ assert (
734
+ dataset.contact_points[1].name
735
+ == "Administrateur de Données (Direction Régionale de l’Environnement de l’Aménagement et du Logement d'Auvergne-Rhône-Alpes (DREAL Auvergne-Rhône-Alpes))"
736
+ )
733
737
  assert dataset.contact_points[1].email == "sig.dreal-ara@developpement-durable.gouv.fr"
734
738
  assert dataset.contact_points[1].role == "user"
735
739
 
@@ -744,11 +748,17 @@ class DcatBackendTest(PytestOnlyDBTestCase):
744
748
  assert dataset is not None
745
749
  assert len(dataset.contact_points) == 3
746
750
 
747
- assert dataset.contact_points[0].name == "Administrateur de Données"
751
+ assert (
752
+ dataset.contact_points[0].name
753
+ == "Administrateur de Données (Direction Régionale de l’Environnement de l’Aménagement et du Logement d'Auvergne-Rhône-Alpes (DREAL Auvergne-Rhône-Alpes))"
754
+ )
748
755
  assert dataset.contact_points[0].email == "sig.dreal-ara@developpement-durable.gouv.fr"
749
756
  assert dataset.contact_points[0].role == "contact"
750
757
 
751
- assert dataset.contact_points[1].name == "Jean-Michel GENIS"
758
+ assert (
759
+ dataset.contact_points[1].name
760
+ == "Jean-Michel GENIS (Conservatoire Botanique National Alpin)"
761
+ )
752
762
  assert dataset.contact_points[1].email == "jm.genis@cbn-alpin.fr"
753
763
  assert dataset.contact_points[1].role == "rightsHolder"
754
764
 
@@ -931,6 +941,61 @@ class DcatBackendTest(PytestOnlyDBTestCase):
931
941
  assert len(job.errors) == 1
932
942
  assert "404 Client Error" in job.errors[0].message
933
943
 
944
+ @pytest.mark.parametrize(
945
+ "exception",
946
+ [
947
+ requests.exceptions.ConnectTimeout("Connection timed out"),
948
+ requests.exceptions.ConnectionError(
949
+ "Failed to resolve 'example.com' (Name resolution failed)"
950
+ ),
951
+ requests.exceptions.SSLError("SSL: CERTIFICATE_VERIFY_FAILED"),
952
+ ],
953
+ )
954
+ def test_connection_errors_are_handled_without_sentry(self, rmock, mocker, exception):
955
+ """Connection exceptions should be logged as warning, not sent to Sentry."""
956
+ url = DCAT_URL_PATTERN.format(path="test.jsonld", domain=TEST_DOMAIN)
957
+ rmock.get(url, exc=exception)
958
+
959
+ source = HarvestSourceFactory(backend="dcat", url=url, organization=OrganizationFactory())
960
+
961
+ mock_warning = mocker.patch("udata.harvest.backends.base.log.warning")
962
+ mock_exception = mocker.patch("udata.harvest.backends.base.log.exception")
963
+
964
+ actions.run(source)
965
+ source.reload()
966
+
967
+ job = source.get_last_job()
968
+ assert job.status == "failed"
969
+ assert len(job.errors) == 1
970
+ assert str(exception) in job.errors[0].message
971
+ mock_warning.assert_called_once()
972
+ assert "connection error" in mock_warning.call_args[0][0].lower()
973
+ mock_exception.assert_not_called()
974
+
975
+ def test_preview_does_not_create_contact_points(self, rmock):
976
+ """Preview should not create ContactPoints in DB."""
977
+ from udata.core.contact_point.models import ContactPoint
978
+
979
+ LicenseFactory(id="lov2", title="Licence Ouverte Version 2.0")
980
+ LicenseFactory(id="lov1", title="Licence Ouverte Version 1.0")
981
+
982
+ url = mock_dcat(rmock, "catalog.xml", path="catalog.xml")
983
+ org = OrganizationFactory()
984
+ source = HarvestSourceFactory(backend="dcat", url=url, organization=org)
985
+
986
+ assert ContactPoint.objects.count() == 0
987
+
988
+ job = actions.preview(source)
989
+
990
+ assert job.status == "done"
991
+ assert len(job.items) == 4
992
+
993
+ # No ContactPoints should have been created in the database
994
+ assert ContactPoint.objects.count() == 0
995
+
996
+ # No datasets should have been created either
997
+ assert Dataset.objects.count() == 0
998
+
934
999
 
935
1000
  @pytest.mark.options(HARVESTER_BACKENDS=["csw*"])
936
1001
  class CswDcatBackendTest(PytestOnlyDBTestCase):
@@ -40,6 +40,12 @@ class FiltersTest:
40
40
  with pytest.raises(Invalid):
41
41
  filters.boolean("vrai")
42
42
 
43
+ with pytest.raises(Invalid):
44
+ filters.boolean("42")
45
+
46
+ with pytest.raises(Invalid):
47
+ filters.boolean({"key": "value"})
48
+
43
49
  def test_empty_none(self):
44
50
  empty_values = 0, "", [], {}
45
51
  non_empty_values = "hello", " hello "
udata/i18n.py CHANGED
@@ -1,5 +1,4 @@
1
1
  from contextlib import contextmanager
2
- from importlib import resources
3
2
  from importlib.metadata import entry_points
4
3
 
5
4
  import flask_babel
@@ -18,9 +17,7 @@ def get_translation_directories_and_domains():
18
17
 
19
18
  for pkg in entry_points(group="udata.i18n"):
20
19
  module = pkg.load()
21
- path = resources.files(module)
22
- # `/ ""` is here to transform MultiplexedPath to a simple str
23
- translations_dirs.append(str(path / ""))
20
+ translations_dirs.append(module.__path__[0])
24
21
  domains.append(pkg.name)
25
22
 
26
23
  return translations_dirs, domains
udata/mail.py CHANGED
@@ -39,6 +39,20 @@ class LabelledContent:
39
39
  )
40
40
 
41
41
 
42
+ @dataclass
43
+ class Link:
44
+ """Simple linkable object for use in ParagraphWithLinks"""
45
+
46
+ label: str
47
+ url: str
48
+
49
+ def __str__(self):
50
+ return str(self.label)
51
+
52
+ def url_for(self, **kwargs):
53
+ return self.url
54
+
55
+
42
56
  @dataclass
43
57
  class ParagraphWithLinks:
44
58
  paragraph: LazyString
@@ -5,6 +5,7 @@ Remove Harvest db integrity problems
5
5
 
6
6
  import logging
7
7
 
8
+ import click
8
9
  import mongoengine
9
10
 
10
11
  from udata.core.jobs.models import PeriodicTask
@@ -16,29 +17,35 @@ log = logging.getLogger(__name__)
16
17
  def migrate(db):
17
18
  log.info("Processing HarvestJob source references.")
18
19
 
19
- harvest_jobs = HarvestJob.objects().no_cache().all()
20
+ harvest_jobs = HarvestJob.objects().no_cache()
21
+ total = harvest_jobs.count()
20
22
  count = 0
21
- for harvest_job in harvest_jobs:
22
- try:
23
- harvest_job.source.id
24
- except mongoengine.errors.DoesNotExist:
25
- count += 1
26
- harvest_job.delete()
23
+ with click.progressbar(harvest_jobs, length=total, label="Checking sources refs") as jobs:
24
+ for harvest_job in jobs:
25
+ try:
26
+ if harvest_job.source is None:
27
+ raise mongoengine.errors.DoesNotExist()
28
+ harvest_job.source.id
29
+ except mongoengine.errors.DoesNotExist:
30
+ count += 1
31
+ harvest_job.delete()
27
32
 
28
33
  log.info(f"Completed, removed {count} HarvestJob objects")
29
34
 
30
35
  log.info("Processing HarvestJob items references.")
31
36
 
32
- harvest_jobs = HarvestJob.objects.filter(items__0__exists=True).no_cache().all()
37
+ harvest_jobs = HarvestJob.objects.filter(items__0__exists=True).no_cache()
38
+ total = harvest_jobs.count()
33
39
  count = 0
34
- for harvest_job in harvest_jobs:
35
- for item in harvest_job.items:
36
- try:
37
- item.dataset and item.dataset.id
38
- except mongoengine.errors.DoesNotExist:
39
- count += 1
40
- item.dataset = None
41
- harvest_job.save()
40
+ with click.progressbar(harvest_jobs, length=total, label="Checking items refs") as jobs:
41
+ for harvest_job in jobs:
42
+ for item in harvest_job.items:
43
+ try:
44
+ item.dataset and item.dataset.id
45
+ except mongoengine.errors.DoesNotExist:
46
+ count += 1
47
+ item.dataset = None
48
+ harvest_job.save()
42
49
 
43
50
  log.info(f"Completed, modified {count} HarvestJob objects")
44
51
 
@@ -0,0 +1,55 @@
1
+ """
2
+ Create MembershipRequestNotification for all pending membership requests
3
+ """
4
+
5
+ import logging
6
+
7
+ import click
8
+
9
+ from udata.core.organization.models import Organization
10
+ from udata.core.organization.notifications import MembershipRequestNotificationDetails
11
+ from udata.features.notifications.models import Notification
12
+
13
+ log = logging.getLogger(__name__)
14
+
15
+
16
+ def migrate(db):
17
+ log.info("Processing pending membership requests...")
18
+
19
+ created_count = 0
20
+
21
+ with click.progressbar(
22
+ Organization.objects, length=Organization.objects().count()
23
+ ) as organizations:
24
+ for org in organizations:
25
+ # Get all admin users for this organization
26
+ admin_users = [member.user for member in org.members if member.role == "admin"]
27
+
28
+ # Process each pending request
29
+ for request in org.pending_requests:
30
+ # Create a notification for each admin user
31
+ for admin_user in admin_users:
32
+ try:
33
+ # Check if notification already exists
34
+ existing = Notification.objects(
35
+ user=admin_user,
36
+ details__request_organization=org,
37
+ details__request_user=request.user,
38
+ ).first()
39
+ if not existing:
40
+ notification = Notification(user=admin_user)
41
+ notification.details = MembershipRequestNotificationDetails(
42
+ request_organization=org, request_user=request.user
43
+ )
44
+ # Set the created_at to match the request creation date
45
+ notification.created_at = request.created
46
+ notification.save()
47
+ created_count += 1
48
+ except Exception as e:
49
+ log.error(
50
+ f"Error creating notification for user {admin_user.id} "
51
+ f"and organization {org.id}: {e}"
52
+ )
53
+ continue
54
+
55
+ log.info(f"Created {created_count} MembershipRequestNotifications")
@@ -0,0 +1,28 @@
1
+ """
2
+ This migration adds UUIDs to existing discussion messages that don't have one.
3
+ """
4
+
5
+ import logging
6
+
7
+ import click
8
+
9
+ from udata.core.discussions.models import Discussion
10
+
11
+ log = logging.getLogger(__name__)
12
+
13
+
14
+ def migrate(db):
15
+ log.info("Adding UUIDs to discussion messages...")
16
+
17
+ # Find all discussions that have at least one message without an id
18
+ discussions = Discussion.objects(
19
+ __raw__={"discussion": {"$elemMatch": {"id": {"$exists": False}}}}
20
+ )
21
+ count = discussions.count()
22
+
23
+ with click.progressbar(discussions, length=count) as progress:
24
+ for discussion in progress:
25
+ discussion._mark_as_changed("discussion")
26
+ discussion.save()
27
+
28
+ log.info(f"Migration complete. {count} discussions updated.")