udata 14.0.3.dev1__py3-none-any.whl → 14.7.3.dev4__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 (151) hide show
  1. udata/api/__init__.py +2 -0
  2. udata/api_fields.py +120 -19
  3. udata/app.py +18 -20
  4. udata/auth/__init__.py +4 -7
  5. udata/auth/forms.py +3 -3
  6. udata/auth/views.py +13 -6
  7. udata/commands/dcat.py +1 -1
  8. udata/commands/serve.py +3 -11
  9. udata/core/activity/api.py +5 -6
  10. udata/core/badges/tests/test_tasks.py +0 -2
  11. udata/core/csv.py +5 -0
  12. udata/core/dataservices/api.py +8 -1
  13. udata/core/dataservices/apiv2.py +3 -6
  14. udata/core/dataservices/models.py +5 -2
  15. udata/core/dataservices/rdf.py +2 -1
  16. udata/core/dataservices/tasks.py +6 -2
  17. udata/core/dataset/api.py +30 -4
  18. udata/core/dataset/api_fields.py +1 -1
  19. udata/core/dataset/apiv2.py +1 -1
  20. udata/core/dataset/constants.py +2 -9
  21. udata/core/dataset/models.py +21 -9
  22. udata/core/dataset/permissions.py +31 -0
  23. udata/core/dataset/rdf.py +18 -16
  24. udata/core/dataset/tasks.py +16 -7
  25. udata/core/discussions/api.py +15 -1
  26. udata/core/discussions/models.py +6 -0
  27. udata/core/legal/__init__.py +0 -0
  28. udata/core/legal/mails.py +128 -0
  29. udata/core/organization/api.py +16 -5
  30. udata/core/organization/api_fields.py +3 -3
  31. udata/core/organization/apiv2.py +3 -4
  32. udata/core/organization/mails.py +1 -1
  33. udata/core/organization/models.py +40 -7
  34. udata/core/organization/notifications.py +84 -0
  35. udata/core/organization/permissions.py +1 -1
  36. udata/core/organization/tasks.py +3 -0
  37. udata/core/pages/models.py +49 -0
  38. udata/core/pages/tests/test_api.py +165 -1
  39. udata/core/post/api.py +25 -70
  40. udata/core/post/constants.py +8 -0
  41. udata/core/post/models.py +109 -17
  42. udata/core/post/tests/test_api.py +140 -3
  43. udata/core/post/tests/test_models.py +24 -0
  44. udata/core/reports/api.py +18 -0
  45. udata/core/reports/models.py +42 -2
  46. udata/core/reuse/api.py +8 -0
  47. udata/core/reuse/apiv2.py +3 -6
  48. udata/core/reuse/models.py +1 -1
  49. udata/core/spatial/forms.py +2 -2
  50. udata/core/topic/models.py +8 -2
  51. udata/core/user/api.py +10 -3
  52. udata/core/user/api_fields.py +3 -3
  53. udata/core/user/models.py +33 -8
  54. udata/features/notifications/api.py +7 -18
  55. udata/features/notifications/models.py +59 -0
  56. udata/features/notifications/tasks.py +25 -0
  57. udata/features/transfer/actions.py +2 -0
  58. udata/features/transfer/models.py +17 -0
  59. udata/features/transfer/notifications.py +96 -0
  60. udata/flask_mongoengine/engine.py +0 -4
  61. udata/flask_mongoengine/pagination.py +1 -1
  62. udata/frontend/markdown.py +2 -1
  63. udata/harvest/actions.py +20 -0
  64. udata/harvest/api.py +24 -7
  65. udata/harvest/backends/base.py +27 -1
  66. udata/harvest/backends/ckan/harvesters.py +21 -4
  67. udata/harvest/backends/dcat.py +4 -1
  68. udata/harvest/commands.py +33 -0
  69. udata/harvest/filters.py +17 -6
  70. udata/harvest/models.py +16 -0
  71. udata/harvest/permissions.py +27 -0
  72. udata/harvest/tests/ckan/test_ckan_backend.py +33 -0
  73. udata/harvest/tests/test_actions.py +46 -2
  74. udata/harvest/tests/test_api.py +161 -6
  75. udata/harvest/tests/test_base_backend.py +86 -1
  76. udata/harvest/tests/test_dcat_backend.py +68 -3
  77. udata/harvest/tests/test_filters.py +6 -0
  78. udata/i18n.py +1 -4
  79. udata/mail.py +14 -0
  80. udata/migrations/2021-08-17-harvest-integrity.py +23 -16
  81. udata/migrations/2025-10-31-create-membership-request-notifications.py +55 -0
  82. udata/migrations/2025-12-04-add-uuid-to-discussion-messages.py +28 -0
  83. udata/migrations/2025-12-16-create-transfer-request-notifications.py +69 -0
  84. udata/migrations/2026-01-14-add-default-kind-to-posts.py +17 -0
  85. udata/mongo/slug_fields.py +1 -1
  86. udata/rdf.py +65 -11
  87. udata/routing.py +2 -2
  88. udata/settings.py +11 -0
  89. udata/tasks.py +2 -0
  90. udata/templates/mail/message.html +3 -1
  91. udata/tests/api/__init__.py +7 -17
  92. udata/tests/api/test_activities_api.py +36 -0
  93. udata/tests/api/test_datasets_api.py +69 -0
  94. udata/tests/api/test_organizations_api.py +0 -3
  95. udata/tests/api/test_reports_api.py +157 -0
  96. udata/tests/api/test_user_api.py +1 -1
  97. udata/tests/apiv2/test_dataservices.py +14 -0
  98. udata/tests/apiv2/test_organizations.py +9 -0
  99. udata/tests/apiv2/test_reuses.py +11 -0
  100. udata/tests/cli/test_cli_base.py +0 -1
  101. udata/tests/dataservice/test_dataservice_tasks.py +29 -0
  102. udata/tests/dataset/test_dataset_model.py +13 -1
  103. udata/tests/dataset/test_dataset_rdf.py +164 -5
  104. udata/tests/dataset/test_dataset_tasks.py +25 -0
  105. udata/tests/frontend/test_auth.py +58 -1
  106. udata/tests/frontend/test_csv.py +0 -3
  107. udata/tests/helpers.py +31 -27
  108. udata/tests/organization/test_notifications.py +67 -2
  109. udata/tests/search/test_search_integration.py +70 -0
  110. udata/tests/site/test_site_csv_exports.py +22 -10
  111. udata/tests/test_activity.py +9 -9
  112. udata/tests/test_api_fields.py +10 -0
  113. udata/tests/test_discussions.py +5 -5
  114. udata/tests/test_legal_mails.py +359 -0
  115. udata/tests/test_notifications.py +15 -57
  116. udata/tests/test_notifications_task.py +43 -0
  117. udata/tests/test_owned.py +81 -1
  118. udata/tests/test_transfer.py +181 -2
  119. udata/tests/test_uris.py +33 -0
  120. udata/translations/ar/LC_MESSAGES/udata.mo +0 -0
  121. udata/translations/ar/LC_MESSAGES/udata.po +309 -158
  122. udata/translations/de/LC_MESSAGES/udata.mo +0 -0
  123. udata/translations/de/LC_MESSAGES/udata.po +313 -160
  124. udata/translations/es/LC_MESSAGES/udata.mo +0 -0
  125. udata/translations/es/LC_MESSAGES/udata.po +312 -160
  126. udata/translations/fr/LC_MESSAGES/udata.mo +0 -0
  127. udata/translations/fr/LC_MESSAGES/udata.po +475 -202
  128. udata/translations/it/LC_MESSAGES/udata.mo +0 -0
  129. udata/translations/it/LC_MESSAGES/udata.po +317 -162
  130. udata/translations/pt/LC_MESSAGES/udata.mo +0 -0
  131. udata/translations/pt/LC_MESSAGES/udata.po +315 -161
  132. udata/translations/sr/LC_MESSAGES/udata.mo +0 -0
  133. udata/translations/sr/LC_MESSAGES/udata.po +323 -164
  134. udata/translations/udata.pot +169 -124
  135. udata/uris.py +0 -2
  136. udata/utils.py +23 -0
  137. udata-14.7.3.dev4.dist-info/METADATA +109 -0
  138. {udata-14.0.3.dev1.dist-info → udata-14.7.3.dev4.dist-info}/RECORD +142 -135
  139. udata/core/post/forms.py +0 -30
  140. udata/flask_mongoengine/json.py +0 -38
  141. udata/templates/mail/base.html +0 -105
  142. udata/templates/mail/base.txt +0 -6
  143. udata/templates/mail/button.html +0 -3
  144. udata/templates/mail/layouts/1-column.html +0 -19
  145. udata/templates/mail/layouts/2-columns.html +0 -20
  146. udata/templates/mail/layouts/center-panel.html +0 -16
  147. udata-14.0.3.dev1.dist-info/METADATA +0 -132
  148. {udata-14.0.3.dev1.dist-info → udata-14.7.3.dev4.dist-info}/WHEEL +0 -0
  149. {udata-14.0.3.dev1.dist-info → udata-14.7.3.dev4.dist-info}/entry_points.txt +0 -0
  150. {udata-14.0.3.dev1.dist-info → udata-14.7.3.dev4.dist-info}/licenses/LICENSE +0 -0
  151. {udata-14.0.3.dev1.dist-info → udata-14.7.3.dev4.dist-info}/top_level.txt +0 -0
