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.

Files changed (152) hide show
  1. udata/api/__init__.py +2 -0
  2. udata/api_fields.py +35 -4
  3. udata/app.py +18 -20
  4. udata/auth/__init__.py +29 -6
  5. udata/auth/forms.py +2 -2
  6. udata/auth/views.py +13 -6
  7. udata/commands/dcat.py +1 -1
  8. udata/commands/serve.py +3 -11
  9. udata/commands/tests/test_fixtures.py +9 -9
  10. udata/core/access_type/api.py +1 -1
  11. udata/core/access_type/constants.py +12 -8
  12. udata/core/activity/api.py +5 -6
  13. udata/core/badges/tests/test_commands.py +6 -6
  14. udata/core/csv.py +5 -0
  15. udata/core/dataservices/api.py +8 -1
  16. udata/core/dataservices/apiv2.py +2 -5
  17. udata/core/dataservices/models.py +5 -2
  18. udata/core/dataservices/rdf.py +2 -1
  19. udata/core/dataservices/tasks.py +13 -2
  20. udata/core/dataset/api.py +10 -0
  21. udata/core/dataset/models.py +6 -6
  22. udata/core/dataset/permissions.py +31 -0
  23. udata/core/dataset/rdf.py +8 -2
  24. udata/core/dataset/tasks.py +23 -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/apiv2.py +2 -3
  31. udata/core/organization/mails.py +1 -1
  32. udata/core/organization/models.py +15 -2
  33. udata/core/organization/notifications.py +84 -0
  34. udata/core/organization/permissions.py +1 -1
  35. udata/core/organization/tasks.py +3 -0
  36. udata/core/pages/tests/test_api.py +32 -0
  37. udata/core/post/api.py +24 -69
  38. udata/core/post/models.py +84 -16
  39. udata/core/post/tests/test_api.py +24 -1
  40. udata/core/reports/api.py +18 -0
  41. udata/core/reports/models.py +42 -2
  42. udata/core/reuse/api.py +8 -0
  43. udata/core/reuse/apiv2.py +2 -5
  44. udata/core/reuse/models.py +1 -1
  45. udata/core/reuse/tasks.py +7 -0
  46. udata/core/spatial/forms.py +2 -2
  47. udata/core/topic/models.py +8 -2
  48. udata/core/user/api.py +10 -3
  49. udata/core/user/models.py +12 -2
  50. udata/features/notifications/api.py +7 -18
  51. udata/features/notifications/models.py +56 -0
  52. udata/features/notifications/tasks.py +25 -0
  53. udata/flask_mongoengine/engine.py +0 -4
  54. udata/flask_mongoengine/pagination.py +1 -1
  55. udata/frontend/markdown.py +2 -1
  56. udata/harvest/actions.py +21 -1
  57. udata/harvest/api.py +25 -8
  58. udata/harvest/backends/base.py +27 -1
  59. udata/harvest/backends/ckan/harvesters.py +11 -2
  60. udata/harvest/backends/dcat.py +4 -1
  61. udata/harvest/commands.py +33 -0
  62. udata/harvest/filters.py +17 -6
  63. udata/harvest/models.py +16 -0
  64. udata/harvest/permissions.py +27 -0
  65. udata/harvest/tests/ckan/test_ckan_backend.py +33 -0
  66. udata/harvest/tests/test_actions.py +58 -5
  67. udata/harvest/tests/test_api.py +276 -122
  68. udata/harvest/tests/test_base_backend.py +86 -1
  69. udata/harvest/tests/test_dcat_backend.py +81 -10
  70. udata/harvest/tests/test_filters.py +6 -0
  71. udata/i18n.py +1 -4
  72. udata/mail.py +19 -1
  73. udata/migrations/2025-10-31-create-membership-request-notifications.py +55 -0
  74. udata/migrations/2025-12-04-add-uuid-to-discussion-messages.py +28 -0
  75. udata/mongo/slug_fields.py +1 -1
  76. udata/rdf.py +58 -10
  77. udata/routing.py +2 -2
  78. udata/settings.py +11 -0
  79. udata/tasks.py +1 -0
  80. udata/templates/mail/message.html +5 -31
  81. udata/tests/__init__.py +27 -2
  82. udata/tests/api/__init__.py +108 -21
  83. udata/tests/api/test_activities_api.py +36 -0
  84. udata/tests/api/test_auth_api.py +121 -95
  85. udata/tests/api/test_base_api.py +7 -4
  86. udata/tests/api/test_datasets_api.py +50 -19
  87. udata/tests/api/test_organizations_api.py +192 -197
  88. udata/tests/api/test_reports_api.py +157 -0
  89. udata/tests/api/test_reuses_api.py +147 -147
  90. udata/tests/api/test_security_api.py +12 -12
  91. udata/tests/api/test_swagger.py +4 -4
  92. udata/tests/api/test_tags_api.py +8 -8
  93. udata/tests/api/test_user_api.py +1 -1
  94. udata/tests/apiv2/test_search.py +30 -0
  95. udata/tests/apiv2/test_swagger.py +4 -4
  96. udata/tests/cli/test_cli_base.py +8 -9
  97. udata/tests/dataservice/test_dataservice_tasks.py +29 -0
  98. udata/tests/dataset/test_dataset_commands.py +4 -4
  99. udata/tests/dataset/test_dataset_model.py +66 -26
  100. udata/tests/dataset/test_dataset_rdf.py +99 -5
  101. udata/tests/dataset/test_dataset_tasks.py +25 -0
  102. udata/tests/frontend/test_auth.py +58 -1
  103. udata/tests/frontend/test_csv.py +0 -3
  104. udata/tests/helpers.py +31 -27
  105. udata/tests/organization/test_notifications.py +67 -2
  106. udata/tests/plugin.py +6 -261
  107. udata/tests/search/test_search_integration.py +33 -0
  108. udata/tests/site/test_site_csv_exports.py +22 -10
  109. udata/tests/test_activity.py +9 -9
  110. udata/tests/test_api_fields.py +10 -0
  111. udata/tests/test_dcat_commands.py +2 -2
  112. udata/tests/test_discussions.py +5 -5
  113. udata/tests/test_legal_mails.py +359 -0
  114. udata/tests/test_migrations.py +21 -21
  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_storages.py +25 -19
  119. udata/tests/test_topics.py +77 -61
  120. udata/tests/test_uris.py +33 -0
  121. udata/tests/workers/test_jobs_commands.py +23 -23
  122. udata/translations/ar/LC_MESSAGES/udata.mo +0 -0
  123. udata/translations/ar/LC_MESSAGES/udata.po +187 -108
  124. udata/translations/de/LC_MESSAGES/udata.mo +0 -0
  125. udata/translations/de/LC_MESSAGES/udata.po +187 -108
  126. udata/translations/es/LC_MESSAGES/udata.mo +0 -0
  127. udata/translations/es/LC_MESSAGES/udata.po +187 -108
  128. udata/translations/fr/LC_MESSAGES/udata.mo +0 -0
  129. udata/translations/fr/LC_MESSAGES/udata.po +188 -109
  130. udata/translations/it/LC_MESSAGES/udata.mo +0 -0
  131. udata/translations/it/LC_MESSAGES/udata.po +187 -108
  132. udata/translations/pt/LC_MESSAGES/udata.mo +0 -0
  133. udata/translations/pt/LC_MESSAGES/udata.po +187 -108
  134. udata/translations/sr/LC_MESSAGES/udata.mo +0 -0
  135. udata/translations/sr/LC_MESSAGES/udata.po +187 -108
  136. udata/translations/udata.pot +215 -106
  137. udata/uris.py +0 -2
  138. udata-14.5.1.dev6.dist-info/METADATA +109 -0
  139. {udata-14.0.0.dist-info → udata-14.5.1.dev6.dist-info}/RECORD +143 -140
  140. udata/core/post/forms.py +0 -30
  141. udata/flask_mongoengine/json.py +0 -38
  142. udata/templates/mail/base.html +0 -105
  143. udata/templates/mail/base.txt +0 -6
  144. udata/templates/mail/button.html +0 -3
  145. udata/templates/mail/layouts/1-column.html +0 -19
  146. udata/templates/mail/layouts/2-columns.html +0 -20
  147. udata/templates/mail/layouts/center-panel.html +0 -16
  148. udata-14.0.0.dist-info/METADATA +0 -132
  149. {udata-14.0.0.dist-info → udata-14.5.1.dev6.dist-info}/WHEEL +0 -0
  150. {udata-14.0.0.dist-info → udata-14.5.1.dev6.dist-info}/entry_points.txt +0 -0
  151. {udata-14.0.0.dist-info → udata-14.5.1.dev6.dist-info}/licenses/LICENSE +0 -0
  152. {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 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
 
@@ -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
- mocker.patch.dict(
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": "http://example.org/this-url-does-not-exist",
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
- 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
@@ -28,7 +28,7 @@ class LabelledContent:
28
28
  label: LazyString
29
29
  content: str
30
30
  inline: bool = False
31
- truncated_at: int = 50
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.")
@@ -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=f"^{slug}-\d*$").clear_cls_query().only(field.db_field)
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 contact_points_from_rdf(rdf, prop, role, dataset):
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 = rdf_value(contact_point, VCARD.fn) or ""
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
- name = (
382
- rdf_value(contact_point, FOAF.name)
383
- or rdf_value(contact_point, SKOS.prefLabel)
384
- or ""
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
- contact, _ = ContactPoint.objects.get_or_create(
402
- name=name, email=email, contact_form=contact_form, role=role, **org_or_owner
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 url_quote(value)
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