udata 10.1.2.dev34172__py2.py3-none-any.whl → 10.1.3__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 (59) hide show
  1. udata/__init__.py +1 -1
  2. udata/commands/fixtures.py +1 -1
  3. udata/core/dataservices/constants.py +11 -0
  4. udata/core/dataservices/csv.py +3 -3
  5. udata/core/dataservices/models.py +27 -12
  6. udata/core/dataservices/rdf.py +5 -3
  7. udata/core/dataservices/search.py +13 -5
  8. udata/core/dataset/api.py +18 -3
  9. udata/core/dataset/forms.py +8 -4
  10. udata/core/dataset/models.py +6 -0
  11. udata/core/metrics/commands.py +20 -1
  12. udata/core/organization/api_fields.py +3 -1
  13. udata/core/user/api.py +8 -1
  14. udata/core/user/api_fields.py +5 -0
  15. udata/core/user/models.py +16 -11
  16. udata/core/user/tasks.py +81 -2
  17. udata/core/user/tests/test_user_model.py +29 -12
  18. udata/features/transfer/api.py +7 -4
  19. udata/harvest/actions.py +5 -0
  20. udata/harvest/backends/base.py +22 -2
  21. udata/harvest/models.py +19 -0
  22. udata/harvest/tests/test_actions.py +12 -0
  23. udata/harvest/tests/test_base_backend.py +74 -8
  24. udata/harvest/tests/test_dcat_backend.py +1 -1
  25. udata/migrations/2025-01-05-dataservices-fields-changes.py +136 -0
  26. udata/settings.py +5 -0
  27. udata/templates/mail/account_inactivity.html +29 -0
  28. udata/templates/mail/account_inactivity.txt +22 -0
  29. udata/templates/mail/inactive_account_deleted.html +5 -0
  30. udata/templates/mail/inactive_account_deleted.txt +6 -0
  31. udata/tests/api/test_dataservices_api.py +41 -2
  32. udata/tests/api/test_datasets_api.py +58 -0
  33. udata/tests/api/test_me_api.py +1 -1
  34. udata/tests/api/test_transfer_api.py +38 -0
  35. udata/tests/api/test_user_api.py +47 -8
  36. udata/tests/dataservice/test_csv_adapter.py +2 -0
  37. udata/tests/dataset/test_dataset_model.py +14 -0
  38. udata/tests/user/test_user_tasks.py +144 -0
  39. udata/translations/ar/LC_MESSAGES/udata.mo +0 -0
  40. udata/translations/ar/LC_MESSAGES/udata.po +88 -60
  41. udata/translations/de/LC_MESSAGES/udata.mo +0 -0
  42. udata/translations/de/LC_MESSAGES/udata.po +88 -60
  43. udata/translations/es/LC_MESSAGES/udata.mo +0 -0
  44. udata/translations/es/LC_MESSAGES/udata.po +88 -60
  45. udata/translations/fr/LC_MESSAGES/udata.mo +0 -0
  46. udata/translations/fr/LC_MESSAGES/udata.po +88 -60
  47. udata/translations/it/LC_MESSAGES/udata.mo +0 -0
  48. udata/translations/it/LC_MESSAGES/udata.po +88 -60
  49. udata/translations/pt/LC_MESSAGES/udata.mo +0 -0
  50. udata/translations/pt/LC_MESSAGES/udata.po +88 -60
  51. udata/translations/sr/LC_MESSAGES/udata.mo +0 -0
  52. udata/translations/sr/LC_MESSAGES/udata.po +88 -60
  53. udata/translations/udata.pot +83 -54
  54. {udata-10.1.2.dev34172.dist-info → udata-10.1.3.dist-info}/METADATA +15 -2
  55. {udata-10.1.2.dev34172.dist-info → udata-10.1.3.dist-info}/RECORD +59 -52
  56. {udata-10.1.2.dev34172.dist-info → udata-10.1.3.dist-info}/LICENSE +0 -0
  57. {udata-10.1.2.dev34172.dist-info → udata-10.1.3.dist-info}/WHEEL +0 -0
  58. {udata-10.1.2.dev34172.dist-info → udata-10.1.3.dist-info}/entry_points.txt +0 -0
  59. {udata-10.1.2.dev34172.dist-info → udata-10.1.3.dist-info}/top_level.txt +0 -0
