udata 10.4.1.dev35201__py2.py3-none-any.whl → 10.4.2__py2.py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of udata might be problematic. Click here for more details.

Files changed (50) hide show
  1. udata/__init__.py +1 -1
  2. udata/core/activity/__init__.py +2 -0
  3. udata/core/activity/api.py +10 -2
  4. udata/core/activity/models.py +28 -1
  5. udata/core/activity/tasks.py +19 -4
  6. udata/core/dataservices/activities.py +53 -0
  7. udata/core/dataservices/api.py +43 -0
  8. udata/core/dataservices/models.py +16 -20
  9. udata/core/dataset/activities.py +52 -5
  10. udata/core/dataset/api.py +44 -0
  11. udata/core/dataset/csv.py +0 -1
  12. udata/core/dataset/models.py +49 -47
  13. udata/core/dataset/rdf.py +1 -1
  14. udata/core/metrics/commands.py +1 -0
  15. udata/core/metrics/helpers.py +102 -0
  16. udata/core/metrics/models.py +1 -0
  17. udata/core/metrics/tasks.py +1 -0
  18. udata/core/organization/activities.py +3 -2
  19. udata/core/organization/api.py +11 -0
  20. udata/core/organization/api_fields.py +6 -5
  21. udata/core/organization/models.py +31 -31
  22. udata/core/owned.py +1 -1
  23. udata/core/post/api.py +34 -0
  24. udata/core/reuse/activities.py +6 -5
  25. udata/core/reuse/api.py +42 -1
  26. udata/core/reuse/models.py +8 -16
  27. udata/core/site/models.py +33 -0
  28. udata/core/topic/activities.py +36 -0
  29. udata/core/topic/models.py +23 -15
  30. udata/core/user/activities.py +17 -6
  31. udata/core/user/api.py +1 -0
  32. udata/core/user/api_fields.py +6 -1
  33. udata/core/user/models.py +39 -32
  34. udata/migrations/2025-05-22-purge-duplicate-activities.py +101 -0
  35. udata/mongo/datetime_fields.py +1 -0
  36. udata/settings.py +4 -0
  37. udata/tests/api/test_activities_api.py +29 -1
  38. udata/tests/api/test_dataservices_api.py +53 -0
  39. udata/tests/api/test_datasets_api.py +61 -0
  40. udata/tests/api/test_organizations_api.py +27 -2
  41. udata/tests/api/test_reuses_api.py +54 -0
  42. udata/tests/dataset/test_csv_adapter.py +6 -3
  43. udata/tests/dataset/test_dataset_model.py +49 -0
  44. udata/tests/test_topics.py +19 -0
  45. {udata-10.4.1.dev35201.dist-info → udata-10.4.2.dist-info}/METADATA +17 -2
  46. {udata-10.4.1.dev35201.dist-info → udata-10.4.2.dist-info}/RECORD +50 -46
  47. {udata-10.4.1.dev35201.dist-info → udata-10.4.2.dist-info}/LICENSE +0 -0
  48. {udata-10.4.1.dev35201.dist-info → udata-10.4.2.dist-info}/WHEEL +0 -0
  49. {udata-10.4.1.dev35201.dist-info → udata-10.4.2.dist-info}/entry_points.txt +0 -0
  50. {udata-10.4.1.dev35201.dist-info → udata-10.4.2.dist-info}/top_level.txt +0 -0
@@ -1,6 +1,7 @@
1
1
  from datetime import datetime
2
2
  from xml.etree.ElementTree import XML
3
3
 
4
+ import feedparser
4
5
  import pytest
5
6
  from flask import url_for
6
7
  from werkzeug.test import TestResponse
@@ -627,3 +628,55 @@ class DataserviceRdfViewsTest:
627
628
  response = client.get(url, headers={"Accept": mime})
628
629
  assert200(response)
629
630
  assert response.content_type == mime
