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.
- udata/api/__init__.py +2 -0
- udata/api_fields.py +120 -19
- udata/app.py +18 -20
- udata/auth/__init__.py +4 -7
- udata/auth/forms.py +3 -3
- udata/auth/views.py +13 -6
- udata/commands/dcat.py +1 -1
- udata/commands/serve.py +3 -11
- udata/core/activity/api.py +5 -6
- udata/core/badges/tests/test_tasks.py +0 -2
- udata/core/csv.py +5 -0
- udata/core/dataservices/api.py +8 -1
- udata/core/dataservices/apiv2.py +3 -6
- udata/core/dataservices/models.py +5 -2
- udata/core/dataservices/rdf.py +2 -1
- udata/core/dataservices/tasks.py +6 -2
- udata/core/dataset/api.py +30 -4
- udata/core/dataset/api_fields.py +1 -1
- udata/core/dataset/apiv2.py +1 -1
- udata/core/dataset/constants.py +2 -9
- udata/core/dataset/models.py +21 -9
- udata/core/dataset/permissions.py +31 -0
- udata/core/dataset/rdf.py +18 -16
- udata/core/dataset/tasks.py +16 -7
- udata/core/discussions/api.py +15 -1
- udata/core/discussions/models.py +6 -0
- udata/core/legal/__init__.py +0 -0
- udata/core/legal/mails.py +128 -0
- udata/core/organization/api.py +16 -5
- udata/core/organization/api_fields.py +3 -3
- udata/core/organization/apiv2.py +3 -4
- udata/core/organization/mails.py +1 -1
- udata/core/organization/models.py +40 -7
- udata/core/organization/notifications.py +84 -0
- udata/core/organization/permissions.py +1 -1
- udata/core/organization/tasks.py +3 -0
- udata/core/pages/models.py +49 -0
- udata/core/pages/tests/test_api.py +165 -1
- udata/core/post/api.py +25 -70
- udata/core/post/constants.py +8 -0
- udata/core/post/models.py +109 -17
- udata/core/post/tests/test_api.py +140 -3
- udata/core/post/tests/test_models.py +24 -0
- udata/core/reports/api.py +18 -0
- udata/core/reports/models.py +42 -2
- udata/core/reuse/api.py +8 -0
- udata/core/reuse/apiv2.py +3 -6
- udata/core/reuse/models.py +1 -1
- udata/core/spatial/forms.py +2 -2
- udata/core/topic/models.py +8 -2
- udata/core/user/api.py +10 -3
- udata/core/user/api_fields.py +3 -3
- udata/core/user/models.py +33 -8
- udata/features/notifications/api.py +7 -18
- udata/features/notifications/models.py +59 -0
- udata/features/notifications/tasks.py +25 -0
- udata/features/transfer/actions.py +2 -0
- udata/features/transfer/models.py +17 -0
- udata/features/transfer/notifications.py +96 -0
- udata/flask_mongoengine/engine.py +0 -4
- udata/flask_mongoengine/pagination.py +1 -1
- udata/frontend/markdown.py +2 -1
- udata/harvest/actions.py +20 -0
- udata/harvest/api.py +24 -7
- udata/harvest/backends/base.py +27 -1
- udata/harvest/backends/ckan/harvesters.py +21 -4
- udata/harvest/backends/dcat.py +4 -1
- udata/harvest/commands.py +33 -0
- udata/harvest/filters.py +17 -6
- udata/harvest/models.py +16 -0
- udata/harvest/permissions.py +27 -0
- udata/harvest/tests/ckan/test_ckan_backend.py +33 -0
- udata/harvest/tests/test_actions.py +46 -2
- udata/harvest/tests/test_api.py +161 -6
- udata/harvest/tests/test_base_backend.py +86 -1
- udata/harvest/tests/test_dcat_backend.py +68 -3
- udata/harvest/tests/test_filters.py +6 -0
- udata/i18n.py +1 -4
- udata/mail.py +14 -0
- udata/migrations/2021-08-17-harvest-integrity.py +23 -16
- udata/migrations/2025-10-31-create-membership-request-notifications.py +55 -0
- udata/migrations/2025-12-04-add-uuid-to-discussion-messages.py +28 -0
- udata/migrations/2025-12-16-create-transfer-request-notifications.py +69 -0
- udata/migrations/2026-01-14-add-default-kind-to-posts.py +17 -0
- udata/mongo/slug_fields.py +1 -1
- udata/rdf.py +65 -11
- udata/routing.py +2 -2
- udata/settings.py +11 -0
- udata/tasks.py +2 -0
- udata/templates/mail/message.html +3 -1
- udata/tests/api/__init__.py +7 -17
- udata/tests/api/test_activities_api.py +36 -0
- udata/tests/api/test_datasets_api.py +69 -0
- udata/tests/api/test_organizations_api.py +0 -3
- udata/tests/api/test_reports_api.py +157 -0
- udata/tests/api/test_user_api.py +1 -1
- udata/tests/apiv2/test_dataservices.py +14 -0
- udata/tests/apiv2/test_organizations.py +9 -0
- udata/tests/apiv2/test_reuses.py +11 -0
- udata/tests/cli/test_cli_base.py +0 -1
- udata/tests/dataservice/test_dataservice_tasks.py +29 -0
- udata/tests/dataset/test_dataset_model.py +13 -1
- udata/tests/dataset/test_dataset_rdf.py +164 -5
- udata/tests/dataset/test_dataset_tasks.py +25 -0
- udata/tests/frontend/test_auth.py +58 -1
- udata/tests/frontend/test_csv.py +0 -3
- udata/tests/helpers.py +31 -27
- udata/tests/organization/test_notifications.py +67 -2
- udata/tests/search/test_search_integration.py +70 -0
- udata/tests/site/test_site_csv_exports.py +22 -10
- udata/tests/test_activity.py +9 -9
- udata/tests/test_api_fields.py +10 -0
- udata/tests/test_discussions.py +5 -5
- udata/tests/test_legal_mails.py +359 -0
- udata/tests/test_notifications.py +15 -57
- udata/tests/test_notifications_task.py +43 -0
- udata/tests/test_owned.py +81 -1
- udata/tests/test_transfer.py +181 -2
- udata/tests/test_uris.py +33 -0
- udata/translations/ar/LC_MESSAGES/udata.mo +0 -0
- udata/translations/ar/LC_MESSAGES/udata.po +309 -158
- udata/translations/de/LC_MESSAGES/udata.mo +0 -0
- udata/translations/de/LC_MESSAGES/udata.po +313 -160
- udata/translations/es/LC_MESSAGES/udata.mo +0 -0
- udata/translations/es/LC_MESSAGES/udata.po +312 -160
- udata/translations/fr/LC_MESSAGES/udata.mo +0 -0
- udata/translations/fr/LC_MESSAGES/udata.po +475 -202
- udata/translations/it/LC_MESSAGES/udata.mo +0 -0
- udata/translations/it/LC_MESSAGES/udata.po +317 -162
- udata/translations/pt/LC_MESSAGES/udata.mo +0 -0
- udata/translations/pt/LC_MESSAGES/udata.po +315 -161
- udata/translations/sr/LC_MESSAGES/udata.mo +0 -0
- udata/translations/sr/LC_MESSAGES/udata.po +323 -164
- udata/translations/udata.pot +169 -124
- udata/uris.py +0 -2
- udata/utils.py +23 -0
- udata-14.7.3.dev4.dist-info/METADATA +109 -0
- {udata-14.0.3.dev1.dist-info → udata-14.7.3.dev4.dist-info}/RECORD +142 -135
- udata/core/post/forms.py +0 -30
- udata/flask_mongoengine/json.py +0 -38
- udata/templates/mail/base.html +0 -105
- udata/templates/mail/base.txt +0 -6
- udata/templates/mail/button.html +0 -3
- udata/templates/mail/layouts/1-column.html +0 -19
- udata/templates/mail/layouts/2-columns.html +0 -20
- udata/templates/mail/layouts/center-panel.html +0 -16
- udata-14.0.3.dev1.dist-info/METADATA +0 -132
- {udata-14.0.3.dev1.dist-info → udata-14.7.3.dev4.dist-info}/WHEEL +0 -0
- {udata-14.0.3.dev1.dist-info → udata-14.7.3.dev4.dist-info}/entry_points.txt +0 -0
- {udata-14.0.3.dev1.dist-info → udata-14.7.3.dev4.dist-info}/licenses/LICENSE +0 -0
- {udata-14.0.3.dev1.dist-info → udata-14.7.3.dev4.dist-info}/top_level.txt +0 -0
udata/harvest/tests/test_api.py
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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()
|
|
20
|
+
harvest_jobs = HarvestJob.objects().no_cache()
|
|
21
|
+
total = harvest_jobs.count()
|
|
20
22
|
count = 0
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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()
|
|
37
|
+
harvest_jobs = HarvestJob.objects.filter(items__0__exists=True).no_cache()
|
|
38
|
+
total = harvest_jobs.count()
|
|
33
39
|
count = 0
|
|
34
|
-
|
|
35
|
-
for
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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.")
|