udata/tests/helpers.py CHANGED
@@ -4,7 +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 mock
7
+ import pytest
8
8
  from flask import current_app, json
9
9
  from flask_security.babel import FsDomain
10
10
  from PIL import Image
@@ -12,6 +12,11 @@ from PIL import Image
12
12
  from udata.core.spatial.factories import GeoZoneFactory
13
13
  from udata.mail import mail_sent
14
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
+
15
20
 
16
21
  def assert_equal_dates(datetime1, datetime2, limit=1): # Seconds.
17
22
  """Lax date comparison, avoid comparing milliseconds and seconds."""
@@ -35,51 +40,50 @@ def assert_json_equal(first, second):
35
40
 
36
41
 
37
42
  @contextmanager
38
- def mock_signals(callback, *signals):
43
+ def mock_signals(*signals):
39
44
  __tracebackhide__ = True
40
- specs = []
41
45
 
42
- def handler(sender, **kwargs):
43
- pass
46
+ callbacks_by_signal = {}
47
+ calls_kwargs_by_signal = {}
44
48
 
45
- for signal in signals:
46
- m = mock.Mock(spec=handler)
47
- signal.connect(m, weak=False)
48
- specs.append((signal, m))
49
+ for requestSignal in signals:
50
+ # We capture requestSignal with a default argument
51
+ def callback(*args, requestSignal=requestSignal, **kwargs):
52
+ calls_kwargs_by_signal.setdefault(requestSignal, [])
53
+ calls_kwargs_by_signal[requestSignal].append(kwargs)
54
+
55
+ callbacks_by_signal[requestSignal] = callback
56
+ requestSignal.connect(callback, weak=False)
49
57
 