631
+
632
+
633
+ class DataservicesFeedAPItest(APITestCase):
634
+ def test_recent_feed(self):
635
+ dataservices = [DataserviceFactory() for i in range(3)]
636
+
637
+ response = self.get(url_for("api.recent_dataservices_atom_feed"))
638
+
639
+ self.assert200(response)
640
+
641
+ feed = feedparser.parse(response.data)
642
+
643
+ self.assertEqual(len(feed.entries), len(dataservices))
644
+ for i in range(1, len(feed.entries)):
645
+ published_date = feed.entries[i].published_parsed
646
+ prev_published_date = feed.entries[i - 1].published_parsed
647
+ self.assertGreaterEqual(prev_published_date, published_date)
648
+
649
+ def test_recent_feed_owner(self):
650
+ owner = UserFactory()
651
+ DataserviceFactory(owner=owner)
652
+
653
+ response = self.get(url_for("api.recent_dataservices_atom_feed"))
654
+
655
+ self.assert200(response)
656
+
657
+ feed = feedparser.parse(response.data)
658
+
659
+ self.assertEqual(len(feed.entries), 1)
660
+ entry = feed.entries[0]
661
+ self.assertEqual(len(entry.authors), 1)
662
+ author = entry.authors[0]
663
+ self.assertEqual(author.name, owner.fullname)
664
+ self.assertEqual(author.href, owner.external_url)
665
+
666
+ def test_recent_feed_org(self):
667
+ owner = UserFactory()
668
+ org = OrganizationFactory()
669
+ DataserviceFactory(owner=owner, organization=org)
670
+
671
+ response = self.get(url_for("api.recent_dataservices_atom_feed"))
672
+
673
+ self.assert200(response)
674
+
675
+ feed = feedparser.parse(response.data)
676
+
677
+ self.assertEqual(len(feed.entries), 1)
678
+ entry = feed.entries[0]
679
+ self.assertEqual(len(entry.authors), 1)
680
+ author = entry.authors[0]
681
+ self.assertEqual(author.name, org.name)
682
+ self.assertEqual(author.href, org.external_url)
@@ -3,6 +3,7 @@ from datetime import datetime
3
3
  from io import BytesIO
4
4
  from uuid import uuid4
5
5
 
6
+ import feedparser
6
7
  import pytest
7
8
  import pytz
8
9
  import requests_mock
@@ -977,6 +978,14 @@ class DatasetAPITest(APITestCase):
977
978
  dataset = Dataset.objects.first()
978
979
  self.assertEqual(dataset.contact_points[0].name, contact_point_data["name"])
979
980
 
981
+ data["contact_points"] = []
982
+ response = self.put(url_for("api.dataset", dataset=dataset), data)
983
+ self.assert200(response)
984
+
985
+ dataset = Dataset.objects.first()
986
+ # This is weird, we should have no contact point if sending an empty array… :RemoveAllContactPoints (in cdata)
987
+ self.assertEqual(dataset.contact_points[0].name, contact_point_data["name"])
988
+
980
989
  data["contact_points"] = None
981
990
  response = self.put(url_for("api.dataset", dataset=dataset), data)
982
991
  self.assert200(response)
@@ -1242,6 +1251,58 @@ class DatasetAPITest(APITestCase):
1242
1251
  }
1243
1252
 
1244
1253
 
