udata 14.4.1.dev7__py3-none-any.whl → 14.5.1.dev9__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- udata/api/__init__.py +2 -0
- udata/auth/views.py +7 -3
- udata/commands/dcat.py +1 -1
- udata/core/dataservices/api.py +8 -1
- udata/core/dataservices/apiv2.py +2 -5
- udata/core/dataservices/models.py +4 -1
- udata/core/dataservices/rdf.py +2 -1
- udata/core/dataservices/tasks.py +6 -2
- udata/core/dataset/api.py +28 -4
- udata/core/dataset/api_fields.py +1 -1
- udata/core/dataset/apiv2.py +1 -1
- udata/core/dataset/models.py +4 -4
- udata/core/dataset/rdf.py +8 -2
- udata/core/dataset/tasks.py +6 -2
- udata/core/discussions/api.py +15 -1
- udata/core/discussions/models.py +5 -0
- udata/core/legal/__init__.py +0 -0
- udata/core/legal/mails.py +128 -0
- udata/core/organization/api.py +8 -0
- udata/core/organization/api_fields.py +3 -3
- udata/core/organization/apiv2.py +2 -3
- udata/core/organization/models.py +6 -1
- udata/core/reuse/api.py +8 -0
- udata/core/reuse/apiv2.py +2 -5
- udata/core/topic/models.py +8 -2
- udata/core/user/api.py +10 -3
- udata/core/user/api_fields.py +3 -3
- udata/core/user/models.py +7 -1
- udata/flask_mongoengine/pagination.py +1 -1
- udata/harvest/backends/dcat.py +4 -1
- udata/harvest/tests/test_dcat_backend.py +24 -0
- udata/mail.py +14 -0
- udata/rdf.py +20 -5
- udata/settings.py +4 -0
- udata/tests/api/test_datasets_api.py +44 -0
- udata/tests/apiv2/test_search.py +30 -0
- udata/tests/dataservice/test_dataservice_tasks.py +29 -0
- udata/tests/dataset/test_dataset_rdf.py +16 -0
- udata/tests/dataset/test_dataset_tasks.py +25 -0
- udata/tests/frontend/test_auth.py +34 -0
- udata/tests/helpers.py +6 -0
- udata/tests/search/test_search_integration.py +33 -0
- udata/tests/test_api_fields.py +10 -0
- udata/tests/test_legal_mails.py +359 -0
- {udata-14.4.1.dev7.dist-info → udata-14.5.1.dev9.dist-info}/METADATA +2 -2
- {udata-14.4.1.dev7.dist-info → udata-14.5.1.dev9.dist-info}/RECORD +50 -45
- {udata-14.4.1.dev7.dist-info → udata-14.5.1.dev9.dist-info}/WHEEL +0 -0
- {udata-14.4.1.dev7.dist-info → udata-14.5.1.dev9.dist-info}/entry_points.txt +0 -0
- {udata-14.4.1.dev7.dist-info → udata-14.5.1.dev9.dist-info}/licenses/LICENSE +0 -0
- {udata-14.4.1.dev7.dist-info → udata-14.5.1.dev9.dist-info}/top_level.txt +0 -0
udata/core/reuse/apiv2.py
CHANGED
|
@@ -1,9 +1,6 @@
|
|
|
1
|
-
from flask import request
|
|
2
|
-
|
|
3
1
|
from udata import search
|
|
4
2
|
from udata.api import API, apiv2
|
|
5
3
|
from udata.core.reuse.models import Reuse
|
|
6
|
-
from udata.utils import multi_to_dict
|
|
7
4
|
|
|
8
5
|
from .api_fields import reuse_permissions_fields
|
|
9
6
|
from .search import ReuseSearch
|
|
@@ -28,5 +25,5 @@ class ReuseSearchAPI(API):
|
|
|
28
25
|
@apiv2.marshal_with(Reuse.__page_fields__)
|
|
29
26
|
def get(self):
|
|
30
27
|
"""Search all reuses"""
|
|
31
|
-
search_parser.parse_args()
|
|
32
|
-
return search.query(ReuseSearch, **
|
|
28
|
+
args = search_parser.parse_args()
|
|
29
|
+
return search.query(ReuseSearch, **args)
|
udata/core/topic/models.py
CHANGED
|
@@ -16,7 +16,10 @@ __all__ = ("Topic", "TopicElement")
|
|
|
16
16
|
|
|
17
17
|
class TopicElement(Auditable, db.Document):
|
|
18
18
|
title = field(db.StringField(required=False))
|
|
19
|
-
description = field(
|
|
19
|
+
description = field(
|
|
20
|
+
db.StringField(required=False),
|
|
21
|
+
markdown=True,
|
|
22
|
+
)
|
|
20
23
|
tags = field(db.ListField(db.StringField()))
|
|
21
24
|
extras = field(db.ExtrasField())
|
|
22
25
|
element = field(db.GenericReferenceField(choices=["Dataset", "Reuse", "Dataservice"]))
|
|
@@ -63,7 +66,10 @@ class Topic(db.Datetimed, Auditable, Linkable, db.Document, Owned):
|
|
|
63
66
|
db.SlugField(max_length=255, required=True, populate_from="name", update=True, follow=True),
|
|
64
67
|
auditable=False,
|
|
65
68
|
)
|
|
66
|
-
description = field(
|
|
69
|
+
description = field(
|
|
70
|
+
db.StringField(),
|
|
71
|
+
markdown=True,
|
|
72
|
+
)
|
|
67
73
|
tags = field(db.ListField(db.StringField()))
|
|
68
74
|
color = field(db.IntField())
|
|
69
75
|
|
udata/core/user/api.py
CHANGED
|
@@ -8,6 +8,7 @@ from udata.core.dataset.api_fields import community_resource_fields, dataset_fie
|
|
|
8
8
|
from udata.core.discussions.actions import discussions_for
|
|
9
9
|
from udata.core.discussions.api import discussion_fields
|
|
10
10
|
from udata.core.followers.api import FollowAPI
|
|
11
|
+
from udata.core.legal.mails import add_send_legal_notice_argument, send_legal_notice_on_deletion
|
|
11
12
|
from udata.core.storages.api import (
|
|
12
13
|
image_parser,
|
|
13
14
|
parse_uploaded_image,
|
|
@@ -265,11 +266,14 @@ class UserAvatarAPI(API):
|
|
|
265
266
|
return {"image": user.avatar}
|
|
266
267
|
|
|
267
268
|
|
|
268
|
-
delete_parser = api.parser()
|
|
269
|
+
delete_parser = add_send_legal_notice_argument(api.parser())
|
|
269
270
|
delete_parser.add_argument(
|
|
270
271
|
"no_mail",
|
|
271
272
|
type=bool,
|
|
272
|
-
help=
|
|
273
|
+
help=(
|
|
274
|
+
"Do not send the simple deletion notification email. "
|
|
275
|
+
"Note: automatically set to True when send_legal_notice=True to avoid sending duplicate emails."
|
|
276
|
+
),
|
|
273
277
|
location="args",
|
|
274
278
|
default=False,
|
|
275
279
|
)
|
|
@@ -321,8 +325,11 @@ class UserAPI(API):
|
|
|
321
325
|
api.abort(
|
|
322
326
|
403, "You cannot delete yourself with this API. " + 'Use the "me" API instead.'
|
|
323
327
|
)
|
|
328
|
+
send_legal_notice_on_deletion(user, args)
|
|
324
329
|
|
|
325
|
-
|
|
330
|
+
# Skip simple notification if legal notice is sent (to avoid duplicate emails)
|
|
331
|
+
skip_notification = args["no_mail"] or args["send_legal_notice"]
|
|
332
|
+
user.mark_as_deleted(notify=not skip_notification, delete_comments=args["delete_comments"])
|
|
326
333
|
return "", 204
|
|
327
334
|
|
|
328
335
|
|
udata/core/user/api_fields.py
CHANGED
|
@@ -9,7 +9,7 @@ user_ref_fields = api.inherit(
|
|
|
9
9
|
{
|
|
10
10
|
"first_name": fields.String(description="The user first name", readonly=True),
|
|
11
11
|
"last_name": fields.String(description="The user larst name", readonly=True),
|
|
12
|
-
"slug": fields.String(description="The user permalink string",
|
|
12
|
+
"slug": fields.String(description="The user permalink string", readonly=True),
|
|
13
13
|
"uri": fields.String(
|
|
14
14
|
attribute=lambda u: u.self_api_url(),
|
|
15
15
|
description="The API URI for this user",
|
|
@@ -35,8 +35,8 @@ from udata.core.organization.api_fields import member_email_with_visibility_chec
|
|
|
35
35
|
user_fields = api.model(
|
|
36
36
|
"User",
|
|
37
37
|
{
|
|
38
|
-
"id": fields.String(description="The user identifier",
|
|
39
|
-
"slug": fields.String(description="The user permalink string",
|
|
38
|
+
"id": fields.String(description="The user identifier", readonly=True),
|
|
39
|
+
"slug": fields.String(description="The user permalink string", readonly=True),
|
|
40
40
|
"first_name": fields.String(description="The user first name", required=True),
|
|
41
41
|
"last_name": fields.String(description="The user last name", required=True),
|
|
42
42
|
"email": fields.Raw(
|
udata/core/user/models.py
CHANGED
|
@@ -18,6 +18,7 @@ from udata.core.discussions.models import Discussion
|
|
|
18
18
|
from udata.core.linkable import Linkable
|
|
19
19
|
from udata.core.storages import avatars, default_image_basename
|
|
20
20
|
from udata.frontend.markdown import mdstrip
|
|
21
|
+
from udata.i18n import lazy_gettext as _
|
|
21
22
|
from udata.models import Follow, WithMetrics, db
|
|
22
23
|
from udata.uris import cdata_url
|
|
23
24
|
|
|
@@ -62,7 +63,10 @@ class User(WithMetrics, UserMixin, Linkable, db.Document):
|
|
|
62
63
|
db.ImageField(fs=avatars, basename=default_image_basename, thumbnails=AVATAR_SIZES)
|
|
63
64
|
)
|
|
64
65
|
website = field(db.URLField())
|
|
65
|
-
about = field(
|
|
66
|
+
about = field(
|
|
67
|
+
db.StringField(),
|
|
68
|
+
markdown=True,
|
|
69
|
+
)
|
|
66
70
|
|
|
67
71
|
prefered_language = field(db.StringField())
|
|
68
72
|
|
|
@@ -116,6 +120,8 @@ class User(WithMetrics, UserMixin, Linkable, db.Document):
|
|
|
116
120
|
"auto_create_index_on_save": True,
|
|
117
121
|
}
|
|
118
122
|
|
|
123
|
+
verbose_name = _("account")
|
|
124
|
+
|
|
119
125
|
__metrics_keys__ = [
|
|
120
126
|
"datasets",
|
|
121
127
|
"reuses",
|
udata/harvest/backends/dcat.py
CHANGED
|
@@ -225,7 +225,9 @@ class DcatBackend(BaseBackend):
|
|
|
225
225
|
|
|
226
226
|
dataset = self.get_dataset(item.remote_id)
|
|
227
227
|
remote_url_prefix = self.get_extra_config_value("remote_url_prefix")
|
|
228
|
-
dataset = dataset_from_rdf(
|
|
228
|
+
dataset = dataset_from_rdf(
|
|
229
|
+
page, dataset, node=node, remote_url_prefix=remote_url_prefix, dryrun=self.dryrun
|
|
230
|
+
)
|
|
229
231
|
if dataset.organization:
|
|
230
232
|
dataset.organization.compute_aggregate_metrics = False
|
|
231
233
|
self.organizations_to_update.add(dataset.organization)
|
|
@@ -242,6 +244,7 @@ class DcatBackend(BaseBackend):
|
|
|
242
244
|
node,
|
|
243
245
|
[item.dataset for item in self.job.items],
|
|
244
246
|
remote_url_prefix=remote_url_prefix,
|
|
247
|
+
dryrun=self.dryrun,
|
|
245
248
|
)
|
|
246
249
|
|
|
247
250
|
def get_node_from_item(self, graph, item):
|
|
@@ -972,6 +972,30 @@ class DcatBackendTest(PytestOnlyDBTestCase):
|
|
|
972
972
|
assert "connection error" in mock_warning.call_args[0][0].lower()
|
|
973
973
|
mock_exception.assert_not_called()
|
|
974
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
|
+
|
|
975
999
|
|
|
976
1000
|
@pytest.mark.options(HARVESTER_BACKENDS=["csw*"])
|
|
977
1001
|
class CswDcatBackendTest(PytestOnlyDBTestCase):
|
udata/mail.py
CHANGED
|
@@ -39,6 +39,20 @@ class LabelledContent:
|
|
|
39
39
|
)
|
|
40
40
|
|
|
41
41
|
|
|
42
|
+
@dataclass
|
|
43
|
+
class Link:
|
|
44
|
+
"""Simple linkable object for use in ParagraphWithLinks"""
|
|
45
|
+
|
|
46
|
+
label: str
|
|
47
|
+
url: str
|
|
48
|
+
|
|
49
|
+
def __str__(self):
|
|
50
|
+
return str(self.label)
|
|
51
|
+
|
|
52
|
+
def url_for(self, **kwargs):
|
|
53
|
+
return self.url
|
|
54
|
+
|
|
55
|
+
|
|
42
56
|
@dataclass
|
|
43
57
|
class ParagraphWithLinks:
|
|
44
58
|
paragraph: LazyString
|
udata/rdf.py
CHANGED
|
@@ -356,7 +356,13 @@ def theme_labels_from_rdf(rdf):
|
|
|
356
356
|
|
|
357
357
|
|
|
358
358
|
def themes_from_rdf(rdf):
|
|
359
|
-
tags = [
|
|
359
|
+
tags = []
|
|
360
|
+
for tag in rdf.objects(DCAT.keyword):
|
|
361
|
+
if isinstance(tag, RdfResource):
|
|
362
|
+
# dcat:keyword should be Literal, not a Resource/URIRef
|
|
363
|
+
log.warning(f"Ignoring dcat:keyword with URI value: {tag.identifier}")
|
|
364
|
+
continue
|
|
365
|
+
tags.append(tag.toPython())
|
|
360
366
|
tags += theme_labels_from_rdf(rdf)
|
|
361
367
|
return list(set(tags))
|
|
362
368
|
|
|
@@ -367,7 +373,7 @@ def contact_point_name(agent_name: str | None, org_name: str | None) -> str:
|
|
|
367
373
|
return agent_name or org_name or ""
|
|
368
374
|
|
|
369
375
|
|
|
370
|
-
def contact_points_from_rdf(rdf, prop, role, dataset):
|
|
376
|
+
def contact_points_from_rdf(rdf, prop, role, dataset, dryrun=False):
|
|
371
377
|
if not dataset.organization and not dataset.owner:
|
|
372
378
|
return
|
|
373
379
|
for contact_point in rdf.objects(prop):
|
|
@@ -414,9 +420,18 @@ def contact_points_from_rdf(rdf, prop, role, dataset):
|
|
|
414
420
|
else:
|
|
415
421
|
org_or_owner = {"owner": dataset.owner}
|
|
416
422
|
try:
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
423
|
+
if dryrun:
|
|
424
|
+
# In dryrun mode, only reuse existing contact points, don't create new ones.
|
|
425
|
+
# Mongoengine doesn't allow referencing unsaved documents.
|
|
426
|
+
contact = ContactPoint.objects.filter(
|
|
427
|
+
name=name, email=email, contact_form=contact_form, role=role, **org_or_owner
|
|
428
|
+
).first()
|
|
429
|
+
if not contact:
|
|
430
|
+
continue
|
|
431
|
+
else:
|
|
432
|
+
contact, _ = ContactPoint.objects.get_or_create(
|
|
433
|
+
name=name, email=email, contact_form=contact_form, role=role, **org_or_owner
|
|
434
|
+
)
|
|
420
435
|
except mongoengine.errors.ValidationError as validation_error:
|
|
421
436
|
log.warning(f"Unable to validate contact point: {validation_error}", exc_info=True)
|
|
422
437
|
continue
|
udata/settings.py
CHANGED
|
@@ -174,6 +174,10 @@ class Defaults(object):
|
|
|
174
174
|
SITE_AUTHOR = "Udata"
|
|
175
175
|
SITE_GITHUB_URL = "https://github.com/etalab/udata"
|
|
176
176
|
|
|
177
|
+
TERMS_OF_USE_URL = None
|
|
178
|
+
TERMS_OF_USE_DELETION_ARTICLE = None
|
|
179
|
+
TELERECOURS_URL = None
|
|
180
|
+
|
|
177
181
|
UDATA_INSTANCE_NAME = "udata"
|
|
178
182
|
|
|
179
183
|
HARVESTER_BACKENDS = []
|
|
@@ -1555,6 +1555,44 @@ class DatasetsFeedAPItest(APITestCase):
|
|
|
1555
1555
|
entry = feed.entries[0]
|
|
1556
1556
|
assert uris.validate(entry["id"])
|
|
1557
1557
|
|
|
1558
|
+
@pytest.mark.options(DELAY_BEFORE_APPEARING_IN_RSS_FEED=0)
|
|
1559
|
+
def test_recent_feed_with_organization_filter(self):
|
|
1560
|
+
org1 = OrganizationFactory()
|
|
1561
|
+
org2 = OrganizationFactory()
|
|
1562
|
+
DatasetFactory(title="Dataset Org1", organization=org1, resources=[ResourceFactory()])
|
|
1563
|
+
DatasetFactory(title="Dataset Org2", organization=org2, resources=[ResourceFactory()])
|
|
1564
|
+
|
|
1565
|
+
response = self.get(url_for("api.recent_datasets_atom_feed", organization=str(org1.id)))
|
|
1566
|
+
self.assert200(response)
|
|
1567
|
+
|
|
1568
|
+
feed = feedparser.parse(response.data)
|
|
1569
|
+
self.assertEqual(len(feed.entries), 1)
|
|
1570
|
+
self.assertEqual(feed.entries[0].title, "Dataset Org1")
|
|
1571
|
+
|
|
1572
|
+
@pytest.mark.options(DELAY_BEFORE_APPEARING_IN_RSS_FEED=0)
|
|
1573
|
+
def test_recent_feed_with_tag_filter(self):
|
|
1574
|
+
DatasetFactory(title="Tagged", tags=["transport"], resources=[ResourceFactory()])
|
|
1575
|
+
DatasetFactory(title="Not Tagged", tags=["other"], resources=[ResourceFactory()])
|
|
1576
|
+
|
|
1577
|
+
response = self.get(url_for("api.recent_datasets_atom_feed", tag="transport"))
|
|
1578
|
+
self.assert200(response)
|
|
1579
|
+
|
|
1580
|
+
feed = feedparser.parse(response.data)
|
|
1581
|
+
self.assertEqual(len(feed.entries), 1)
|
|
1582
|
+
self.assertEqual(feed.entries[0].title, "Tagged")
|
|
1583
|
+
|
|
1584
|
+
@pytest.mark.options(DELAY_BEFORE_APPEARING_IN_RSS_FEED=0)
|
|
1585
|
+
def test_recent_feed_with_search_query(self):
|
|
1586
|
+
DatasetFactory(title="Transport public", resources=[ResourceFactory()])
|
|
1587
|
+
DatasetFactory(title="Environnement", resources=[ResourceFactory()])
|
|
1588
|
+
|
|
1589
|
+
response = self.get(url_for("api.recent_datasets_atom_feed", q="transport"))
|
|
1590
|
+
self.assert200(response)
|
|
1591
|
+
|
|
1592
|
+
feed = feedparser.parse(response.data)
|
|
1593
|
+
self.assertEqual(len(feed.entries), 1)
|
|
1594
|
+
self.assertEqual(feed.entries[0].title, "Transport public")
|
|
1595
|
+
|
|
1558
1596
|
|
|
1559
1597
|
class DatasetBadgeAPITest(APITestCase):
|
|
1560
1598
|
@classmethod
|
|
@@ -1706,6 +1744,12 @@ class DatasetResourceAPITest(APITestCase):
|
|
|
1706
1744
|
self.dataset.reload()
|
|
1707
1745
|
self.assertEqual(len(self.dataset.resources), 2)
|
|
1708
1746
|
|
|
1747
|
+
def test_create_with_list_returns_400(self):
|
|
1748
|
+
"""It should return 400 when sending a list instead of a dict"""
|
|
1749
|
+
data = [ResourceFactory.as_dict()]
|
|
1750
|
+
response = self.post(url_for("api.resources", dataset=self.dataset), data)
|
|
1751
|
+
self.assert400(response)
|
|
1752
|
+
|
|
1709
1753
|
def test_create_with_file(self):
|
|
1710
1754
|
"""It should create a resource from the API with a file"""
|
|
1711
1755
|
user = self.login()
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
from udata.core.dataservices.factories import DataserviceFactory
|
|
2
|
+
from udata.core.organization.factories import OrganizationFactory
|
|
3
|
+
from udata.core.reuse.factories import ReuseFactory
|
|
4
|
+
from udata.tests.api import APITestCase
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class SearchAPIV2Test(APITestCase):
|
|
8
|
+
def test_dataservice_search_with_model_query_param(self):
|
|
9
|
+
"""Searching dataservices with 'model' as query param should not crash.
|
|
10
|
+
|
|
11
|
+
Regression test for: TypeError: query() got multiple values for argument 'model'
|
|
12
|
+
"""
|
|
13
|
+
DataserviceFactory.create_batch(3)
|
|
14
|
+
|
|
15
|
+
response = self.get("/api/2/dataservices/search/?model=malicious")
|
|
16
|
+
self.assert200(response)
|
|
17
|
+
|
|
18
|
+
def test_reuse_search_with_model_query_param(self):
|
|
19
|
+
"""Searching reuses with 'model' as query param should not crash."""
|
|
20
|
+
ReuseFactory.create_batch(3)
|
|
21
|
+
|
|
22
|
+
response = self.get("/api/2/reuses/search/?model=malicious")
|
|
23
|
+
self.assert200(response)
|
|
24
|
+
|
|
25
|
+
def test_organization_search_with_model_query_param(self):
|
|
26
|
+
"""Searching organizations with 'model' as query param should not crash."""
|
|
27
|
+
OrganizationFactory.create_batch(3)
|
|
28
|
+
|
|
29
|
+
response = self.get("/api/2/organizations/search/?model=malicious")
|
|
30
|
+
self.assert200(response)
|
|
@@ -43,3 +43,32 @@ class DataserviceTasksTest(PytestOnlyDBTestCase):
|
|
|
43
43
|
assert Discussion.objects.filter(id=discussion.id).count() == 0
|
|
44
44
|
assert Follow.objects.filter(id=follower.id).count() == 0
|
|
45
45
|
assert HarvestJob.objects.filter(items__dataservice=dataservices[0].id).count() == 0
|
|
46
|
+
|
|
47
|
+
def test_purge_dataservices_cleans_all_harvest_items_references(self):
|
|
48
|
+
"""Test that purging dataservices cleans all HarvestItem references in a job.
|
|
49
|
+
|
|
50
|
+
The same dataservice can appear multiple times in a job's items (e.g. if the
|
|
51
|
+
harvest source has duplicates). The $ operator only updates the first match,
|
|
52
|
+
so we need to use $[] with array_filters to update all matches.
|
|
53
|
+
"""
|
|
54
|
+
dataservice_to_delete = Dataservice.objects.create(
|
|
55
|
+
title="delete me", base_api_url="https://example.com/api", deleted_at="2016-01-01"
|
|
56
|
+
)
|
|
57
|
+
dataservice_keep = Dataservice.objects.create(
|
|
58
|
+
title="keep me", base_api_url="https://example.com/api"
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
job = HarvestJobFactory(
|
|
62
|
+
items=[
|
|
63
|
+
HarvestItem(dataservice=dataservice_to_delete, remote_id="1"),
|
|
64
|
+
HarvestItem(dataservice=dataservice_keep, remote_id="2"),
|
|
65
|
+
HarvestItem(dataservice=dataservice_to_delete, remote_id="3"),
|
|
66
|
+
]
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
tasks.purge_dataservices()
|
|
70
|
+
|
|
71
|
+
job.reload()
|
|
72
|
+
assert job.items[0].dataservice is None
|
|
73
|
+
assert job.items[1].dataservice == dataservice_keep
|
|
74
|
+
assert job.items[2].dataservice is None
|
|
@@ -755,6 +755,22 @@ class RdfToDatasetTest(PytestOnlyDBTestCase):
|
|
|
755
755
|
assert isinstance(dataset, Dataset)
|
|
756
756
|
assert set(dataset.tags) == set(tags + themes)
|
|
757
757
|
|
|
758
|
+
def test_keyword_as_uriref(self):
|
|
759
|
+
"""Regression test: keywords can be URIRef instead of Literal in some DCAT feeds."""
|
|
760
|
+
node = BNode()
|
|
761
|
+
g = Graph()
|
|
762
|
+
|
|
763
|
+
g.add((node, RDF.type, DCAT.Dataset))
|
|
764
|
+
g.add((node, DCT.title, Literal(faker.sentence())))
|
|
765
|
+
g.add((node, DCAT.keyword, Literal("literal-tag")))
|
|
766
|
+
g.add((node, DCAT.keyword, URIRef("http://example.org/keyword/uriref-tag")))
|
|
767
|
+
|
|
768
|
+
dataset = dataset_from_rdf(g)
|
|
769
|
+
dataset.validate()
|
|
770
|
+
|
|
771
|
+
assert isinstance(dataset, Dataset)
|
|
772
|
+
assert "literal-tag" in dataset.tags
|
|
773
|
+
|
|
758
774
|
def test_parse_null_frequency(self):
|
|
759
775
|
assert frequency_from_rdf(None) is None
|
|
760
776
|
|
|
@@ -60,6 +60,31 @@ class DatasetTasksTest(PytestOnlyDBTestCase):
|
|
|
60
60
|
assert HarvestJob.objects.filter(items__dataset=datasets[0].id).count() == 0
|
|
61
61
|
assert Dataservice.objects.filter(datasets=datasets[0].id).count() == 0
|
|
62
62
|
|
|
63
|
+
def test_purge_datasets_cleans_all_harvest_items_references(self):
|
|
64
|
+
"""Test that purging datasets cleans all HarvestItem references in a job.
|
|
65
|
+
|
|
66
|
+
The same dataset can appear multiple times in a job's items (e.g. if the
|
|
67
|
+
harvest source has duplicates). The $ operator only updates the first match,
|
|
68
|
+
so we need to use $[] with array_filters to update all matches.
|
|
69
|
+
"""
|
|
70
|
+
dataset_to_delete = Dataset.objects.create(title="delete me", deleted="2016-01-01")
|
|
71
|
+
dataset_keep = Dataset.objects.create(title="keep me")
|
|
72
|
+
|
|
73
|
+
job = HarvestJobFactory(
|
|
74
|
+
items=[
|
|
75
|
+
HarvestItem(dataset=dataset_to_delete, remote_id="1"),
|
|
76
|
+
HarvestItem(dataset=dataset_keep, remote_id="2"),
|
|
77
|
+
HarvestItem(dataset=dataset_to_delete, remote_id="3"),
|
|
78
|
+
]
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
tasks.purge_datasets()
|
|
82
|
+
|
|
83
|
+
job.reload()
|
|
84
|
+
assert job.items[0].dataset is None
|
|
85
|
+
assert job.items[1].dataset == dataset_keep
|
|
86
|
+
assert job.items[2].dataset is None
|
|
87
|
+
|
|
63
88
|
def test_purge_datasets_community(self):
|
|
64
89
|
dataset = Dataset.objects.create(title="delete me", deleted="2016-01-01")
|
|
65
90
|
community_resource1 = CommunityResourceFactory()
|
|
@@ -45,3 +45,37 @@ class AuthTest(APITestCase):
|
|
|
45
45
|
# Email should not have changed
|
|
46
46
|
user.reload()
|
|
47
47
|
assert user.email == original_email
|
|
48
|
+
|
|
49
|
+
def test_change_mail_after_password_change(self):
|
|
50
|
+
"""Changing password rotates fs_uniquifier and invalidates email change token"""
|
|
51
|
+
user = UserFactory(password="Password123")
|
|
52
|
+
self.login(user)
|
|
53
|
+
old_uniquifier = user.fs_uniquifier
|
|
54
|
+
|
|
55
|
+
new_email = "new@example.com"
|
|
56
|
+
|
|
57
|
+
security = current_app.extensions["security"]
|
|
58
|
+
|
|
59
|
+
data = [str(user.fs_uniquifier), hash_data(user.email), new_email]
|
|
60
|
+
token = security.confirm_serializer.dumps(data)
|
|
61
|
+
confirmation_link = url_for("security.confirm_change_email", token=token)
|
|
62
|
+
|
|
63
|
+
# Change password via API
|
|
64
|
+
resp = self.post(
|
|
65
|
+
url_for("security.change_password"),
|
|
66
|
+
{
|
|
67
|
+
"password": "Password123",
|
|
68
|
+
"new_password": "NewPassword456",
|
|
69
|
+
"new_password_confirm": "NewPassword456",
|
|
70
|
+
"submit": True,
|
|
71
|
+
},
|
|
72
|
+
)
|
|
73
|
+
assert resp.status_code == 200, f"Password change failed: {resp.data}"
|
|
74
|
+
|
|
75
|
+
user.reload()
|
|
76
|
+
assert user.fs_uniquifier != old_uniquifier, "fs_uniquifier should have changed"
|
|
77
|
+
|
|
78
|
+
# Now try to use the email change link - should fail
|
|
79
|
+
resp = self.get(confirmation_link)
|
|
80
|
+
assert resp.status_code == 302
|
|
81
|
+
assert "change_email_invalid" in resp.location
|
udata/tests/helpers.py
CHANGED
|
@@ -4,6 +4,7 @@ from datetime import timedelta
|
|
|
4
4
|
from io import BytesIO
|
|
5
5
|
from urllib.parse import parse_qs, urlparse
|
|
6
6
|
|
|
7
|
+
import pytest
|
|
7
8
|
from flask import current_app, json
|
|
8
9
|
from flask_security.babel import FsDomain
|
|
9
10
|
from PIL import Image
|
|
@@ -11,6 +12,11 @@ from PIL import Image
|
|
|
11
12
|
from udata.core.spatial.factories import GeoZoneFactory
|
|
12
13
|
from udata.mail import mail_sent
|
|
13
14
|
|
|
15
|
+
requires_search_service = pytest.mark.skipif(
|
|
16
|
+
not os.environ.get("UDATA_TEST_SEARCH_INTEGRATION"),
|
|
17
|
+
reason="Set UDATA_TEST_SEARCH_INTEGRATION=1 to run search integration tests",
|
|
18
|
+
)
|
|
19
|
+
|
|
14
20
|
|
|
15
21
|
def assert_equal_dates(datetime1, datetime2, limit=1): # Seconds.
|
|
16
22
|
"""Lax date comparison, avoid comparing milliseconds and seconds."""
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import time
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
|
|
5
|
+
from udata.core.dataset.factories import DatasetFactory
|
|
6
|
+
from udata.tests.api import APITestCase
|
|
7
|
+
from udata.tests.helpers import requires_search_service
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@requires_search_service
|
|
11
|
+
@pytest.mark.options(SEARCH_SERVICE_API_URL="http://localhost:5000/api/1/", AUTO_INDEX=True)
|
|
12
|
+
class SearchIntegrationTest(APITestCase):
|
|
13
|
+
"""Integration tests that require a running search-service and Elasticsearch."""
|
|
14
|
+
|
|
15
|
+
def test_dataset_fuzzy_search(self):
|
|
16
|
+
"""
|
|
17
|
+
Test that Elasticsearch fuzzy search works.
|
|
18
|
+
|
|
19
|
+
A typo in the search query ("spectakulaire" instead of "spectaculaire")
|
|
20
|
+
should still find the dataset thanks to ES fuzzy matching.
|
|
21
|
+
"""
|
|
22
|
+
DatasetFactory(title="Données spectaculaires sur les transports")
|
|
23
|
+
|
|
24
|
+
# Small delay to let ES index the document
|
|
25
|
+
time.sleep(1)
|
|
26
|
+
|
|
27
|
+
# Search with a typo - only ES fuzzy search can handle this
|
|
28
|
+
response = self.get("/api/2/datasets/search/?q=spectakulaire")
|
|
29
|
+
self.assert200(response)
|
|
30
|
+
assert response.json["total"] >= 1
|
|
31
|
+
|
|
32
|
+
titles = [d["title"] for d in response.json["data"]]
|
|
33
|
+
assert "Données spectaculaires sur les transports" in titles
|
udata/tests/test_api_fields.py
CHANGED
|
@@ -354,3 +354,13 @@ class ApplyPaginationTest(PytestOnlyDBTestCase):
|
|
|
354
354
|
results: DBPaginator = Fake.apply_pagination(Fake.apply_sort_filters(Fake.objects))
|
|
355
355
|
assert results.page_size == 5
|
|
356
356
|
assert results.page == 3
|
|
357
|
+
|
|
358
|
+
def test_negative_page_size_returns_404(self, app) -> None:
|
|
359
|
+
"""Negative page_size should return a 404 error."""
|
|
360
|
+
from werkzeug.exceptions import NotFound
|
|
361
|
+
|
|
362
|
+
FakeFactory()
|
|
363
|
+
|
|
364
|
+
with app.test_request_context("/foobar", query_string={"page": 1, "page_size": -5}):
|
|
365
|
+
with pytest.raises(NotFound):
|
|
366
|
+
Fake.apply_pagination(Fake.apply_sort_filters(Fake.objects))
|