50
- yield
58
+ yield calls_kwargs_by_signal
51
59
 
52
- for signal, mock_handler in specs:
53
- signal.disconnect(mock_handler)
54
- signal_name = getattr(signal, "name", str(signal))
55
- callback(signal_name, mock_handler)
60
+ for sig in signals:
61
+ sig.disconnect(callbacks_by_signal[sig])
56
62
 
57
63
 
58
64
  @contextmanager
59
65
  def assert_emit(*signals, assertions_callback=None):
60
66
  __tracebackhide__ = True
61
- msg = 'Signal "{0}" should have been emitted'
62
67
 
63
- def callback(name, handler):
64
- assert handler.called, msg.format(name)
65
- if assertions_callback is not None:
66
- assertions_callback(handler.call_args)
67
-
68
- with mock_signals(callback, *signals):
68
+ with mock_signals(*signals) as calls_kwargs_by_signal:
69
69
  yield
70
70
 
71
+ for signal in signals:
72
+ assert signal in calls_kwargs_by_signal, f'Signal "{signal}" should have been emitted'
73
+ if assertions_callback is not None:
74
+ for kwargs in calls_kwargs_by_signal[signal]:
75
+ assertions_callback(kwargs)
76
+
71
77
 
72
78
  @contextmanager
73
79
  def assert_not_emit(*signals):