1254
+ class DatasetsFeedAPItest(APITestCase):
1255
+ def test_recent_feed(self):
1256
+ datasets = [DatasetFactory(resources=[ResourceFactory()]) for i in range(3)]
1257
+
1258
+ response = self.get(url_for("api.recent_datasets_atom_feed"))
1259
+
1260
+ self.assert200(response)
1261
+
1262
+ feed = feedparser.parse(response.data)
1263
+
1264
+ self.assertEqual(len(feed.entries), len(datasets))
1265
+ for i in range(1, len(feed.entries)):
1266
+ published_date = feed.entries[i].published_parsed
1267
+ prev_published_date = feed.entries[i - 1].published_parsed
1268
+ self.assertGreaterEqual(prev_published_date, published_date)
1269
+
1270
+ def test_recent_feed_owner(self):
1271
+ owner = UserFactory()
1272
+ DatasetFactory(owner=owner, resources=[ResourceFactory()])
1273
+
1274
+ response = self.get(url_for("api.recent_datasets_atom_feed"))
1275
+
1276
+ self.assert200(response)
1277
+
1278
+ feed = feedparser.parse(response.data)
1279
+
1280
+ self.assertEqual(len(feed.entries), 1)
1281
+ entry = feed.entries[0]
1282
+ self.assertEqual(len(entry.authors), 1)
1283
+ author = entry.authors[0]
1284
+ self.assertEqual(author.name, owner.fullname)
1285
+ self.assertEqual(author.href, owner.external_url)
1286
+
1287
+ def test_recent_feed_org(self):
1288
+ owner = UserFactory()
1289
+ org = OrganizationFactory()
1290
+ DatasetFactory(owner=owner, organization=org, resources=[ResourceFactory()])
1291
+
1292
+ response = self.get(url_for("api.recent_datasets_atom_feed"))
1293
+
1294
+ self.assert200(response)
1295
+
1296
+ feed = feedparser.parse(response.data)
1297
+
1298
+ self.assertEqual(len(feed.entries), 1)
1299
+ entry = feed.entries[0]
1300
+ self.assertEqual(len(entry.authors), 1)
1301
+ author = entry.authors[0]
1302
+ self.assertEqual(author.name, org.name)
1303
+ self.assertEqual(author.href, org.external_url)
1304
+
1305
+
1245
1306
  class DatasetBadgeAPITest(APITestCase):
1246
1307
  @classmethod
1247
1308
  def setUpClass(cls):
@@ -8,6 +8,7 @@ import udata.core.organization.constants as org_constants
8
8
  from udata.core import csv
9
9
  from udata.core.badges.factories import badge_factory
10
10
  from udata.core.badges.signals import on_badge_added, on_badge_removed
11
+ from udata.core.dataservices.factories import DataserviceFactory
11
12
  from udata.core.dataset.factories import DatasetFactory, ResourceFactory
12
13
  from udata.core.discussions.factories import DiscussionFactory
13
14
  from udata.core.organization.factories import OrganizationFactory
@@ -362,10 +363,10 @@ class MembershipAPITest:
362
363
  assert len(members) == 2
363
364
  assert members[0]["role"] == "admin"
364
365
  assert members[0]["since"] == "2024-04-14T00:00:00+00:00"
365
- assert members[0]["user"]["email"] is None
366
+ assert members[0]["user"]["email"] == "***@example.org"
366
367
 
367
368
  assert members[1]["role"] == "editor"
368
- assert members[1]["user"]["email"] is None
369
+ assert members[1]["user"]["email"] == "***@example.org"
369
370
 
370
371
  # Super admin of udata can see emails
371
372
  api.login(AdminFactory())
@@ -1042,6 +1043,30 @@ class OrganizationCsvExportsTest:
1042
1043
  assert str(hidden_dataset.id) not in dataset_ids
1043
1044
  assert str(not_org_dataset.id) not in dataset_ids
1044
1045
 
1046
+ def test_dataservices_csv(self, api):
1047
+ org = OrganizationFactory()
1048
+ [DataserviceFactory(organization=org) for _ in range(3)]
1049
+
1050
+ response = api.get(url_for("api.organization_dataservices_csv", org=org))
1051
+
1052
+ assert200(response)
1053
+ assert response.mimetype == "text/csv"
1054
+ assert response.charset == "utf-8"
1055
+
1056
+ csvfile = StringIO(response.data.decode("utf-8"))
1057
+ reader = csv.get_reader(csvfile)
1058
+ header = next(reader)
1059
+
1060
+ assert header[0] == "id"
1061
+ assert "title" in header
1062
+ assert "url" in header
1063
+ assert "description" in header
1064
+ assert "created_at" in header
1065
+ assert "metadata_modified_at" in header
1066
+ assert "tags" in header
1067
+ assert "metric.views" in header
1068
+ assert "datasets" in header
1069
+
1045
1070
  def test_discussions_csv_content_empty(self, api):