@@ -558,6 +558,18 @@ class DatasetAPITest(APITestCase):
558
558
  self.assertEqual(Dataset.objects.count(), 1)
559
559
  self.assertEqual(Dataset.objects.first().description, "new description")
560
560
 
561
+ def test_cannot_modify_dataset_id(self):
562
+ user = self.login()
563
+ dataset = DatasetFactory(owner=user)
564
+
565
+ data = dataset.to_dict()
566
+ data["id"] = "7776aa373aa050e302b5714d"
567
+
568
+ response = self.put(url_for("api.dataset", dataset=dataset.id), data)
569
+
570
+ self.assert200(response)
571
+ self.assertEqual(response.json["id"], str(dataset.id))
572
+
561
573
  def test_dataset_api_update_org(self):
562
574
  """It shouldn't update the dataset org"""
563
575
  user = self.login()
@@ -1130,6 +1142,37 @@ class DatasetResourceAPITest(APITestCase):
1130
1142
  # should fail because the POST endpoint only supports URL setting for remote resources
1131
1143
  self.assert400(response)
1132
1144
 
1145
+ def test_creating_and_updating_resource_uuid(self):
1146
+ uuid_a = "c312cfb0-60f7-417c-9cf9-3d985196b22a"
1147
+ uuid_b = "e8262134-5ff0-4bd8-98bc-5db76bb27856"
1148
+
1149
+ data = ResourceFactory.as_dict()
1150
+ data["filetype"] = "remote"
1151
+ data["id"] = uuid_a
1152
+ response = self.post(url_for("api.resources", dataset=self.dataset), data)
1153
+ self.assert201(response)
1154
+ self.assertNotEqual(response.json["id"], uuid_a)
1155
+
1156
+ first_generated_uuid = response.json["id"]
1157
+
1158
+ # Sending the same UUID twice doesn't change anything…
1159
+ data = ResourceFactory.as_dict()
1160
+ data["filetype"] = "remote"
1161
+ data["id"] = first_generated_uuid
1162
+ response = self.post(url_for("api.resources", dataset=self.dataset), data)
1163
+ self.assert201(response)
1164
+ self.assertNotEqual(response.json["id"], first_generated_uuid)
1165
+
1166
+ # Cannot modify the ID of an existing resource
1167
+ data = response.json
1168
+ data["id"] = uuid_b
1169
+ response = self.put(
1170
+ url_for("api.resource", dataset=self.dataset, rid=first_generated_uuid),
1171
+ data,
1172
+ )
1173
+ self.assert200(response)
1174
+ self.assertEqual(response.json["id"], first_generated_uuid)
1175
+
1133
1176
  def test_create_normalize_format(self):
1134
1177
  _format = " FORMAT "
1135
1178
  data = ResourceFactory.as_dict()
@@ -1295,6 +1338,21 @@ class DatasetResourceAPITest(APITestCase):
1295
1338
  self.assertEqual(updated.url, data["url"])
1296
1339
  self.assertEqual(updated.extras, {"extra:id": "id"})
1297
1340
 
1341
+ def test_cannot_update_resource_filetype(self):
1342
+ user = self.login()
1343
+ resource = ResourceFactory(filetype="file")
1344
+ dataset = DatasetFactory(owner=user, resources=[resource])
1345
+
1346
+ data = {
1347
+ "filetype": "remote",
1348
+ "url": faker.url(),
1349
+ }
1350
+ response = self.put(url_for("api.resource", dataset=dataset, rid=str(resource.id)), data)
1351
+ self.assert400(response)
1352
+
1353
+ dataset.reload()
1354
+ self.assertEqual(dataset.resources[0].filetype, "file")
1355
+
1298
1356
  def test_bulk_update(self):
