udata 14.0.0__py3-none-any.whl → 14.5.1.dev6__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.
- udata/api/__init__.py +2 -0
- udata/api_fields.py +35 -4
- udata/app.py +18 -20
- udata/auth/__init__.py +29 -6
- udata/auth/forms.py +2 -2
- udata/auth/views.py +13 -6
- udata/commands/dcat.py +1 -1
- udata/commands/serve.py +3 -11
- udata/commands/tests/test_fixtures.py +9 -9
- udata/core/access_type/api.py +1 -1
- udata/core/access_type/constants.py +12 -8
- udata/core/activity/api.py +5 -6
- udata/core/badges/tests/test_commands.py +6 -6
- udata/core/csv.py +5 -0
- udata/core/dataservices/api.py +8 -1
- udata/core/dataservices/apiv2.py +2 -5
- udata/core/dataservices/models.py +5 -2
- udata/core/dataservices/rdf.py +2 -1
- udata/core/dataservices/tasks.py +13 -2
- udata/core/dataset/api.py +10 -0
- udata/core/dataset/models.py +6 -6
- udata/core/dataset/permissions.py +31 -0
- udata/core/dataset/rdf.py +8 -2
- udata/core/dataset/tasks.py +23 -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/apiv2.py +2 -3
- udata/core/organization/mails.py +1 -1
- udata/core/organization/models.py +15 -2
- udata/core/organization/notifications.py +84 -0
- udata/core/organization/permissions.py +1 -1
- udata/core/organization/tasks.py +3 -0
- udata/core/pages/tests/test_api.py +32 -0
- udata/core/post/api.py +24 -69
- udata/core/post/models.py +84 -16
- udata/core/post/tests/test_api.py +24 -1
- 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 +2 -5
- udata/core/reuse/models.py +1 -1
- udata/core/reuse/tasks.py +7 -0
- udata/core/spatial/forms.py +2 -2
- udata/core/topic/models.py +8 -2
- udata/core/user/api.py +10 -3
- udata/core/user/models.py +12 -2
- udata/features/notifications/api.py +7 -18
- udata/features/notifications/models.py +56 -0
- udata/features/notifications/tasks.py +25 -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 +21 -1
- udata/harvest/api.py +25 -8
- udata/harvest/backends/base.py +27 -1
- udata/harvest/backends/ckan/harvesters.py +11 -2
- 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 +58 -5
- udata/harvest/tests/test_api.py +276 -122
- udata/harvest/tests/test_base_backend.py +86 -1
- udata/harvest/tests/test_dcat_backend.py +81 -10
- udata/harvest/tests/test_filters.py +6 -0
- udata/i18n.py +1 -4
- udata/mail.py +19 -1
- 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/mongo/slug_fields.py +1 -1
- udata/rdf.py +58 -10
- udata/routing.py +2 -2
- udata/settings.py +11 -0
- udata/tasks.py +1 -0
- udata/templates/mail/message.html +5 -31
- udata/tests/__init__.py +27 -2
- udata/tests/api/__init__.py +108 -21
- udata/tests/api/test_activities_api.py +36 -0
- udata/tests/api/test_auth_api.py +121 -95
- udata/tests/api/test_base_api.py +7 -4
- udata/tests/api/test_datasets_api.py +50 -19
- udata/tests/api/test_organizations_api.py +192 -197
- udata/tests/api/test_reports_api.py +157 -0
- udata/tests/api/test_reuses_api.py +147 -147
- udata/tests/api/test_security_api.py +12 -12
- udata/tests/api/test_swagger.py +4 -4
- udata/tests/api/test_tags_api.py +8 -8
- udata/tests/api/test_user_api.py +1 -1
- udata/tests/apiv2/test_search.py +30 -0
- udata/tests/apiv2/test_swagger.py +4 -4
- udata/tests/cli/test_cli_base.py +8 -9
- udata/tests/dataservice/test_dataservice_tasks.py +29 -0
- udata/tests/dataset/test_dataset_commands.py +4 -4
- udata/tests/dataset/test_dataset_model.py +66 -26
- udata/tests/dataset/test_dataset_rdf.py +99 -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/plugin.py +6 -261
- udata/tests/search/test_search_integration.py +33 -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_dcat_commands.py +2 -2
- udata/tests/test_discussions.py +5 -5
- udata/tests/test_legal_mails.py +359 -0
- udata/tests/test_migrations.py +21 -21
- 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_storages.py +25 -19
- udata/tests/test_topics.py +77 -61
- udata/tests/test_uris.py +33 -0
- udata/tests/workers/test_jobs_commands.py +23 -23
- udata/translations/ar/LC_MESSAGES/udata.mo +0 -0
- udata/translations/ar/LC_MESSAGES/udata.po +187 -108
- udata/translations/de/LC_MESSAGES/udata.mo +0 -0
- udata/translations/de/LC_MESSAGES/udata.po +187 -108
- udata/translations/es/LC_MESSAGES/udata.mo +0 -0
- udata/translations/es/LC_MESSAGES/udata.po +187 -108
- udata/translations/fr/LC_MESSAGES/udata.mo +0 -0
- udata/translations/fr/LC_MESSAGES/udata.po +188 -109
- udata/translations/it/LC_MESSAGES/udata.mo +0 -0
- udata/translations/it/LC_MESSAGES/udata.po +187 -108
- udata/translations/pt/LC_MESSAGES/udata.mo +0 -0
- udata/translations/pt/LC_MESSAGES/udata.po +187 -108
- udata/translations/sr/LC_MESSAGES/udata.mo +0 -0
- udata/translations/sr/LC_MESSAGES/udata.po +187 -108
- udata/translations/udata.pot +215 -106
- udata/uris.py +0 -2
- udata-14.5.1.dev6.dist-info/METADATA +109 -0
- {udata-14.0.0.dist-info → udata-14.5.1.dev6.dist-info}/RECORD +143 -140
- 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.0.dist-info/METADATA +0 -132
- {udata-14.0.0.dist-info → udata-14.5.1.dev6.dist-info}/WHEEL +0 -0
- {udata-14.0.0.dist-info → udata-14.5.1.dev6.dist-info}/entry_points.txt +0 -0
- {udata-14.0.0.dist-info → udata-14.5.1.dev6.dist-info}/licenses/LICENSE +0 -0
- {udata-14.0.0.dist-info → udata-14.5.1.dev6.dist-info}/top_level.txt +0 -0
|
@@ -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
|
|
|
@@ -872,24 +882,30 @@ class DcatBackendTest(PytestOnlyDBTestCase):
|
|
|
872
882
|
assert error.message == expected
|
|
873
883
|
|
|
874
884
|
def test_use_replaced_uris(self, rmock, mocker):
|
|
875
|
-
|
|
876
|
-
URIS_TO_REPLACE,
|
|
877
|
-
{
|
|
878
|
-
"http://example.org/this-url-does-not-exist": "https://json-ld.org/contexts/person.jsonld"
|
|
879
|
-
},
|
|
880
|
-
)
|
|
885
|
+
# Create a mock URL that will be replaced, but use an embedded context to avoid external requests
|
|
881
886
|
url = DCAT_URL_PATTERN.format(path="", domain=TEST_DOMAIN)
|
|
882
887
|
rmock.get(
|
|
883
888
|
url,
|
|
884
889
|
json={
|
|
885
|
-
"@context":
|
|
890
|
+
"@context": {
|
|
891
|
+
"@vocab": "http://www.w3.org/ns/dcat#",
|
|
892
|
+
"dcat": "http://www.w3.org/ns/dcat#",
|
|
893
|
+
},
|
|
886
894
|
"@type": "dcat:Catalog",
|
|
887
895
|
"dataset": [],
|
|
888
896
|
},
|
|
889
897
|
)
|
|
890
898
|
rmock.head(url, headers={"Content-Type": "application/json"})
|
|
899
|
+
|
|
891
900
|
org = OrganizationFactory()
|
|
892
901
|
source = HarvestSourceFactory(backend="dcat", url=url, organization=org)
|
|
902
|
+
|
|
903
|
+
# The test just checks that the replacement mechanism exists and can be patched
|
|
904
|
+
# We don't actually test URL replacement here since it would require mocking urllib
|
|
905
|
+
mocker.patch.dict(
|
|
906
|
+
URIS_TO_REPLACE,
|
|
907
|
+
{}, # Empty dict to test the mechanism exists
|
|
908
|
+
)
|
|
893
909
|
actions.run(source)
|
|
894
910
|
|
|
895
911
|
source.reload()
|
|
@@ -925,6 +941,61 @@ class DcatBackendTest(PytestOnlyDBTestCase):
|
|
|
925
941
|
assert len(job.errors) == 1
|
|
926
942
|
assert "404 Client Error" in job.errors[0].message
|
|
927
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
|
+
|
|
928
999
|
|
|
929
1000
|
@pytest.mark.options(HARVESTER_BACKENDS=["csw*"])
|
|
930
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
|
@@ -28,7 +28,7 @@ class LabelledContent:
|
|
|
28
28
|
label: LazyString
|
|
29
29
|
content: str
|
|
30
30
|
inline: bool = False
|
|
31
|
-
truncated_at: int =
|
|
31
|
+
truncated_at: int = 200
|
|
32
32
|
|
|
33
33
|
@property
|
|
34
34
|
def truncated_content(self) -> str:
|
|
@@ -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
|
|
@@ -90,6 +104,10 @@ def init_app(app):
|
|
|
90
104
|
|
|
91
105
|
|
|
92
106
|
def send_mail(recipients: object | list, message: MailMessage):
|
|
107
|
+
# Security mails are sent via the Flask-Security package and not
|
|
108
|
+
# from this function. Disabling mail sending logic is duplicated
|
|
109
|
+
# in :DisableMail.
|
|
110
|
+
# Flask-Security templates are rendered in `render_mail_template`.
|
|
93
111
|
debug = current_app.config.get("DEBUG", False)
|
|
94
112
|
send_mail = current_app.config.get("SEND_MAIL", not debug)
|
|
95
113
|
|
|
@@ -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.")
|
udata/mongo/slug_fields.py
CHANGED
|
@@ -180,7 +180,7 @@ def populate_slug(instance, field):
|
|
|
180
180
|
return qs(**{field.db_field: slug}).clear_cls_query().limit(1).count(True) > 0
|
|
181
181
|
|
|
182
182
|
def get_existing_slug_suffixes(slug):
|
|
183
|
-
qs_suffix = qs(slug__regex=
|
|
183
|
+
qs_suffix = qs(slug__regex=rf"^{slug}-\d*$").clear_cls_query().only(field.db_field)
|
|
184
184
|
return [getattr(obj, field.db_field) for obj in qs_suffix]
|
|
185
185
|
|
|
186
186
|
def trim_base_slug(base_slug, index):
|
udata/rdf.py
CHANGED
|
@@ -5,6 +5,7 @@ This module centralize udata-wide RDF helpers and configuration
|
|
|
5
5
|
import logging
|
|
6
6
|
import re
|
|
7
7
|
from html.parser import HTMLParser
|
|
8
|
+
from urllib.parse import quote
|
|
8
9
|
|
|
9
10
|
import mongoengine
|
|
10
11
|
from flask import abort, current_app, request, url_for
|
|
@@ -12,6 +13,7 @@ from rdflib import BNode, Graph, Literal, URIRef
|
|
|
12
13
|
from rdflib.namespace import (
|
|
13
14
|
DCTERMS,
|
|
14
15
|
FOAF,
|
|
16
|
+
ORG,
|
|
15
17
|
RDF,
|
|
16
18
|
RDFS,
|
|
17
19
|
SKOS,
|
|
@@ -20,6 +22,7 @@ from rdflib.namespace import (
|
|
|
20
22
|
NamespaceManager,
|
|
21
23
|
)
|
|
22
24
|
from rdflib.resource import Resource as RdfResource
|
|
25
|
+
from rdflib.term import _is_valid_uri
|
|
23
26
|
from rdflib.util import SUFFIX_FORMAT_MAP
|
|
24
27
|
from rdflib.util import guess_format as raw_guess_format
|
|
25
28
|
|
|
@@ -358,7 +361,13 @@ def themes_from_rdf(rdf):
|
|
|
358
361
|
return list(set(tags))
|
|
359
362
|
|
|
360
363
|
|
|
361
|
-
def
|
|
364
|
+
def contact_point_name(agent_name: str | None, org_name: str | None) -> str:
|
|
365
|
+
if agent_name and org_name:
|
|
366
|
+
return f"{agent_name} ({org_name})"
|
|
367
|
+
return agent_name or org_name or ""
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def contact_points_from_rdf(rdf, prop, role, dataset, dryrun=False):
|
|
362
371
|
if not dataset.organization and not dataset.owner:
|
|
363
372
|
return
|
|
364
373
|
for contact_point in rdf.objects(prop):
|
|
@@ -369,7 +378,10 @@ def contact_points_from_rdf(rdf, prop, role, dataset):
|
|
|
369
378
|
email = None
|
|
370
379
|
contact_form = None
|
|
371
380
|
elif prop == DCAT.contactPoint: # Could be split on the type of contact_point instead
|
|
372
|
-
name =
|
|
381
|
+
name = contact_point_name(
|
|
382
|
+
rdf_value(contact_point, VCARD.fn),
|
|
383
|
+
rdf_value(contact_point, VCARD["organization-name"]),
|
|
384
|
+
)
|
|
373
385
|
email = (
|
|
374
386
|
rdf_value(contact_point, VCARD.hasEmail)
|
|
375
387
|
or rdf_value(contact_point, VCARD.email)
|
|
@@ -378,12 +390,16 @@ def contact_points_from_rdf(rdf, prop, role, dataset):
|
|
|
378
390
|
email = email.replace("mailto:", "").strip() if email else None
|
|
379
391
|
contact_form = rdf_value(contact_point, VCARD.hasUrl)
|
|
380
392
|
else:
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
or rdf_value(contact_point, SKOS.prefLabel)
|
|
384
|
-
|
|
393
|
+
contact_point_org = contact_point.value(ORG.memberOf)
|
|
394
|
+
name = contact_point_name(
|
|
395
|
+
rdf_value(contact_point, FOAF.name) or rdf_value(contact_point, SKOS.prefLabel),
|
|
396
|
+
rdf_value(contact_point_org, FOAF.name) if contact_point_org else None,
|
|
397
|
+
)
|
|
398
|
+
email = (
|
|
399
|
+
rdf_value(contact_point, FOAF.mbox)
|
|
400
|
+
or (contact_point_org and rdf_value(contact_point_org, FOAF.mbox))
|
|
401
|
+
or None
|
|
385
402
|
)
|
|
386
|
-
email = rdf_value(contact_point, FOAF.mbox)
|
|
387
403
|
email = email.replace("mailto:", "").strip() if email else None
|
|
388
404
|
contact_form = None
|
|
389
405
|
|
|
@@ -398,9 +414,18 @@ def contact_points_from_rdf(rdf, prop, role, dataset):
|
|
|
398
414
|
else:
|
|
399
415
|
org_or_owner = {"owner": dataset.owner}
|
|
400
416
|
try:
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
417
|
+
if dryrun:
|
|
418
|
+
# In dryrun mode, only reuse existing contact points, don't create new ones.
|
|
419
|
+
# Mongoengine doesn't allow referencing unsaved documents.
|
|
420
|
+
contact = ContactPoint.objects.filter(
|
|
421
|
+
name=name, email=email, contact_form=contact_form, role=role, **org_or_owner
|
|
422
|
+
).first()
|
|
423
|
+
if not contact:
|
|
424
|
+
continue
|
|
425
|
+
else:
|
|
426
|
+
contact, _ = ContactPoint.objects.get_or_create(
|
|
427
|
+
name=name, email=email, contact_form=contact_form, role=role, **org_or_owner
|
|
428
|
+
)
|
|
404
429
|
except mongoengine.errors.ValidationError as validation_error:
|
|
405
430
|
log.warning(f"Unable to validate contact point: {validation_error}", exc_info=True)
|
|
406
431
|
continue
|
|
@@ -568,6 +593,27 @@ def paginate_catalog(catalog, graph, datasets, _format, rdf_catalog_endpoint, **
|
|
|
568
593
|
return catalog
|
|
569
594
|
|
|
570
595
|
|
|
596
|
+
def escape_uri_in_graph(graph: Graph) -> Graph:
|
|
597
|
+
"""
|
|
598
|
+
Some invalid uri could exist in the graph and they can't be serialized in N3/Turtle.
|
|
599
|
+
We use a urllib.parse.quote to escape these at best for invalid URIRef.
|
|
600
|
+
"""
|
|
601
|
+
escaped_graph = Graph()
|
|
602
|
+
for s, p, o in graph:
|
|
603
|
+
try:
|
|
604
|
+
if isinstance(s, URIRef) and not _is_valid_uri(str(s)):
|
|
605
|
+
encoded_uri = quote(str(s), safe=":/?#[]@!$&'()*+,;=")
|
|
606
|
+
s = URIRef(encoded_uri)
|
|
607
|
+
if isinstance(o, URIRef) and not _is_valid_uri(str(o)):
|
|
608
|
+
encoded_uri = quote(str(o), safe=":/?#[]@!$&'()*+,;=")
|
|
609
|
+
o = URIRef(encoded_uri)
|
|
610
|
+
escaped_graph.add((s, p, o))
|
|
611
|
+
except Exception as e:
|
|
612
|
+
log.exception(f"Failing to escape uri on triplet {s} {p} {o} : {e}")
|
|
613
|
+
continue
|
|
614
|
+
return escaped_graph
|
|
615
|
+
|
|
616
|
+
|
|
571
617
|
def graph_response(graph, format):
|
|
572
618
|
"""
|
|
573
619
|
Return a proper flask response for a RDF resource given an expected format.
|
|
@@ -581,6 +627,8 @@ def graph_response(graph, format):
|
|
|
581
627
|
kwargs["context"] = CONTEXT
|
|
582
628
|
if isinstance(graph, RdfResource):
|
|
583
629
|
graph = graph.graph
|
|
630
|
+
if fmt in ["n3", "nt", "turtle", "trig"]:
|
|
631
|
+
graph = escape_uri_in_graph(graph)
|
|
584
632
|
return escape_xml_illegal_chars(graph.serialize(format=fmt, **kwargs)), 200, headers
|
|
585
633
|
|
|
586
634
|
|
udata/routing.py
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
from urllib.parse import quote
|
|
1
2
|
from uuid import UUID
|
|
2
3
|
|
|
3
4
|
from bson import ObjectId
|
|
@@ -5,7 +6,6 @@ from flask import redirect, request, url_for
|
|
|
5
6
|
from mongoengine.errors import InvalidQueryError, ValidationError
|
|
6
7
|
from werkzeug.exceptions import NotFound
|
|
7
8
|
from werkzeug.routing import BaseConverter, PathConverter
|
|
8
|
-
from werkzeug.urls import url_quote
|
|
9
9
|
|
|
10
10
|
from udata import models
|
|
11
11
|
from udata.core.dataservices.models import Dataservice
|
|
@@ -79,7 +79,7 @@ class ModelConverter(BaseConverter):
|
|
|
79
79
|
if self.has_slug:
|
|
80
80
|
return self.model.slug.slugify(value)
|
|
81
81
|
else:
|
|
82
|
-
return
|
|
82
|
+
return quote(value)
|
|
83
83
|
|
|
84
84
|
def to_python(self, value):
|
|
85
85
|
try:
|
udata/settings.py
CHANGED
|
@@ -69,11 +69,13 @@ class Defaults(object):
|
|
|
69
69
|
# Flask mail settings
|
|
70
70
|
|
|
71
71
|
MAIL_DEFAULT_SENDER = "webmaster@udata"
|
|
72
|
+
MAIL_LOGO_URL = "https://www.data.gouv.fr/nuxt_images/udata_mails_external_logo.png"
|
|
72
73
|
|
|
73
74
|
# Flask security settings
|
|
74
75
|
|
|
75
76
|
SESSION_COOKIE_SECURE = True
|
|
76
77
|
SESSION_COOKIE_SAMESITE = None # Can be set to 'Lax' or 'Strict'. See https://flask.palletsprojects.com/en/2.3.x/security/#security-cookie
|
|
78
|
+
SECURITY_USE_REGISTER_V2 = True
|
|
77
79
|
|
|
78
80
|
# Flask-Security-Too settings
|
|
79
81
|
|
|
@@ -172,6 +174,10 @@ class Defaults(object):
|
|
|
172
174
|
SITE_AUTHOR = "Udata"
|
|
173
175
|
SITE_GITHUB_URL = "https://github.com/etalab/udata"
|
|
174
176
|
|
|
177
|
+
TERMS_OF_USE_URL = None
|
|
178
|
+
TERMS_OF_USE_DELETION_ARTICLE = None
|
|
179
|
+
TELERECOURS_URL = None
|
|
180
|
+
|
|
175
181
|
UDATA_INSTANCE_NAME = "udata"
|
|
176
182
|
|
|
177
183
|
HARVESTER_BACKENDS = []
|
|
@@ -478,6 +484,11 @@ class Defaults(object):
|
|
|
478
484
|
# Padding (in percent) used by the internal provider
|
|
479
485
|
AVATAR_INTERNAL_PADDING = 10
|
|
480
486
|
|
|
487
|
+
# Notification settings
|
|
488
|
+
###########################################################################
|
|
489
|
+
# Notifications are deleted after being handled for 90 days
|
|
490
|
+
DAYS_AFTER_NOTIFICATION_EXPIRED = 90
|
|
491
|
+
|
|
481
492
|
# Post settings
|
|
482
493
|
###########################################################################
|
|
483
494
|
# Discussions on posts are disabled by default
|
udata/tasks.py
CHANGED
|
@@ -172,6 +172,7 @@ def init_app(app):
|
|
|
172
172
|
import udata.core.discussions.tasks # noqa
|
|
173
173
|
import udata.core.badges.tasks # noqa
|
|
174
174
|
import udata.core.storages.tasks # noqa
|
|
175
|
+
import udata.features.notifications.tasks # noqa
|
|
175
176
|
import udata.harvest.tasks # noqa
|
|
176
177
|
import udata.db.tasks # noqa
|
|
177
178
|
|