1046
1071
  organization = OrganizationFactory()
1047
1072
  response = api.get(url_for("api.organization_discussions_csv", org=organization))
@@ -1,5 +1,6 @@
1
1
  from datetime import datetime
2
2
 
3
+ import feedparser
3
4
  import pytest
4
5
  from flask import url_for
5
6
  from werkzeug.test import TestResponse
@@ -12,6 +13,7 @@ from udata.core.reuse.constants import REUSE_TOPICS, REUSE_TYPES
12
13
  from udata.core.reuse.factories import ReuseFactory
13
14
  from udata.core.user.factories import AdminFactory, UserFactory
14
15
  from udata.models import Follow, Member, Reuse
16
+ from udata.tests.api import APITestCase
15
17
  from udata.tests.helpers import (
16
18
  assert200,
17
19
  assert201,
@@ -569,6 +571,58 @@ class ReuseAPITest:
569
571
  assert len(response.json) == 0
570
572
 
571
573
 
574
+ class ReusesFeedAPItest(APITestCase):
575
+ def test_recent_feed(self):
576
+ datasets = [ReuseFactory(datasets=[DatasetFactory()]) for i in range(3)]
577
+
578
+ response = self.get(url_for("api.recent_reuses_atom_feed"))
579
+
580
+ self.assert200(response)
581
+
582
+ feed = feedparser.parse(response.data)
583
+
584
+ self.assertEqual(len(feed.entries), len(datasets))
585
+ for i in range(1, len(feed.entries)):
586
+ published_date = feed.entries[i].published_parsed
587
+ prev_published_date = feed.entries[i - 1].published_parsed
588
+ self.assertGreaterEqual(prev_published_date, published_date)
589
+
590
+ def test_recent_feed_owner(self):
591
+ owner = UserFactory()
592
+ ReuseFactory(owner=owner, datasets=[DatasetFactory()])
593
+
594
+ response = self.get(url_for("api.recent_reuses_atom_feed"))
595
+
596
+ self.assert200(response)
597
+
598
+ feed = feedparser.parse(response.data)
599
+
600
+ self.assertEqual(len(feed.entries), 1)
601
+ entry = feed.entries[0]
602
+ self.assertEqual(len(entry.authors), 1)
603
+ author = entry.authors[0]
604
+ self.assertEqual(author.name, owner.fullname)
605
+ self.assertEqual(author.href, owner.external_url)
606
+
607
+ def test_recent_feed_org(self):
608
+ owner = UserFactory()
609
+ org = OrganizationFactory()
610
+ ReuseFactory(owner=owner, organization=org, datasets=[DatasetFactory()])
611
+
612
+ response = self.get(url_for("api.recent_reuses_atom_feed"))
613
+
614
+ self.assert200(response)
615
+
616
+ feed = feedparser.parse(response.data)
617
+
618
+ self.assertEqual(len(feed.entries), 1)
619
+ entry = feed.entries[0]
620
+ self.assertEqual(len(entry.authors), 1)
621
+ author = entry.authors[0]
622
+ self.assertEqual(author.name, org.name)
623
+ self.assertEqual(author.href, org.external_url)
624
+
625
+
572
626
  class ReuseBadgeAPITest:
573
627
  modules = []
574
628
 
@@ -21,7 +21,10 @@ class DatasetCSVAdapterTest:
21
21
  "created_at": date_created,
22
22
  "modified_at": date_modified,
23
23
  "uri": "http://domain.gouv.fr/dataset/uri",
24
- }
24
+ },
25
+ metrics={
26
+ "views": 42,
27
+ },
25
28
  )
26
29
  ],
