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.
Files changed (50) hide show
  1. udata/api/__init__.py +2 -0
  2. udata/auth/views.py +7 -3
  3. udata/commands/dcat.py +1 -1
  4. udata/core/dataservices/api.py +8 -1
  5. udata/core/dataservices/apiv2.py +2 -5
  6. udata/core/dataservices/models.py +4 -1
  7. udata/core/dataservices/rdf.py +2 -1
  8. udata/core/dataservices/tasks.py +6 -2
  9. udata/core/dataset/api.py +28 -4
  10. udata/core/dataset/api_fields.py +1 -1
  11. udata/core/dataset/apiv2.py +1 -1
  12. udata/core/dataset/models.py +4 -4
  13. udata/core/dataset/rdf.py +8 -2
  14. udata/core/dataset/tasks.py +6 -2
  15. udata/core/discussions/api.py +15 -1
  16. udata/core/discussions/models.py +5 -0
  17. udata/core/legal/__init__.py +0 -0
  18. udata/core/legal/mails.py +128 -0
  19. udata/core/organization/api.py +8 -0
  20. udata/core/organization/api_fields.py +3 -3
  21. udata/core/organization/apiv2.py +2 -3
  22. udata/core/organization/models.py +6 -1
  23. udata/core/reuse/api.py +8 -0
  24. udata/core/reuse/apiv2.py +2 -5
  25. udata/core/topic/models.py +8 -2
  26. udata/core/user/api.py +10 -3
  27. udata/core/user/api_fields.py +3 -3
  28. udata/core/user/models.py +7 -1
  29. udata/flask_mongoengine/pagination.py +1 -1
  30. udata/harvest/backends/dcat.py +4 -1
  31. udata/harvest/tests/test_dcat_backend.py +24 -0
  32. udata/mail.py +14 -0
  33. udata/rdf.py +20 -5
  34. udata/settings.py +4 -0
  35. udata/tests/api/test_datasets_api.py +44 -0
  36. udata/tests/apiv2/test_search.py +30 -0
  37. udata/tests/dataservice/test_dataservice_tasks.py +29 -0
  38. udata/tests/dataset/test_dataset_rdf.py +16 -0
  39. udata/tests/dataset/test_dataset_tasks.py +25 -0
  40. udata/tests/frontend/test_auth.py +34 -0
  41. udata/tests/helpers.py +6 -0
  42. udata/tests/search/test_search_integration.py +33 -0
  43. udata/tests/test_api_fields.py +10 -0
  44. udata/tests/test_legal_mails.py +359 -0
  45. {udata-14.4.1.dev7.dist-info → udata-14.5.1.dev9.dist-info}/METADATA +2 -2
  46. {udata-14.4.1.dev7.dist-info → udata-14.5.1.dev9.dist-info}/RECORD +50 -45
  47. {udata-14.4.1.dev7.dist-info → udata-14.5.1.dev9.dist-info}/WHEEL +0 -0
  48. {udata-14.4.1.dev7.dist-info → udata-14.5.1.dev9.dist-info}/entry_points.txt +0 -0
  49. {udata-14.4.1.dev7.dist-info → udata-14.5.1.dev9.dist-info}/licenses/LICENSE +0 -0
  50. {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, **multi_to_dict(request.args))
28
+ args = search_parser.parse_args()
29
+ return search.query(ReuseSearch, **args)
@@ -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(db.StringField(required=False))
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(db.StringField())
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="Do not send a mail to notify the user of the deletion",
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
- user.mark_as_deleted(notify=not args["no_mail"], delete_comments=args["delete_comments"])
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
 
@@ -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", required=True),
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", required=True),
39
- "slug": fields.String(description="The user permalink string", required=True),
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(db.StringField())
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",
@@ -6,7 +6,7 @@ from mongoengine.queryset import QuerySet
6
6
 
7
7
  class Pagination(object):
8
8
  def __init__(self, iterable, page, per_page):
9
- if page < 1:
9
+ if page < 1 or per_page < 1:
10
10
  abort(404)
11
11
 
12
12
  self.iterable = iterable
@@ -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(page, dataset, node=node, remote_url_prefix=remote_url_prefix)
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 = [tag.toPython() for tag in rdf.objects(DCAT.keyword)]
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
- contact, _ = ContactPoint.objects.get_or_create(
418
- name=name, email=email, contact_form=contact_form, role=role, **org_or_owner
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
@@ -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))