1299
1357
  resources = ResourceFactory.build_batch(2)
1300
1358
  self.dataset.resources.extend(resources)
@@ -332,7 +332,7 @@ class MeAPITest(APITestCase):
332
332
 
333
333
  # The discussions are kept but the messages are anonymized
334
334
  self.assertEqual(len(discussion.discussion), 2)
335
- self.assertEqual(discussion.discussion[0].content, "DELETED")
335
+ self.assertEqual(discussion.discussion[0].posted_by.fullname, "DELETED DELETED")
336
336
  self.assertEqual(discussion.discussion[1].content, other_disc_msg_content)
337
337
 
338
338
  # The datasets are unchanged
@@ -2,8 +2,10 @@ from bson import ObjectId
2
2
  from flask import url_for
3
3
 
4
4
  from udata.core.dataset.factories import DatasetFactory
5
+ from udata.core.dataset.models import Dataset
5
6
  from udata.core.organization.factories import OrganizationFactory
6
7
  from udata.core.user.factories import UserFactory
8
+ from udata.core.user.models import User
7
9
  from udata.utils import faker
8
10
 
9
11
  from . import APITestCase
@@ -214,3 +216,39 @@ class TransferAPITest(APITestCase):
214
216
  data = response.json
215
217
 
216
218
  self.assertIn("recipient", data["errors"])
219
+
220
+ def test_cannot_accept_or_refuse_transfer_after_accepting_or_refusing(self):
221
+ user = self.login()
222
+ new_user = UserFactory()
223
+ dataset = DatasetFactory(owner=user)
224
+
225
+ response = self._create_transfer(dataset, new_user)
226
+ self.assert201(response)
227
+
228
+ transfer = response.json
229
+
230
+ self.login(new_user)
231
+ response = self.post(url_for("api.transfer", id=transfer["id"]), {"response": "accept"})
232
+ self.assert200(response)
233
+
234
+ response = self.post(url_for("api.transfer", id=transfer["id"]), {"response": "accept"})
235
+ self.assert400(response)
236
+
237
+ response = self.post(url_for("api.transfer", id=transfer["id"]), {"response": "refuse"})
238
+ self.assert400(response)
239
+
240
+ def _create_transfer(self, source: Dataset, destination: User):
241
+ return self.post(
242
+ url_for("api.transfers"),
243
+ {
244
+ "subject": {
245
+ "class": "Dataset",
246
+ "id": str(source.id),
247
+ },
248
+ "recipient": {
249
+ "class": "User",
250
+ "id": str(destination.id),
251
+ },
252
+ "comment": "Some comment",
253
+ },
254
+ )
@@ -1,8 +1,9 @@
1
1
  from flask import url_for
2
2
 
3
3
  from udata.core import storages
4
+ from udata.core.discussions.factories import DiscussionFactory, MessageDiscussionFactory
4
5
  from udata.core.user.factories import AdminFactory, UserFactory
5
- from udata.models import Follow
6
+ from udata.models import Discussion, Follow
6
7
  from udata.tests.helpers import capture_mails, create_test_image
7
8
  from udata.utils import faker
8
9
 
@@ -352,36 +353,74 @@ class UserAPITest(APITestCase):
352
353
  def test_delete_user(self):
353
354
  user = AdminFactory()
354
355
  self.login(user)
355
- other_user = UserFactory()
356
+ user_to_delete = UserFactory()
356
357
  file = create_test_image()
358
+ discussion = DiscussionFactory(
359
+ user=user_to_delete,
360
+ discussion=[
361
+ MessageDiscussionFactory(posted_by=user_to_delete),
362
+ MessageDiscussionFactory(posted_by=user_to_delete),
363
+ ],
364
+ )
357
365
 