74
80
  __tracebackhide__ = True
75
- msg = 'Signal "{0}" should NOT have been emitted'
76
-
77
- def callback(name, handler):
78
- assert not handler.called, msg.format(name)
79
-
80
- with mock_signals(callback, *signals):
81
+ with mock_signals(*signals) as calls_args_by_signal:
81
82
  yield
82
83
 
84
+ for signal in signals:
85
+ assert signal not in calls_args_by_signal, f'Signal "{signal}" should not have been emitted'
86
+
83
87
 
84
88
  @contextmanager
85
89
  def capture_mails():
@@ -1,8 +1,11 @@
1
1
  from udata.core.organization.factories import OrganizationFactory
2
- from udata.core.organization.notifications import membership_request_notifications
2
+ from udata.core.organization.notifications import (
3
+ membership_request_notifications,
4
+ )
3
5
  from udata.core.user.factories import UserFactory
6
+ from udata.features.notifications.models import Notification
4
7
  from udata.models import Member, MembershipRequest
5
- from udata.tests.api import PytestOnlyDBTestCase
8
+ from udata.tests.api import DBTestCase, PytestOnlyDBTestCase
6
9
  from udata.tests.helpers import assert_equal_dates
7
10
 
8
11
 
@@ -27,3 +30,65 @@ class OrganizationNotificationsTest(PytestOnlyDBTestCase):
27
30
  assert details["user"]["id"] == applicant.id
28
31
  assert details["user"]["fullname"] == applicant.fullname
29
32
  assert details["user"]["avatar"] == str(applicant.avatar)