27
30
  harvest={
@@ -41,6 +44,8 @@ class DatasetCSVAdapterTest:
41
44
  assert date_modified.isoformat() in d_row
42
45
  # dataset harvest dates should not be here
43
46
  assert another_date.isoformat() not in d_row
47
+ # assert resource metrics downloads
48
+ assert 42 in d_row
44
49
 
45
50
  def test_datasets_csv_adapter(self):
46
51
  date_created = datetime(2022, 12, 31)
@@ -87,10 +92,8 @@ class DatasetCSVAdapterTest:
87
92
  assert harvest_dataset_values["harvest.domain"] == "example.com"
88
93
  assert harvest_dataset_values["harvest.remote_url"] == "https://www.example.com/"
89
94
  assert harvest_dataset_values["resources_count"] == 0
90
- assert harvest_dataset_values["downloads"] == 0
91
95
 
92
96
  resources_dataset_values = csv[str(resources_dataset.id)]
93
97
  assert resources_dataset_values["resources_count"] == 3
94
98
  assert resources_dataset_values["main_resources_count"] == 1
95
99
  assert set(resources_dataset_values["resources_formats"].split(",")) == set(["csv", "json"])
96
- assert resources_dataset_values["downloads"] == 1337 + 42
@@ -9,6 +9,14 @@ from mongoengine import ValidationError as MongoEngineValidationError
9
9
  from mongoengine import post_save
10
10
 
11
11
  from udata.app import cache
12
+ from udata.core.dataset.activities import (
13
+ UserAddedResourceToDataset,
14
+ UserCreatedDataset,
15
+ UserDeletedDataset,
16
+ UserRemovedResourceFromDataset,
17
+ UserUpdatedDataset,
18
+ UserUpdatedResource,
19
+ )
12
20
  from udata.core.dataset.constants import LEGACY_FREQUENCIES, UPDATE_FREQUENCIES
13
21
  from udata.core.dataset.exceptions import (
14
22
  SchemasCacheUnavailableException,
@@ -350,6 +358,47 @@ class DatasetModelTest:
350
358
 
351
359
  assert dataset_without_resources.resources_len == 0
352
360
 
361
+ def test_dataset_activities(self, api, mocker):
362
+ # A user must be authenticated for activities to be emitted
363
+ user = api.login()
364
+
365
+ mock_created = mocker.patch.object(UserCreatedDataset, "emit")
366
+ mock_updated = mocker.patch.object(UserUpdatedDataset, "emit")
367
+ mock_deleted = mocker.patch.object(UserDeletedDataset, "emit")
368
+ mock_resource_added = mocker.patch.object(UserAddedResourceToDataset, "emit")
369
+ mock_resouce_updated = mocker.patch.object(UserUpdatedResource, "emit")
370
+ mock_resouce_removed = mocker.patch.object(UserRemovedResourceFromDataset, "emit")
371
+
372
+ with assert_emit(Dataset.on_create):
373
+ dataset = DatasetFactory(owner=user)
374
+ mock_created.assert_called()
375
+
376
+ with assert_emit(Dataset.on_update):
377
+ dataset.title = "new title"
378
+ dataset.save()
379
+ mock_updated.assert_called()
380
+
381
+ with assert_emit(Dataset.on_resource_added):
382
+ dataset.add_resource(ResourceFactory())
383
+ mock_resource_added.assert_called()
384
+
385
+ dataset.reload()
386
+
387
+ with assert_emit(Dataset.on_resource_updated):
388
+ resource = dataset.resources[0]
389
+ resource.description = "New description"
390
+ dataset.update_resource(resource)
391
+ mock_resouce_updated.assert_called()
392
+
393
+ with assert_emit(Dataset.on_resource_removed):
394
+ dataset.remove_resource(dataset.resources[-1])
395
+ mock_resouce_removed.assert_called()
396
+
397
+ with assert_emit(Dataset.on_delete):
398
+ dataset.deleted = datetime.utcnow()
399
+ dataset.save()
400
+ mock_deleted.assert_called()
401
+
353
402
 
354
403
  class ResourceModelTest:
355
404
  def test_url_is_required(self):
@@ -1,8 +1,11 @@
1
1
  import pytest
2
2
 
3
3
  from udata.core.dataset.factories import DatasetFactory
4
+ from udata.core.topic.activities import UserCreatedTopic, UserUpdatedTopic
4
5
  from udata.core.topic.factories import TopicFactory
6
+ from udata.core.topic.models import Topic
5
7
  from udata.search import reindex
8
+ from udata.tests.helpers import assert_emit
6
9
 
7
10
 
8
11
  @pytest.fixture
@@ -45,3 +48,19 @@ class TopicModelTest:
45
48
  # creates a topic with datasets, thus calls reindex
46
49
  TopicFactory()
47
50
  job_reindex_undelayed.assert_called()
51
+
52
+ def test_topic_activities(self, api, mocker):
53
+ # A user must be authenticated for activities to be emitted
54
+ user = api.login()
55
+
56
+ mock_created = mocker.patch.object(UserCreatedTopic, "emit")
57
+ mock_updated = mocker.patch.object(UserUpdatedTopic, "emit")
58
+
59
+ with assert_emit(Topic.on_create):
60
+ topic = TopicFactory(owner=user)
61
+ mock_created.assert_called()
62
+
63
+ with assert_emit(Topic.on_update):
64
+ topic.name = "new name"
65
+ topic.save()
66
+ mock_updated.assert_called()
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: udata
3
- Version: 10.4.1.dev35201
3
+ Version: 10.4.2
4
4
  Summary: Open data portal
5
5
  Home-page: https://github.com/opendatateam/udata
6
6
  Author: Opendata Team
@@ -48,6 +48,7 @@ Requires-Dist: dnspython==2.7.0
48
48
  Requires-Dist: email-validator==2.2.0
49
49
  Requires-Dist: factory-boy==3.3.3
50
50
  Requires-Dist: faker==37.0.2
51
+ Requires-Dist: feedgenerator==2.1.0
51
52
  Requires-Dist: filelock==3.18.0
52
53
  Requires-Dist: flask==2.1.2
53
54
  Requires-Dist: flask-babel==4.0.0
@@ -139,8 +140,22 @@ It is collectively taken care of by members of the
139
140
 
140
141
  # Changelog
141
142
 
142
- ## Current (in progress)
143
+ ## 10.4.2 (2025-06-05)
143
144
 
145
+ - :warning: Add migration to clean duplicate activities [#3327](https://github.com/opendatateam/udata/pull/3327)
146
+ - Add test for removing last contact point [#3322](https://github.com/opendatateam/udata/pull/3322)
147
+ - Add activities to dataservices, topics and resources, add Auditable class to refactor improve code [#3308](https://github.com/opendatateam/udata/pull/3308) [#3333](https://github.com/opendatateam/udata/pull/3333)
148
+ - Store activities for private objects [#3328](https://github.com/opendatateam/udata/pull/3328)
149
+ - Do not crash if file doesn't exists during resource deletion [#3323](https://github.com/opendatateam/udata/pull/3323)
150
+ - Show user domain in suggest [#3324](https://github.com/opendatateam/udata/pull/3324)
151
+ - Add new global site metrics [#3325](https://github.com/opendatateam/udata/pull/3325)
152
+ - Keep the existing frequency if not found during harvesting [#3330](https://github.com/opendatateam/udata/pull/3330)
153
+ - Migrate atom feeds from udata-front to udata [#3326](https://github.com/opendatateam/udata/pull/3326)
154
+ - Add organization dataservices catalog route [#3332](https://github.com/opendatateam/udata/pull/3332)
155
+
156
+ ## 10.4.1 (2025-05-20)
157
+
158
+ - Remove duplicate `downloads` in dataset csv adapter [#3319](https://github.com/opendatateam/udata/pull/3319)
144
159
  - Add missing default for license full object [#3317](https://github.com/opendatateam/udata/pull/3317/)
145
160
  - Check for slugs in followers API [#3320](https://github.com/opendatateam/udata/pull/3320)
146
161
  - Fix missing ID in dataset reuses mask [#3321](https://github.com/opendatateam/udata/pull/3321)