358
366
  response = self.post(
359
- url_for("api.user_avatar", user=other_user),
367
+ url_for("api.user_avatar", user=user_to_delete),
360
368
  {"file": (file, "test.png")},
361
369
  json=False,
362
370
  )
363
371
  with capture_mails() as mails:
364
- response = self.delete(url_for("api.user", user=other_user))
372
+ response = self.delete(url_for("api.user", user=user_to_delete))
365
373
  self.assertEqual(list(storages.avatars.list_files()), [])
366
374
  self.assert204(response)
367
375
  self.assertEquals(len(mails), 1)
368
376
 
369
- other_user.reload()
370
- response = self.delete(url_for("api.user", user=other_user))
377
+ user_to_delete.reload()
378
+ response = self.delete(url_for("api.user", user=user_to_delete))
371
379
  self.assert410(response)
372
380
  response = self.delete(url_for("api.user", user=user))
373
381
  self.assert403(response)
374
382
 
383
+ # discussions are kept by default
384
+ discussion.reload()
385
+ assert len(discussion.discussion) == 2
386
+ assert discussion.discussion[1].content != "DELETED"
387
+
375
388
  def test_delete_user_without_notify(self):
376
389
  user = AdminFactory()
377
390
  self.login(user)
378
- other_user = UserFactory()
391
+ user_to_delete = UserFactory()
379
392
 
380
393
  with capture_mails() as mails:
381
- response = self.delete(url_for("api.user", user=other_user, no_mail=True))
394
+ response = self.delete(url_for("api.user", user=user_to_delete, no_mail=True))
382
395
  self.assert204(response)
383
396
  self.assertEqual(len(mails), 0)
384
397
 
398
+ def test_delete_user_with_comments_deletion(self):
399
+ user = AdminFactory()
400
+ self.login(user)
401
+ user_to_delete = UserFactory()
402
+ discussion_only_user = DiscussionFactory(
403
+ user=user_to_delete,
404
+ discussion=[
405
+ MessageDiscussionFactory(posted_by=user_to_delete),
406
+ MessageDiscussionFactory(posted_by=user_to_delete),
407
+ ],
408
+ )
409
+ discussion_with_other = DiscussionFactory(
410
+ user=user,
411
+ discussion=[
412
+ MessageDiscussionFactory(posted_by=user),
413
+ MessageDiscussionFactory(posted_by=user_to_delete),
414
+ ],
415
+ )
416
+
417
+ response = self.delete(url_for("api.user", user=user_to_delete, delete_comments=True))
418
+ self.assert204(response)
419
+
420
+ assert Discussion.objects(id=discussion_only_user.id).first() is None
421
+ discussion_with_other.reload()
422
+ assert discussion_with_other.discussion[1].content == "DELETED"
423
+
385
424
  def test_contact_points(self):
386
425
  user = AdminFactory()
387
426
  self.login(user)
@@ -18,6 +18,7 @@ class DataserviceCSVAdapterTest:
18
18
  metadata_modified_at=datetime(2023, 1, 1),
19
19
  organization=OrganizationFactory(),
20
20
  datasets=[DatasetFactory(), DatasetFactory()],
21
+ metrics={"views": 42},
21
22
  )
22
23
  [DataserviceFactory() for _ in range(10)]
23
24
  adapter = DataserviceCsvAdapter(Dataservice.objects.all())
@@ -41,3 +42,4 @@ class DataserviceCSVAdapterTest:
41
42
  assert dataservice_values["datasets"] == ",".join(
42
43
  str(dataset.id) for dataset in dataservice.datasets
43
44
  )
45
+ assert dataservice_values["metric.views"] == dataservice.metrics["views"]
@@ -1,8 +1,10 @@
1
1
  from datetime import datetime, timedelta
2
+ from uuid import uuid4
2
3
 
3
4
  import pytest
4
5
  import requests
5
6
  from flask import current_app
7
+ from mongoengine import ValidationError as MongoEngineValidationError
6
8
  from mongoengine import post_save
7
9
 
8
10
  from udata.app import cache