33
+
34
+
35
+ class MembershipRequestNotificationTest(DBTestCase):
36
+ def test_notification_created_for_admins_only(self):
37
+ """Notifications are created for all admin users, not editors"""
38
+ admin1 = UserFactory()
39
+ admin2 = UserFactory()
40
+ editor = UserFactory()
41
+ applicant = UserFactory()
42
+ members = [
43
+ Member(user=editor, role="editor"),
44
+ Member(user=admin1, role="admin"),
45
+ Member(user=admin2, role="admin"),
46
+ ]
47
+ org = OrganizationFactory(members=members)
48
+
49
+ request = MembershipRequest(user=applicant, comment="test")
50
+ org.add_membership_request(request)
51
+
52
+ notifications = Notification.objects.all()
53
+ assert len(notifications) == 2
54
+
55
+ admin_users = [notif.user for notif in notifications]
56
+ self.assertIn(admin1, admin_users)
57
+ self.assertIn(admin2, admin_users)
58
+
59
+ for notification in notifications:
60
+ assert notification.details.request_organization == org
61
+ assert notification.details.request_user == applicant
62
+ assert_equal_dates(notification.created_at, request.created)
63
+
64
+ def test_no_duplicate_notifications(self):
65
+ """Duplicate notifications are not created on subsequent saves"""
66
+ admin = UserFactory()
67
+ applicant = UserFactory()
68
+ org = OrganizationFactory(members=[Member(user=admin, role="admin")])
69
+
70
+ request = MembershipRequest(user=applicant, comment="test")
71
+ org.add_membership_request(request)
72
+ org.add_membership_request(request)
73
+
74
+ assert Notification.objects.count() == 1
75
+
76
+ def test_multiple_requests_create_separate_notifications(self):
77
+ """Multiple requests from different users create separate notifications"""
78
+ admin = UserFactory()
79
+ applicant1 = UserFactory()
80
+ applicant2 = UserFactory()
81
+ org = OrganizationFactory(members=[Member(user=admin, role="admin")])
82
+
83
+ request1 = MembershipRequest(user=applicant1, comment="test 1")
84
+ org.add_membership_request(request1)
85
+
86
+ request2 = MembershipRequest(user=applicant2, comment="test 2")
87
+ org.add_membership_request(request2)
88
+
89
+ notifications = Notification.objects.all()
90
+ assert len(notifications) == 2
91
+
92
+ request_users = [notif.details.request_user for notif in notifications]
93
+ self.assertIn(applicant1, request_users)
94
+ self.assertIn(applicant2, request_users)
@@ -0,0 +1,70 @@
1
+ import time
2
+
3
+ import pytest
4
+
5
+ from udata.core.dataset.factories import DatasetFactory
6
+ from udata.core.organization.factories import OrganizationFactory
7
+ from udata.core.reuse.factories import VisibleReuseFactory
8
+ from udata.tests.api import APITestCase
9
+ from udata.tests.helpers import requires_search_service
10
+
11
+
12
+ @requires_search_service
13
+ @pytest.mark.options(SEARCH_SERVICE_API_URL="http://localhost:5000/api/1/", AUTO_INDEX=True)
14
+ class SearchIntegrationTest(APITestCase):
15
+ """Integration tests that require a running search-service and Elasticsearch."""
16
+
17
+ def test_dataset_fuzzy_search(self):
18
+ """
19
+ Test that Elasticsearch fuzzy search works.
20
+
21
+ A typo in the search query ("spectakulaire" instead of "spectaculaire")
22
+ should still find the dataset thanks to ES fuzzy matching.
23
+ """
24
+ DatasetFactory(title="Données spectaculaires sur les transports")
25
+
26
+ # Small delay to let ES index the document
27
+ time.sleep(1)
28
+
29
+ # Search with a typo - only ES fuzzy search can handle this
30
+ response = self.get("/api/2/datasets/search/?q=spectakulaire")
31
+ self.assert200(response)
32
+ assert response.json["total"] >= 1
33
+
34
+ titles = [d["title"] for d in response.json["data"]]
35
+ assert "Données spectaculaires sur les transports" in titles
36
+
37
+ def test_reuse_search_with_organization_filter(self):
38
+ """
39
+ Regression test for: 500 Server Error when None values are passed to search service.
40
+
41
+ When searching reuses with only an organization filter, other params should not be
42
+ sent as literal 'None' strings (e.g. ?q=None&tag=None).
43
+ """
44
+ org = OrganizationFactory()
45
+ reuse = VisibleReuseFactory(organization=org)
46
+
47
+ time.sleep(1)
48
+
49
+ response = self.get(f"/api/2/reuses/search/?organization={org.id}")
50
+ self.assert200(response)
51
+ assert response.json["total"] >= 1
52
+ ids = [r["id"] for r in response.json["data"]]
53
+ assert str(reuse.id) in ids
54
+
55
+ def test_organization_search_with_query(self):
56
+ """
57
+ Regression test for: 500 Server Error when None values are passed to search service.
58
+
59
+ When searching organizations, other params should not be sent as literal
60
+ 'None' strings (e.g. ?badge=None).
61
+ """
62
+ org = OrganizationFactory(name="Organisation Unique Test")
63
+
64
+ time.sleep(1)
65
+
66
+ response = self.get("/api/2/organizations/search/?q=unique")
67
+ self.assert200(response)
68
+ assert response.json["total"] >= 1
69
+ ids = [o["id"] for o in response.json["data"]]
70
+ assert str(org.id) in ids
@@ -7,6 +7,7 @@ from flask import url_for
7
7
  from udata.core import csv
8
8
  from udata.core.dataservices.factories import DataserviceFactory
9
9
  from udata.core.dataset import tasks as dataset_tasks
10
+ from udata.core.dataset.constants import SPD
10
11
  from udata.core.dataset.factories import DatasetFactory, ResourceFactory
11
12
  from udata.core.organization.factories import OrganizationFactory
12
13
  from udata.core.reuse.factories import ReuseFactory
@@ -25,7 +26,6 @@ class SiteCsvExportsTest(APITestCase):
25
26
 
26
27
  self.assert200(response)
27
28
  self.assertEqual(response.mimetype, "text/csv")
28
- self.assertEqual(response.charset, "utf-8")
29
29
 
30
30
  csvfile = StringIO(response.data.decode("utf8"))
31
31
  reader = csv.get_reader(csvfile)
@@ -73,7 +73,6 @@ class SiteCsvExportsTest(APITestCase):
73
73
 
74
74
  self.assert200(response)
