udata 14.0.0__py3-none-any.whl → 14.4.1.dev7__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of udata might be problematic. Click here for more details.
- 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 +6 -3
- 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/models.py +1 -1
- udata/core/dataservices/tasks.py +7 -0
- udata/core/dataset/api.py +2 -0
- udata/core/dataset/models.py +2 -2
- udata/core/dataset/permissions.py +31 -0
- udata/core/dataset/tasks.py +17 -5
- udata/core/discussions/models.py +1 -0
- udata/core/organization/api.py +8 -5
- udata/core/organization/mails.py +1 -1
- udata/core/organization/models.py +9 -1
- 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/models.py +1 -1
- udata/core/reuse/tasks.py +7 -0
- udata/core/spatial/forms.py +2 -2
- udata/core/user/models.py +5 -1
- 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/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/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 +57 -10
- udata/harvest/tests/test_filters.py +6 -0
- udata/i18n.py +1 -4
- udata/mail.py +5 -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 +45 -6
- udata/routing.py +2 -2
- udata/settings.py +7 -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 +44 -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_swagger.py +4 -4
- udata/tests/cli/test_cli_base.py +8 -9
- 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/frontend/test_auth.py +24 -1
- udata/tests/frontend/test_csv.py +0 -3
- udata/tests/helpers.py +25 -27
- udata/tests/organization/test_notifications.py +67 -2
- udata/tests/plugin.py +6 -261
- udata/tests/site/test_site_csv_exports.py +22 -10
- udata/tests/test_activity.py +9 -9
- udata/tests/test_dcat_commands.py +2 -2
- udata/tests/test_discussions.py +5 -5
- 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.4.1.dev7.dist-info/METADATA +109 -0
- {udata-14.0.0.dist-info → udata-14.4.1.dev7.dist-info}/RECORD +121 -123
- 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.4.1.dev7.dist-info}/WHEEL +0 -0
- {udata-14.0.0.dist-info → udata-14.4.1.dev7.dist-info}/entry_points.txt +0 -0
- {udata-14.0.0.dist-info → udata-14.4.1.dev7.dist-info}/licenses/LICENSE +0 -0
- {udata-14.0.0.dist-info → udata-14.4.1.dev7.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,37 @@ 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
|
+
|
|
928
975
|
|
|
929
976
|
@pytest.mark.options(HARVESTER_BACKENDS=["csw*"])
|
|
930
977
|
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:
|
|
@@ -90,6 +90,10 @@ def init_app(app):
|
|
|
90
90
|
|
|
91
91
|
|
|
92
92
|
def send_mail(recipients: object | list, message: MailMessage):
|
|
93
|
+
# Security mails are sent via the Flask-Security package and not
|
|
94
|
+
# from this function. Disabling mail sending logic is duplicated
|
|
95
|
+
# in :DisableMail.
|
|
96
|
+
# Flask-Security templates are rendered in `render_mail_template`.
|
|
93
97
|
debug = current_app.config.get("DEBUG", False)
|
|
94
98
|
send_mail = current_app.config.get("SEND_MAIL", not debug)
|
|
95
99
|
|
|
@@ -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,6 +361,12 @@ def themes_from_rdf(rdf):
|
|
|
358
361
|
return list(set(tags))
|
|
359
362
|
|
|
360
363
|
|
|
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
|
+
|
|
361
370
|
def contact_points_from_rdf(rdf, prop, role, dataset):
|
|
362
371
|
if not dataset.organization and not dataset.owner:
|
|
363
372
|
return
|
|
@@ -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
|
|
|
@@ -568,6 +584,27 @@ def paginate_catalog(catalog, graph, datasets, _format, rdf_catalog_endpoint, **
|
|
|
568
584
|
return catalog
|
|
569
585
|
|
|
570
586
|
|
|
587
|
+
def escape_uri_in_graph(graph: Graph) -> Graph:
|
|
588
|
+
"""
|
|
589
|
+
Some invalid uri could exist in the graph and they can't be serialized in N3/Turtle.
|
|
590
|
+
We use a urllib.parse.quote to escape these at best for invalid URIRef.
|
|
591
|
+
"""
|
|
592
|
+
escaped_graph = Graph()
|
|
593
|
+
for s, p, o in graph:
|
|
594
|
+
try:
|
|
595
|
+
if isinstance(s, URIRef) and not _is_valid_uri(str(s)):
|
|
596
|
+
encoded_uri = quote(str(s), safe=":/?#[]@!$&'()*+,;=")
|
|
597
|
+
s = URIRef(encoded_uri)
|
|
598
|
+
if isinstance(o, URIRef) and not _is_valid_uri(str(o)):
|
|
599
|
+
encoded_uri = quote(str(o), safe=":/?#[]@!$&'()*+,;=")
|
|
600
|
+
o = URIRef(encoded_uri)
|
|
601
|
+
escaped_graph.add((s, p, o))
|
|
602
|
+
except Exception as e:
|
|
603
|
+
log.exception(f"Failing to escape uri on triplet {s} {p} {o} : {e}")
|
|
604
|
+
continue
|
|
605
|
+
return escaped_graph
|
|
606
|
+
|
|
607
|
+
|
|
571
608
|
def graph_response(graph, format):
|
|
572
609
|
"""
|
|
573
610
|
Return a proper flask response for a RDF resource given an expected format.
|
|
@@ -581,6 +618,8 @@ def graph_response(graph, format):
|
|
|
581
618
|
kwargs["context"] = CONTEXT
|
|
582
619
|
if isinstance(graph, RdfResource):
|
|
583
620
|
graph = graph.graph
|
|
621
|
+
if fmt in ["n3", "nt", "turtle", "trig"]:
|
|
622
|
+
graph = escape_uri_in_graph(graph)
|
|
584
623
|
return escape_xml_illegal_chars(graph.serialize(format=fmt, **kwargs)), 200, headers
|
|
585
624
|
|
|
586
625
|
|
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
|
|
|
@@ -478,6 +480,11 @@ class Defaults(object):
|
|
|
478
480
|
# Padding (in percent) used by the internal provider
|
|
479
481
|
AVATAR_INTERNAL_PADDING = 10
|
|
480
482
|
|
|
483
|
+
# Notification settings
|
|
484
|
+
###########################################################################
|
|
485
|
+
# Notifications are deleted after being handled for 90 days
|
|
486
|
+
DAYS_AFTER_NOTIFICATION_EXPIRED = 90
|
|
487
|
+
|
|
481
488
|
# Post settings
|
|
482
489
|
###########################################################################
|
|
483
490
|
# 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
|
|
|
@@ -6,51 +6,25 @@
|
|
|
6
6
|
<title>{{ message.subject }}</title>
|
|
7
7
|
</head>
|
|
8
8
|
<body style="font-family: Marianne, Helvetica, Arial, sans-serif; line-height: 1.6; color: #333; background-color: #ffffff; max-width: 600px; margin: 0 auto; padding: 20px;">
|
|
9
|
+
{% if config.MAIL_LOGO_URL %}
|
|
9
10
|
<div style="margin-bottom: 30px;">
|
|
10
|
-
<
|
|
11
|
-
<path d="M143.469 29.5086H145.86L152.728 46.4699L159.558 29.5086H161.986L154.208 48.6328H151.248L143.469 29.5086Z" fill="#3558A2"/>
|
|
12
|
-
<path d="M138.218 40.5126V29.5086H140.419V40.4746C140.419 46.1664 137.155 49.3917 132.26 49.3917C127.328 49.3917 124.026 46.1664 124.026 40.4746V29.5086H126.265V40.5126C126.265 44.8383 128.656 47.2668 132.26 47.2668C135.789 47.2668 138.218 44.8383 138.218 40.5126Z" fill="#3558A2"/>
|
|
13
|
-
<path d="M109.662 28.7498C115.809 28.7498 119.945 33.6067 119.945 39.0708C119.945 44.5348 115.809 49.3918 109.662 49.3918C103.553 49.3918 99.4167 44.5348 99.4167 39.0708C99.4167 33.6067 103.553 28.7498 109.662 28.7498ZM109.7 47.2669C114.215 47.2669 117.554 43.3965 117.554 39.0708C117.554 34.6692 114.215 30.8747 109.7 30.8747C105.033 30.8747 101.731 34.7071 101.731 39.0708C101.731 43.4344 105.033 47.2669 109.7 47.2669Z" fill="#3558A2"/>
|
|
14
|
-
<path d="M79.7158 52.1617C79.7158 50.1886 80.7024 48.6328 82.6755 47.1909C81.7269 46.5459 81.2716 45.5214 81.2716 44.4968C81.2716 43.017 82.1822 41.7269 83.6621 40.8541C81.9546 39.602 80.968 37.6288 80.968 35.5039C80.968 31.8991 83.8139 28.7118 88.2155 28.7118C89.5435 28.7118 90.7578 29.0153 91.7823 29.5086H98.9539V31.4817H94.1728C95.0076 32.6201 95.5009 34.024 95.5009 35.5039C95.5009 39.1087 92.655 42.296 88.2155 42.296C87.0771 42.296 86.0526 42.0684 85.1419 41.7269C84.0036 42.3719 83.3965 43.3205 83.3965 44.1553C83.3965 45.0281 83.8139 45.7111 85.2178 45.7111H91.0613C96.2598 45.7111 98.4227 48.1016 98.4227 51.4028C98.4227 55.2732 94.4764 58.1191 88.8605 58.1191C83.4724 58.1191 79.7158 55.8424 79.7158 52.1617ZM88.2534 40.3988C91.4787 40.3988 93.2621 38.0842 93.2621 35.5039C93.2621 32.8478 91.4787 30.609 88.2534 30.609C84.9522 30.609 83.1688 32.8478 83.1688 35.5039C83.1688 38.1221 84.9522 40.3988 88.2534 40.3988ZM81.9546 51.972C81.9546 54.5522 84.7625 56.1839 88.8226 56.1839C93.2242 56.1839 96.1839 54.4005 96.1839 51.5166C96.1839 49.4297 95.0456 47.7601 90.9854 47.7601H84.6487C82.9032 48.9364 81.9546 50.2645 81.9546 51.972Z" fill="#3558A2"/>
|
|
15
|
-
<path d="M65.9257 49.2021C62.0933 49.2021 59.4751 47.0013 59.4751 43.4724C59.4751 40.5886 61.7138 38.4257 65.8119 37.7427L71.6554 36.7561V36.2628C71.6554 34.2518 70.1376 32.9616 67.9368 32.9616C66.0775 32.9616 64.6356 33.8344 63.6111 35.2383L60.0822 32.5442C61.7897 30.1917 64.5977 28.7498 68.0886 28.7498C73.6285 28.7498 76.4744 32.051 76.4744 36.2628V48.6329H71.6554V46.7736C70.4412 48.2534 68.1645 49.2021 65.9257 49.2021ZM64.2562 43.2447C64.2562 44.5348 65.2807 45.3696 66.9123 45.3696C69.1131 45.3696 70.7068 44.3451 71.6554 42.8273V40.1332L67.102 40.8921C65.0909 41.2336 64.2562 42.0684 64.2562 43.2447Z" fill="#3558A2"/>
|
|
16
|
-
<path d="M46.6593 41.6509V33.8343H43.0925V29.5086H46.6593V24.7275H51.5163V29.5086H57.3598V33.8343H51.5163V41.6509C51.5163 43.7759 52.6546 44.6106 54.5519 44.6106C55.88 44.6106 56.7527 44.4589 57.3978 44.1932V48.4051C56.4491 48.8225 55.3108 49.0123 53.7171 49.0123C48.936 49.0123 46.6593 46.3182 46.6593 41.6509Z" fill="#3558A2"/>
|
|
17
|
-
<path d="M30.5161 49.2021C26.6836 49.2021 24.0654 47.0013 24.0654 43.4724C24.0654 40.5886 26.3042 38.4257 30.4022 37.7427L36.2457 36.7561V36.2628C36.2457 34.2518 34.7279 32.9616 32.5271 32.9616C30.6678 32.9616 29.2259 33.8344 28.2014 35.2383L24.6725 32.5442C26.3801 30.1917 29.188 28.7498 32.6789 28.7498C38.2189 28.7498 41.0647 32.051 41.0647 36.2628V48.6329H36.2457V46.7736C35.0315 48.2534 32.7548 49.2021 30.5161 49.2021ZM28.8465 43.2447C28.8465 44.5348 29.871 45.3696 31.5026 45.3696C33.7034 45.3696 35.2971 44.3451 36.2457 42.8273V40.1332L31.6924 40.8921C29.6813 41.2336 28.8465 42.0684 28.8465 43.2447Z" fill="#3558A2"/>
|
|
18
|
-
<path d="M0 39.0706C0 33.4927 3.68066 28.7496 9.71389 28.7496C12.2941 28.7496 14.1534 29.5465 15.6333 31.0263V20.1741H20.4902V48.6327H15.6333V47.115C14.1534 48.5948 12.2941 49.3916 9.71389 49.3916C3.68066 49.3916 0 44.6485 0 39.0706ZM5.04667 39.0706C5.04667 42.4098 7.13364 44.8383 10.3969 44.8383C12.5598 44.8383 14.3432 43.9276 15.6333 42.1821V35.9591C14.3432 34.2137 12.5598 33.303 10.3969 33.303C7.13364 33.303 5.04667 35.7315 5.04667 39.0706Z" fill="#3558A2"/>
|
|
19
|
-
<path d="M179.017 11.7431C179.017 11.7431 179.017 11.7297 179.017 11.7207C179.008 10.9944 178.619 10.3218 177.994 9.96316L174.864 8.1518C174.573 7.98142 174.238 7.89175 173.894 7.88278C173.845 7.88278 173.8 7.88278 173.751 7.88727C173.509 7.90072 173.281 7.94555 173.067 8.03074C172.982 8.06661 172.901 8.10696 172.821 8.1518L170.916 9.25476L169.731 9.94074C169.66 9.98109 169.593 10.0259 169.53 10.0752C169.53 10.0752 169.503 10.0932 169.499 10.0932C169.248 10.277 169.052 10.5146 168.913 10.7792C168.904 10.7971 168.891 10.815 168.882 10.833C168.873 10.8509 168.864 10.8688 168.859 10.8868C168.734 11.1603 168.667 11.4696 168.667 11.7969V15.2672V15.321C168.667 15.3838 168.672 15.4511 168.681 15.5318C168.681 15.5721 168.69 15.6169 168.699 15.6663C168.734 15.9084 168.815 16.137 168.927 16.3433C169.11 16.684 169.383 16.9665 169.718 17.1593L172.794 18.9393C172.906 19.002 172.991 19.0424 173.076 19.0783C173.29 19.1679 173.518 19.2173 173.751 19.2262C173.76 19.2262 173.769 19.2262 173.777 19.2262C173.997 19.2307 174.22 19.2083 174.435 19.141C174.582 19.0962 174.73 19.0334 174.864 18.9527L177.971 17.1548C178.289 16.971 178.552 16.7065 178.736 16.3881C178.919 16.0698 179.017 15.7066 179.017 15.3434V11.7431Z" fill="#3558A2"/>
|
|
20
|
-
<path d="M168.667 18.4043V22.8378V24.1148L173.602 26.9722V21.2617L172.451 20.5965L168.667 18.4043Z" fill="#3558A2"/>
|
|
21
|
-
<path d="M178.359 9.68272C178.359 9.68272 178.4 9.71397 178.418 9.72736C178.418 9.72736 178.427 9.73183 178.431 9.73629C178.462 9.75862 178.494 9.78541 178.525 9.80773C178.525 9.80773 178.53 9.8122 178.534 9.81666C178.539 9.82112 178.548 9.82559 178.552 9.83005C178.588 9.86131 178.628 9.89256 178.66 9.92382C178.669 9.93275 178.678 9.94167 178.687 9.9506C178.709 9.97293 178.732 9.99525 178.754 10.0176C178.763 10.0265 178.772 10.0354 178.776 10.0399C178.808 10.0756 178.839 10.1113 178.871 10.1471C178.889 10.1649 178.902 10.1872 178.915 10.2051C178.924 10.2185 178.938 10.2319 178.947 10.2453C178.956 10.2542 178.965 10.2676 178.974 10.2765C179.005 10.3167 179.032 10.3614 179.059 10.406C179.086 10.4507 179.113 10.4908 179.14 10.5355C179.167 10.5846 179.189 10.6337 179.216 10.6828C179.238 10.7275 179.256 10.7677 179.274 10.8123C179.292 10.8525 179.306 10.8971 179.319 10.9373C179.319 10.9463 179.324 10.9507 179.328 10.9597C179.328 10.9686 179.333 10.9775 179.337 10.982C179.351 11.0222 179.364 11.0623 179.377 11.1025C179.395 11.1695 179.409 11.2365 179.418 11.3079C179.418 11.3079 179.418 11.3124 179.418 11.3168C179.418 11.3258 179.422 11.3392 179.422 11.3481C179.422 11.3704 179.431 11.3883 179.436 11.4106C179.449 11.5133 179.458 11.6204 179.458 11.7276V15.1968L180.149 15.5941V9.88363L175.179 7.02615V7.84321L178.207 9.58449C178.256 9.61128 178.306 9.64253 178.355 9.67825L178.359 9.68272Z" fill="#3558A2"/>
|
|
22
|
-
<path d="M161.984 7.02615V12.7595L166.954 15.6284V9.89506L161.984 7.02615Z" fill="#3558A2"/>
|
|
23
|
-
<path d="M178.969 16.8342C178.929 16.8925 178.884 16.9419 178.84 16.9958C178.809 17.0317 178.777 17.0676 178.746 17.1035C178.697 17.1573 178.639 17.2022 178.586 17.2516C178.559 17.274 178.532 17.3009 178.505 17.3279L179.352 17.8215C179.419 17.8619 179.464 17.9337 179.464 18.0145V22.8032L180.15 23.2026V17.4625L179.005 16.7938C179.005 16.7938 178.982 16.8207 178.973 16.8342H178.969Z" fill="#3558A2"/>
|
|
24
|
-
<path d="M172.779 6.64593V7.67751C172.779 7.67751 172.811 7.66858 172.828 7.65965C172.917 7.61946 173.015 7.5882 173.109 7.5614C173.185 7.53908 173.26 7.52121 173.341 7.50782C173.416 7.49442 173.488 7.48102 173.563 7.47209C173.59 7.47209 173.612 7.46316 173.639 7.45869V6.11451L168.704 3.25644V4.15405L172.668 6.44944C172.735 6.48963 172.779 6.56108 172.779 6.64146V6.64593Z" fill="#3558A2"/>
|
|
25
|
-
<path d="M161.984 20.3141L166.954 23.1686V17.4551L161.984 14.6006V20.3141Z" fill="#3558A2"/>
|
|
26
|
-
<path d="M185.53 20.314V14.6005L180.594 17.455V23.1685L185.53 20.314Z" fill="#3558A2"/>
|
|
27
|
-
<path d="M174.048 26.9723L179.018 24.1148V22.9138V18.4043L174.048 21.2618V26.9723Z" fill="#3558A2"/>
|
|
28
|
-
<path d="M167.4 15.6284L168.215 15.1533V11.7554C168.215 10.9754 168.585 10.2313 169.209 9.76061C169.209 9.76061 169.217 9.75613 169.222 9.75165C169.222 9.75165 169.226 9.75165 169.231 9.74716C169.24 9.74268 169.249 9.73372 169.253 9.73372C169.333 9.67544 169.391 9.6351 169.458 9.59475L172.335 7.92272V7.02618L167.4 9.89509V15.6284Z" fill="#3558A2"/>
|
|
29
|
-
<path d="M185.53 12.7595V7.02618L180.594 9.89509V15.6284L185.53 12.7595Z" fill="#3558A2"/>
|
|
30
|
-
<path d="M174.169 7.46644C174.218 7.47089 174.262 7.48426 174.311 7.48871C174.369 7.49762 174.431 7.51099 174.489 7.52435C174.543 7.53772 174.592 7.55554 174.641 7.57336C174.681 7.58672 174.721 7.59563 174.761 7.609V6.63336C174.761 6.55317 174.806 6.48189 174.872 6.44179L179.055 4.02718V3.25647L174.12 6.10767V7.45753C174.12 7.45753 174.155 7.46644 174.173 7.46644H174.169Z" fill="#3558A2"/>
|
|
31
|
-
<path d="M168.26 17.9037C168.273 17.8815 168.296 17.8637 168.318 17.8459C168.323 17.8459 168.327 17.837 168.332 17.8325L169.182 17.343C169.182 17.343 169.146 17.3074 169.124 17.2896C169.052 17.2273 168.981 17.165 168.914 17.0937C168.882 17.0626 168.855 17.027 168.829 16.9958C168.775 16.9335 168.726 16.8712 168.681 16.8045C168.672 16.7911 168.658 16.7778 168.645 16.7599L168.623 16.7733L167.4 17.4765V23.1688L168.22 22.697V18.0239C168.22 17.9883 168.233 17.9527 168.246 17.9216C168.246 17.9127 168.255 17.9082 168.26 17.8993V17.9037Z" fill="#3558A2"/>
|
|
32
|
-
<path d="M172.132 6.629L167.182 3.76953L162.227 6.629L167.182 9.49294L172.132 6.629Z" fill="#3558A2"/>
|
|
33
|
-
<path d="M178.847 2.86394L173.878 0L168.908 2.86394L173.878 5.72341L178.847 2.86394Z" fill="#3558A2"/>
|
|
34
|
-
<path d="M180.045 9.30052L180.371 9.49294L185.326 6.629L180.371 3.76953L179.396 4.33337L175.421 6.629L177.514 7.8417L180.045 9.30052Z" fill="#3558A2"/>
|
|
35
|
-
<path d="M180.264 16.1917L179.438 15.7158C179.433 15.7469 179.42 15.7825 179.415 15.8136C179.402 15.8892 179.384 15.9648 179.366 16.036C179.353 16.0805 179.335 16.125 179.321 16.165C179.299 16.2317 179.277 16.294 179.245 16.3607C179.236 16.3785 179.232 16.3918 179.228 16.4096L180.376 17.0679L185.328 14.2213L184.617 13.8121L180.492 16.1828C180.457 16.2006 180.421 16.2139 180.381 16.2139C180.34 16.2139 180.305 16.205 180.269 16.1828L180.264 16.1917Z" fill="#3558A2"/>
|
|
36
|
-
<path d="M175.068 19.3326C174.902 19.426 174.723 19.5016 174.544 19.5594C174.419 19.5994 174.29 19.6217 174.16 19.6395C174.115 19.6439 174.071 19.6439 174.026 19.6484C173.959 19.6528 173.896 19.6617 173.829 19.6617C173.793 19.6617 173.757 19.6617 173.722 19.6617C173.722 19.6617 173.717 19.6617 173.713 19.6617C173.57 19.6573 173.431 19.6395 173.292 19.6083C173.154 19.5772 173.02 19.5372 172.89 19.4838C172.787 19.4393 172.684 19.3904 172.581 19.337L169.585 17.6157L169.349 17.7536L168.875 18.0249L173.829 20.8715L174.366 20.5602L174.871 20.2711L175.881 19.6884L178.779 18.0249L178.305 17.7536L178.068 17.6157L175.072 19.337L175.068 19.3326Z" fill="#3558A2"/>
|
|
37
|
-
<path d="M168.256 15.7246C168.256 15.6935 168.247 15.6668 168.242 15.6357L167.29 16.1828C167.254 16.2006 167.218 16.2139 167.178 16.2139C167.138 16.2139 167.102 16.205 167.066 16.1828L162.938 13.8121L162.227 14.2213L167.182 17.0679L168.43 16.3518C168.354 16.1739 168.296 15.9693 168.26 15.7424C168.26 15.7424 168.26 15.7335 168.26 15.7291L168.256 15.7246Z" fill="#3558A2"/>
|
|
38
|
-
</svg>
|
|
11
|
+
<img width="186" src="{{ config.MAIL_LOGO_URL }}" alt="Logo">
|
|
39
12
|
</div>
|
|
13
|
+
{% endif %}
|
|
40
14
|
|
|
41
15
|
<p style="margin-bottom: 20px;">{{ _('Hi %(user)s', user=recipient.first_name) }},</p>
|
|
42
16
|
|
|
43
17
|
{% for item in message.paragraphs %}
|
|
44
18
|
{% if item.__class__.__name__ == 'MailCTA' %}
|
|
45
19
|
<p style="margin: 25px 0;">
|
|
46
|
-
<a href="{{ item.link }}" style="color: #
|
|
20
|
+
<a href="{{ item.link }}" style="color: #000091; text-decoration: underline; font-weight: 700;">{{ item.label }}</a>
|
|
47
21
|
</p>
|
|
48
22
|
{% elif item.__class__.__name__ == 'LabelledContent' %}
|
|
49
23
|
{% if item.inline %}
|
|
50
24
|
<p style="margin-bottom: 15px;"><strong>{{ item.label }}</strong> {{ item.truncated_content }}</p>
|
|
51
25
|
{% else %}
|
|
52
26
|
<p style="margin-bottom: 5px; font-weight: bold;">{{ item.label }}</p>
|
|
53
|
-
<p style="margin-bottom: 15px; margin-top: 0;">{{ item.truncated_content }}</p>
|
|
27
|
+
<p style="margin-bottom: 15px; margin-top: 0; font-style: italic;">{{ item.truncated_content }}</p>
|
|
54
28
|
{% endif %}
|
|
55
29
|
{% elif item.__class__.__name__ == 'ParagraphWithLinks' %}
|
|
56
30
|
<p style="margin-bottom: 15px;">{{ item.html|safe }}</p>
|