@@ -59,6 +61,18 @@ class DatasetModelTest:
59
61
  assert len(dataset.resources) == 2
60
62
  assert dataset.resources[0].id == resource.id
61
63
 
64
+ def test_add_two_resources_with_same_id(self):
65
+ uuid = uuid4()
66
+ user = UserFactory()
67
+ dataset = DatasetFactory(owner=user)
68
+ resource_a = ResourceFactory(id=uuid)
69
+ resource_b = ResourceFactory(id=uuid)
70
+
71
+ dataset.add_resource(resource_a)
72
+ dataset.add_resource(ResourceFactory())
73
+ with pytest.raises(MongoEngineValidationError):
74
+ dataset.add_resource(resource_b)
75
+
62
76
  def test_add_resource_missing_checksum_type(self):
63
77
  user = UserFactory()
64
78
  dataset = DatasetFactory(owner=user)
@@ -0,0 +1,144 @@
1
+ from datetime import datetime, timedelta
2
+
3
+ import pytest
4
+ from flask import current_app
5
+
6
+ from udata.core.discussions.factories import DiscussionFactory
7
+ from udata.core.user import tasks
8
+ from udata.core.user.factories import UserFactory
9
+ from udata.core.user.models import User
10
+ from udata.i18n import gettext as _
11
+ from udata.tests.api import APITestCase
12
+ from udata.tests.helpers import capture_mails
13
+
14
+
15
+ class UserTasksTest(APITestCase):
16
+ @pytest.mark.options(YEARS_OF_INACTIVITY_BEFORE_DEACTIVATION=3)
17
+ def test_notify_inactive_users(self):
18
+ notification_comparison_date = (
19
+ datetime.utcnow()
20
+ - timedelta(days=current_app.config["YEARS_OF_INACTIVITY_BEFORE_DEACTIVATION"] * 365)
21
+ + timedelta(days=current_app.config["DAYS_BEFORE_ACCOUNT_INACTIVITY_NOTIFY_DELAY"])
22
+ - timedelta(days=1) # add margin
23
+ )
24
+
25
+ inactive_user = UserFactory(current_login_at=notification_comparison_date)
26
+ UserFactory(current_login_at=datetime.utcnow()) # Active user
27
+
28
+ with capture_mails() as mails:
29
+ tasks.notify_inactive_users()
30
+
31
+ # Assert (only one) mail has been sent
32
+ self.assertEqual(len(mails), 1)
33
+ self.assertEqual(mails[0].send_to, set([inactive_user.email]))
34
+ self.assertEqual(
35
+ mails[0].subject,
36
+ _("Inactivity of your {site} account").format(site=current_app.config["SITE_TITLE"]),
37
+ )
38
+
39
+ # We shouldn't notify users twice
40
+ with capture_mails() as mails:
41
+ tasks.notify_inactive_users()
42
+
43
+ self.assertEqual(len(mails), 0)
44
+
45
+ @pytest.mark.options(YEARS_OF_INACTIVITY_BEFORE_DEACTIVATION=3)
46
+ @pytest.mark.options(MAX_NUMBER_OF_USER_INACTIVITY_NOTIFICATIONS=10)
47
+ def test_notify_inactive_users_max_notifications(self):
48
+ notification_comparison_date = (
49
+ datetime.utcnow()
50
+ - timedelta(days=current_app.config["YEARS_OF_INACTIVITY_BEFORE_DEACTIVATION"] * 365)
51
+ + timedelta(days=current_app.config["DAYS_BEFORE_ACCOUNT_INACTIVITY_NOTIFY_DELAY"])
52
+ - timedelta(days=1) # add margin
53
+ )
54
+
55
+ NB_USERS_TO_NOTIFY = 15
56
+
57
+ [UserFactory(current_login_at=notification_comparison_date) for _ in range(15)]
58
+ UserFactory(current_login_at=datetime.utcnow()) # Active user
59
+
60
+ with capture_mails() as mails:
61
+ tasks.notify_inactive_users()
62
+
63
+ # Assert MAX_NUMBER_OF_USER_INACTIVITY_NOTIFICATIONS mails have been sent
64
+ self.assertEqual(
65
+ len(mails), current_app.config["MAX_NUMBER_OF_USER_INACTIVITY_NOTIFICATIONS"]
66
+ )
67
+
68
+ # Second batch
69
+ with capture_mails() as mails:
70
+ tasks.notify_inactive_users()
71
+
72
+ # Assert what's left have been sent
73
+ self.assertEqual(
74
+ len(mails),
75
+ NB_USERS_TO_NOTIFY - current_app.config["MAX_NUMBER_OF_USER_INACTIVITY_NOTIFICATIONS"],
76
+ )
77
+
78
+ @pytest.mark.options(YEARS_OF_INACTIVITY_BEFORE_DEACTIVATION=3)
79
+ def test_delete_inactive_users(self):
80
+ deletion_comparison_date = (
81
+ datetime.utcnow()
82
+ - timedelta(days=current_app.config["YEARS_OF_INACTIVITY_BEFORE_DEACTIVATION"] * 365)
83
+ - timedelta(days=1) # add margin
84
+ )
85
+
86
+ notification_comparison_date = (
87
+ datetime.utcnow()
88
+ - timedelta(days=current_app.config["DAYS_BEFORE_ACCOUNT_INACTIVITY_NOTIFY_DELAY"])
89
+ - timedelta(days=1) # add margin
90
+ )
91
+
92
+ inactive_user_to_delete = UserFactory(
93
+ current_login_at=deletion_comparison_date,
94
+ inactive_deletion_notified_at=notification_comparison_date,
95
+ )
96
+ UserFactory(current_login_at=datetime.utcnow()) # Active user
97
+ discussion = DiscussionFactory(user=inactive_user_to_delete)
98
+ discussion_title = discussion.title
99
+
100
+ with capture_mails() as mails:
101
+ tasks.delete_inactive_users()
102
+
103
+ # Assert (only one) mail has been sent
104
+ self.assertEqual(len(mails), 1)
105
+ self.assertEqual(mails[0].send_to, set([inactive_user_to_delete.email]))
106
+ self.assertEqual(
107
+ mails[0].subject,
108
+ _(
109
+ _("Deletion of your inactive {site} account").format(
110
+ site=current_app.config["SITE_TITLE"]
111
+ )
112
+ ),
113
+ )
114
+
115
+ # Assert user has been deleted but not its discussion
116
+ inactive_user_to_delete.reload()
117
+ discussion.reload()
118
+ self.assertEqual(inactive_user_to_delete.fullname, "DELETED DELETED")
119
+ self.assertEqual(discussion.title, discussion_title)
120
+
121
+ @pytest.mark.options(YEARS_OF_INACTIVITY_BEFORE_DEACTIVATION=3)
122
+ def test_keep_inactive_users_that_logged_in(self):
123
+ notification_comparison_date = (
124
+ datetime.utcnow()
125
+ - timedelta(days=current_app.config["DAYS_BEFORE_ACCOUNT_INACTIVITY_NOTIFY_DELAY"])
126
+ - timedelta(days=1) # add margin
127
+ )
128
+
129
+ inactive_user_that_logged_in_since_notification = UserFactory(
130
+ current_login_at=datetime.utcnow(),
131
+ inactive_deletion_notified_at=notification_comparison_date,
132
+ )
133
+
134
+ with capture_mails() as mails:
135
+ tasks.delete_inactive_users()
136
+
137
+ # Assert no mail has been sent
138
+ self.assertEqual(len(mails), 0)
139
+
140
+ # Assert user hasn't been deleted and won't be deleted
141
+ self.assertEqual(User.objects().count(), 1)
142
+ user = User.objects().first()
143
+ self.assertEqual(user, inactive_user_that_logged_in_since_notification)
144
+ self.assertIsNone(user.inactive_deletion_notified_at)
Binary file