75
75
  self.assertEqual(response.mimetype, "text/csv")
76
- self.assertEqual(response.charset, "utf-8")
77
76
 
78
77
  csvfile = StringIO(response.data.decode("utf8"))
79
78
  reader = csv.get_reader(csvfile)
@@ -99,6 +98,27 @@ class SiteCsvExportsTest(APITestCase):
99
98
  self.assertNotIn(str(dataset.id), ids)
100
99
  self.assertNotIn(str(hidden_dataset.id), ids)
101
100
 
101
+ def test_datasets_csv_with_badge_filter(self):
102
+ self.app.config["EXPORT_CSV_MODELS"] = []
103
+ dataset_with_badge = DatasetFactory(resources=[ResourceFactory()])
104
+ dataset_with_badge.add_badge(SPD)
105
+ dataset_without_badge = DatasetFactory(resources=[ResourceFactory()])
106
+
107
+ response = self.get(url_for("api.site_datasets_csv", badge=SPD))
108
+
109
+ self.assert200(response)
110
+
111
+ csvfile = StringIO(response.data.decode("utf8"))
112
+ reader = csv.get_reader(csvfile)
113
+ next(reader) # skip header
114
+
115
+ rows = list(reader)
116
+ ids = [row[0] for row in rows]
117
+
118
+ self.assertEqual(len(rows), 1)
119
+ self.assertIn(str(dataset_with_badge.id), ids)
120
+ self.assertNotIn(str(dataset_without_badge.id), ids)
121
+
102
122
  def test_resources_csv(self):
103
123
  self.app.config["EXPORT_CSV_MODELS"] = []
104
124
  datasets = [
@@ -110,7 +130,6 @@ class SiteCsvExportsTest(APITestCase):
110
130
 
111
131
  self.assert200(response)
112
132
  self.assertEqual(response.mimetype, "text/csv")
113
- self.assertEqual(response.charset, "utf-8")
114
133
 
115
134
  csvfile = StringIO(response.data.decode("utf8"))
116
135
  reader = csv.get_reader(csvfile)
@@ -164,7 +183,6 @@ class SiteCsvExportsTest(APITestCase):
164
183
 
165
184
  self.assert200(response)
166
185
  self.assertEqual(response.mimetype, "text/csv")
167
- self.assertEqual(response.charset, "utf-8")
168
186
 
169
187
  csvfile = StringIO(response.data.decode("utf8"))
170
188
  reader = csv.get_reader(csvfile)
@@ -200,7 +218,6 @@ class SiteCsvExportsTest(APITestCase):
200
218
 
201
219
  self.assert200(response)
202
220
  self.assertEqual(response.mimetype, "text/csv")
203
- self.assertEqual(response.charset, "utf-8")
204
221
 
205
222
  csvfile = StringIO(response.data.decode("utf8"))
206
223
  reader = csv.get_reader(csvfile)
@@ -245,7 +262,6 @@ class SiteCsvExportsTest(APITestCase):
245
262
 
246
263
  self.assert200(response)
247
264
  self.assertEqual(response.mimetype, "text/csv")
248
- self.assertEqual(response.charset, "utf-8")
249
265
 
250
266
  csvfile = StringIO(response.data.decode("utf8"))
251
267
  reader = csv.get_reader(csvfile)
@@ -293,7 +309,6 @@ class SiteCsvExportsTest(APITestCase):
293
309
 
294
310
  self.assert200(response)
295
311
  self.assertEqual(response.mimetype, "text/csv")
296
- self.assertEqual(response.charset, "utf-8")
297
312
 
298
313
  csvfile = StringIO(response.data.decode("utf8"))
299
314
  reader = csv.get_reader(csvfile)
@@ -332,7 +347,6 @@ class SiteCsvExportsTest(APITestCase):
332
347
 
333
348
  self.assert200(response)
334
349
  self.assertEqual(response.mimetype, "text/csv")
335
- self.assertEqual(response.charset, "utf-8")
336
350
 
337
351
  csvfile = StringIO(response.data.decode("utf8"))
338
352
  reader = csv.get_reader(csvfile)
@@ -379,7 +393,6 @@ class SiteCsvExportsTest(APITestCase):
379
393
 
380
394
  self.assert200(response)
381
395
  self.assertEqual(response.mimetype, "text/csv")
382
- self.assertEqual(response.charset, "utf-8")
383
396
 
384
397
  csvfile = StringIO(response.data.decode("utf8"))
385
398
  reader = csv.get_reader(csvfile)
@@ -424,7 +437,6 @@ class SiteCsvExportsTest(APITestCase):
424
437
 
425
438
  self.assert200(response)
426
439
  self.assertEqual(response.mimetype, "text/csv")
427
- self.assertEqual(response.charset, "utf-8")
428
440
 
429
441
  csvfile = StringIO(response.data.decode("utf8"))
430
442
  reader = csv.get_reader(csvfile)
@@ -211,9 +211,9 @@ class AuditableTest(APITestCase):
211
211
  not_auditable="original",
212
212
  )
213
213
 
214
- def check_signal_update(args):
214
+ def check_signal_update(kwargs):
215
215
  self.assertEqual(
216
- args[1]["changed_fields"],
216
+ kwargs["changed_fields"],
217
217
  [
218
218
  "name",
219
219
  "tags",
@@ -224,13 +224,13 @@ class AuditableTest(APITestCase):
224
224
  "embedded_list.1.name",
225
225
  ],
226
226
  )
227
- self.assertEqual(args[1]["previous"]["name"], "fake")
228
- self.assertEqual(args[1]["previous"]["tags"], ["some", "tags"])
229
- self.assertEqual(args[1]["previous"]["some_date"], date(2020, 1, 1))
230
- self.assertEqual(args[1]["previous"]["daterange_embedded.start"], date(2020, 1, 1))
231
- self.assertEqual(args[1]["previous"]["daterange_embedded.end"], date(2020, 12, 31))
232
- self.assertEqual(args[1]["previous"]["some_list"], ["some", "list"])
233
- self.assertEqual(args[1]["previous"]["embedded_list.1.name"], "fake_embedded_1")
227
+ self.assertEqual(kwargs["previous"]["name"], "fake")
228
+ self.assertEqual(kwargs["previous"]["tags"], ["some", "tags"])
229
+ self.assertEqual(kwargs["previous"]["some_date"], date(2020, 1, 1))
230
+ self.assertEqual(kwargs["previous"]["daterange_embedded.start"], date(2020, 1, 1))
231
+ self.assertEqual(kwargs["previous"]["daterange_embedded.end"], date(2020, 12, 31))
232
+ self.assertEqual(kwargs["previous"]["some_list"], ["some", "list"])
233
+ self.assertEqual(kwargs["previous"]["embedded_list.1.name"], "fake_embedded_1")
234
234
 
235
235
  with assert_emit(FakeAuditableSubject.on_update, assertions_callback=check_signal_update):
236
236
  fake.name = "different"
@@ -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))
@@ -142,11 +142,11 @@ class DiscussionsTest(APITestCase):
142
142
  with assert_not_emit(on_new_discussion):
143
143
  discussion_id = None
144
144
 
145
- def check_signal(args):
145
+ def check_signal(kwargs):
146
146
  self.assertIsNotNone(discussion_id)
147
147
  self.assertIn(
148
- f"https://data.gouv.fr/datasets/{dataset.slug}/discussions/?discussion_id={discussion_id}",
149
- args[1]["message"],
148
+ f"https://data.gouv.fr/datasets/{dataset.slug}/discussions?discussion_id={discussion_id}",
149
+ kwargs["message"],
150
150
  )
151
151
 
152
152
  with assert_emit(on_new_potential_spam, assertions_callback=check_signal):
@@ -620,8 +620,8 @@ class DiscussionsTest(APITestCase):
620
620
  self.login()
621
621
  with assert_not_emit(on_new_discussion_comment):
622
622
 
623
- def check_signal(args):
624
- self.assertIn(discussion.url_for(), args[1]["message"])
623
+ def check_signal(kwargs):
624
+ self.assertIn(discussion.url_for(), kwargs["message"])
625
625
 
626
626
  with assert_emit(on_new_potential_spam, assertions_callback=check_signal):
627
627
  response = self.post(