udata 13.0.1.dev12__py3-none-any.whl → 14.4.1.dev7__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 (177) hide show
  1. udata/api/__init__.py +2 -8
  2. udata/api_fields.py +35 -4
  3. udata/app.py +30 -50
  4. udata/auth/__init__.py +29 -6
  5. udata/auth/forms.py +8 -6
  6. udata/auth/views.py +6 -3
  7. udata/commands/__init__.py +2 -14
  8. udata/commands/db.py +13 -25
  9. udata/commands/info.py +0 -16
  10. udata/commands/serve.py +3 -11
  11. udata/commands/tests/test_fixtures.py +9 -9
  12. udata/core/access_type/api.py +1 -1
  13. udata/core/access_type/constants.py +12 -8
  14. udata/core/activity/api.py +5 -6
  15. udata/core/avatars/api.py +43 -0
  16. udata/core/avatars/test_avatar_api.py +30 -0
  17. udata/core/badges/tests/test_commands.py +6 -6
  18. udata/core/csv.py +5 -0
  19. udata/core/dataservices/models.py +15 -3
  20. udata/core/dataservices/tasks.py +7 -0
  21. udata/core/dataset/api.py +2 -0
  22. udata/core/dataset/models.py +2 -2
  23. udata/core/dataset/permissions.py +31 -0
  24. udata/core/dataset/tasks.py +50 -10
  25. udata/core/discussions/models.py +1 -0
  26. udata/core/metrics/__init__.py +0 -6
  27. udata/core/organization/api.py +8 -5
  28. udata/core/organization/mails.py +1 -1
  29. udata/core/organization/models.py +9 -1
  30. udata/core/organization/notifications.py +84 -0
  31. udata/core/organization/permissions.py +1 -1
  32. udata/core/organization/tasks.py +3 -0
  33. udata/core/pages/tests/test_api.py +32 -0
  34. udata/core/post/api.py +24 -69
  35. udata/core/post/models.py +84 -16
  36. udata/core/post/tests/test_api.py +24 -1
  37. udata/core/reports/api.py +18 -0
  38. udata/core/reports/models.py +42 -2
  39. udata/core/reuse/models.py +1 -1
  40. udata/core/reuse/tasks.py +7 -0
  41. udata/core/site/models.py +2 -6
  42. udata/core/spatial/commands.py +2 -4
  43. udata/core/spatial/forms.py +2 -2
  44. udata/core/spatial/models.py +0 -10
  45. udata/core/spatial/tests/test_api.py +1 -36
  46. udata/core/user/models.py +15 -2
  47. udata/cors.py +2 -5
  48. udata/db/migrations.py +279 -0
  49. udata/features/notifications/api.py +7 -18
  50. udata/features/notifications/models.py +56 -0
  51. udata/features/notifications/tasks.py +25 -0
  52. udata/flask_mongoengine/engine.py +0 -4
  53. udata/frontend/__init__.py +3 -122
  54. udata/frontend/markdown.py +2 -1
  55. udata/harvest/actions.py +24 -9
  56. udata/harvest/api.py +30 -22
  57. udata/harvest/backends/__init__.py +21 -9
  58. udata/harvest/backends/base.py +29 -3
  59. udata/harvest/backends/ckan/harvesters.py +13 -2
  60. udata/harvest/backends/dcat.py +3 -0
  61. udata/harvest/backends/maaf.py +1 -0
  62. udata/harvest/commands.py +39 -4
  63. udata/harvest/filters.py +17 -6
  64. udata/harvest/forms.py +9 -6
  65. udata/harvest/models.py +16 -0
  66. udata/harvest/permissions.py +27 -0
  67. udata/harvest/tasks.py +3 -5
  68. udata/harvest/tests/ckan/test_ckan_backend.py +35 -2
  69. udata/harvest/tests/ckan/test_ckan_backend_errors.py +1 -1
  70. udata/harvest/tests/ckan/test_ckan_backend_filters.py +1 -1
  71. udata/harvest/tests/ckan/test_dkan_backend.py +1 -1
  72. udata/harvest/tests/dcat/udata.xml +6 -6
  73. udata/harvest/tests/factories.py +1 -1
  74. udata/harvest/tests/test_actions.py +63 -8
  75. udata/harvest/tests/test_api.py +278 -123
  76. udata/harvest/tests/test_base_backend.py +88 -1
  77. udata/harvest/tests/test_dcat_backend.py +60 -13
  78. udata/harvest/tests/test_filters.py +6 -0
  79. udata/i18n.py +11 -273
  80. udata/mail.py +5 -1
  81. udata/migrations/2025-10-31-create-membership-request-notifications.py +55 -0
  82. udata/migrations/2025-11-13-delete-user-email-index.py +25 -0
  83. udata/migrations/2025-12-04-add-uuid-to-discussion-messages.py +28 -0
  84. udata/models/__init__.py +0 -8
  85. udata/mongo/slug_fields.py +1 -1
  86. udata/rdf.py +45 -6
  87. udata/routing.py +2 -10
  88. udata/sentry.py +4 -10
  89. udata/settings.py +23 -17
  90. udata/tasks.py +4 -3
  91. udata/templates/mail/message.html +5 -31
  92. udata/tests/__init__.py +28 -12
  93. udata/tests/api/__init__.py +108 -21
  94. udata/tests/api/test_activities_api.py +36 -0
  95. udata/tests/api/test_auth_api.py +121 -95
  96. udata/tests/api/test_base_api.py +7 -4
  97. udata/tests/api/test_dataservices_api.py +29 -1
  98. udata/tests/api/test_datasets_api.py +45 -21
  99. udata/tests/api/test_organizations_api.py +192 -197
  100. udata/tests/api/test_reports_api.py +157 -0
  101. udata/tests/api/test_reuses_api.py +147 -147
  102. udata/tests/api/test_security_api.py +12 -12
  103. udata/tests/api/test_swagger.py +4 -4
  104. udata/tests/api/test_tags_api.py +8 -8
  105. udata/tests/api/test_user_api.py +13 -1
  106. udata/tests/apiv2/test_swagger.py +4 -4
  107. udata/tests/apiv2/test_topics.py +1 -1
  108. udata/tests/cli/test_cli_base.py +8 -9
  109. udata/tests/dataset/test_dataset_commands.py +4 -4
  110. udata/tests/dataset/test_dataset_model.py +66 -26
  111. udata/tests/dataset/test_dataset_rdf.py +99 -5
  112. udata/tests/dataset/test_resource_preview.py +0 -1
  113. udata/tests/frontend/test_auth.py +24 -1
  114. udata/tests/frontend/test_csv.py +0 -3
  115. udata/tests/helpers.py +37 -27
  116. udata/tests/organization/test_notifications.py +67 -2
  117. udata/tests/plugin.py +6 -261
  118. udata/tests/site/test_site_csv_exports.py +22 -10
  119. udata/tests/test_activity.py +9 -9
  120. udata/tests/test_cors.py +1 -1
  121. udata/tests/test_dcat_commands.py +2 -2
  122. udata/tests/test_discussions.py +5 -5
  123. udata/tests/test_migrations.py +181 -481
  124. udata/tests/test_notifications.py +15 -57
  125. udata/tests/test_notifications_task.py +43 -0
  126. udata/tests/test_owned.py +81 -1
  127. udata/tests/test_storages.py +25 -19
  128. udata/tests/test_topics.py +77 -61
  129. udata/tests/test_uris.py +33 -0
  130. udata/tests/workers/test_jobs_commands.py +23 -23
  131. udata/translations/ar/LC_MESSAGES/udata.mo +0 -0
  132. udata/translations/ar/LC_MESSAGES/udata.po +187 -108
  133. udata/translations/de/LC_MESSAGES/udata.mo +0 -0
  134. udata/translations/de/LC_MESSAGES/udata.po +187 -108
  135. udata/translations/es/LC_MESSAGES/udata.mo +0 -0
  136. udata/translations/es/LC_MESSAGES/udata.po +187 -108
  137. udata/translations/fr/LC_MESSAGES/udata.mo +0 -0
  138. udata/translations/fr/LC_MESSAGES/udata.po +188 -109
  139. udata/translations/it/LC_MESSAGES/udata.mo +0 -0
  140. udata/translations/it/LC_MESSAGES/udata.po +187 -108
  141. udata/translations/pt/LC_MESSAGES/udata.mo +0 -0
  142. udata/translations/pt/LC_MESSAGES/udata.po +187 -108
  143. udata/translations/sr/LC_MESSAGES/udata.mo +0 -0
  144. udata/translations/sr/LC_MESSAGES/udata.po +187 -108
  145. udata/translations/udata.pot +215 -106
  146. udata/uris.py +0 -2
  147. udata/utils.py +5 -0
  148. udata-14.4.1.dev7.dist-info/METADATA +109 -0
  149. {udata-13.0.1.dev12.dist-info → udata-14.4.1.dev7.dist-info}/RECORD +153 -166
  150. {udata-13.0.1.dev12.dist-info → udata-14.4.1.dev7.dist-info}/entry_points.txt +3 -5
  151. udata/core/followers/views.py +0 -15
  152. udata/core/post/forms.py +0 -30
  153. udata/entrypoints.py +0 -93
  154. udata/features/identicon/__init__.py +0 -0
  155. udata/features/identicon/api.py +0 -13
  156. udata/features/identicon/backends.py +0 -131
  157. udata/features/identicon/tests/__init__.py +0 -0
  158. udata/features/identicon/tests/test_backends.py +0 -18
  159. udata/features/territories/__init__.py +0 -49
  160. udata/features/territories/api.py +0 -25
  161. udata/features/territories/models.py +0 -51
  162. udata/flask_mongoengine/json.py +0 -38
  163. udata/migrations/__init__.py +0 -367
  164. udata/templates/mail/base.html +0 -105
  165. udata/templates/mail/base.txt +0 -6
  166. udata/templates/mail/button.html +0 -3
  167. udata/templates/mail/layouts/1-column.html +0 -19
  168. udata/templates/mail/layouts/2-columns.html +0 -20
  169. udata/templates/mail/layouts/center-panel.html +0 -16
  170. udata/tests/cli/test_db_cli.py +0 -68
  171. udata/tests/features/territories/__init__.py +0 -20
  172. udata/tests/features/territories/test_territories_api.py +0 -185
  173. udata/tests/frontend/test_hooks.py +0 -149
  174. udata-13.0.1.dev12.dist-info/METADATA +0 -133
  175. {udata-13.0.1.dev12.dist-info → udata-14.4.1.dev7.dist-info}/WHEEL +0 -0
  176. {udata-13.0.1.dev12.dist-info → udata-14.4.1.dev7.dist-info}/licenses/LICENSE +0 -0
  177. {udata-13.0.1.dev12.dist-info → udata-14.4.1.dev7.dist-info}/top_level.txt +0 -0
@@ -1,5 +1,4 @@
1
1
  import geojson
2
- from flask import current_app
3
2
  from werkzeug.local import LocalProxy
4
3
  from werkzeug.utils import cached_property
5
4
 
@@ -85,10 +84,6 @@ class GeoZone(WithMetrics, db.Document):
85
84
  return name
86
85
  return self.level_name # Fallback that should never happen.
87
86
 
88
- @property
89
- def handled_level(self):
90
- return self.level in current_app.config.get("HANDLED_LEVELS")
91
-
92
87
  @property
93
88
  def url(self):
94
89
  return None
@@ -158,11 +153,6 @@ class SpatialCoverage(db.EmbeddedDocument):
158
153
  continue
159
154
  return _(top.name)
160
155
 
161
- @property
162
- def handled_zones(self):
163
- """Return only zones with a dedicated page."""
164
- return [zone for zone in self.zones if zone.handled_level]
165
-
166
156
  def clean(self):
167
157
  if self.zones and self.geom:
168
158
  raise db.ValidationError(
@@ -1,4 +1,3 @@
1
- import pytest
2
1
  from flask import url_for
3
2
 
4
3
  from udata.core.dataset.factories import DatasetFactory
@@ -13,9 +12,7 @@ from udata.core.spatial.models import spatial_granularities
13
12
  from udata.core.spatial.tasks import compute_geozones_metrics
14
13
  from udata.tests.api import APITestCase
15
14
  from udata.tests.api.test_datasets_api import SAMPLE_GEOM
16
- from udata.tests.features.territories import (
17
- create_geozones_fixtures,
18
- )
15
+ from udata.tests.helpers import create_geozones_fixtures
19
16
  from udata.utils import faker
20
17
 
21
18
 
@@ -258,38 +255,6 @@ class SpatialApiTest(APITestCase):
258
255
  self.assertEqual(response.json["features"][1]["properties"]["datasets"], 3)
259
256
 
260
257
 
261
- @pytest.mark.options(
262
- ACTIVATE_TERRITORIES=True,
263
- HANDLED_LEVELS=("fr:commune", "fr:departement", "fr:region", "country"),
264
- )
265
- class SpatialTerritoriesApiTest(APITestCase):
266
- def test_zone_datasets_with_dynamic_and_setting(self):
267
- paca, bdr, arles = create_geozones_fixtures()
268
- organization = OrganizationFactory()
269
- for _ in range(3):
270
- DatasetFactory(
271
- organization=organization, spatial=SpatialCoverageFactory(zones=[paca.id])
272
- )
273
-
274
- response = self.get(url_for("api.zone_datasets", id=paca.id, dynamic=1))
275
- self.assert200(response)
276
- # No dynamic datasets given that they are added by udata-front extension.
277
- self.assertEqual(len(response.json), 3)
278
-
279
- def test_zone_datasets_with_dynamic_and_setting_and_size(self):
280
- paca, bdr, arles = create_geozones_fixtures()
281
- organization = OrganizationFactory()
282
- for _ in range(3):
283
- DatasetFactory(
284
- organization=organization, spatial=SpatialCoverageFactory(zones=[paca.id])
285
- )
286
-
287
- response = self.get(url_for("api.zone_datasets", id=paca.id, dynamic=1, size=2))
288
- self.assert200(response)
289
- # No dynamic datasets given that they are added by udata-front extension.
290
- self.assertEqual(len(response.json), 2)
291
-
292
-
293
258
  class DatasetsSpatialAPITest(APITestCase):
294
259
  def test_create_spatial_zones(self):
295
260
  paca, _, _ = create_geozones_fixtures()
udata/core/user/models.py CHANGED
@@ -102,7 +102,16 @@ class User(WithMetrics, UserMixin, Linkable, db.Document):
102
102
  on_delete = Signal()
103
103
 
104
104
  meta = {
105
- "indexes": ["$slug", "-created_at", "slug", "apikey"],
105
+ "indexes": [
106
+ {
107
+ "fields": ["$last_name", "$first_name", "$email"],
108
+ "default_language": "french",
109
+ "weights": {"last_name": 10, "email": 10, "first_name": 5},
110
+ },
111
+ "-created_at",
112
+ "slug",
113
+ "apikey",
114
+ ],
106
115
  "ordering": ["-created_at"],
107
116
  "auto_create_index_on_save": True,
108
117
  }
@@ -133,7 +142,7 @@ class User(WithMetrics, UserMixin, Linkable, db.Document):
133
142
  return self.has_role("admin")
134
143
 
135
144
  def self_web_url(self, **kwargs):
136
- return cdata_url(f"/users/{self._link_id(**kwargs)}/", **kwargs)
145
+ return cdata_url(f"/users/{self._link_id(**kwargs)}", **kwargs)
137
146
 
138
147
  def self_api_url(self, **kwargs):
139
148
  return url_for(
@@ -288,6 +297,10 @@ class User(WithMetrics, UserMixin, Linkable, db.Document):
288
297
  discussion.save()
289
298
  Follow.objects(follower=self).delete()
290
299
  Follow.objects(following=self).delete()
300
+ # Remove related notifications
301
+ from udata.features.notifications.models import Notification
302
+
303
+ Notification.objects.with_user_in_details(self).delete()
291
304
 
292
305
  from udata.models import ContactPoint
293
306
 
udata/cors.py CHANGED
@@ -1,6 +1,6 @@
1
1
  import logging
2
2
 
3
- from flask import current_app, g, request
3
+ from flask import current_app, request
4
4
  from werkzeug.datastructures import Headers
5
5
 
6
6
  log = logging.getLogger(__name__)
@@ -32,10 +32,7 @@ def is_preflight_request() -> bool:
32
32
 
33
33
 
34
34
  def is_allowed_cors_route():
35
- if g and hasattr(g, "lang_code"):
36
- path: str = request.path.removeprefix(f"/{g.lang_code}")
37
- else:
38
- path: str = request.path
35
+ path: str = request.path
39
36
 
40
37
  # Allow to keep clean CORS when `udata` and the frontend are on the same domain
41
38
  # (as it's the case in data.gouv with cdata/udata).
udata/db/migrations.py ADDED
@@ -0,0 +1,279 @@
1
+ """
2
+ Data migrations logic
3
+ """
4
+
5
+ import importlib.util
6
+ import inspect
7
+ import logging
8
+ import os
9
+ import queue
10
+ import traceback
11
+ from datetime import datetime
12
+ from logging.handlers import QueueHandler
13
+
14
+ from mongoengine.connection import get_db
15
+ from pymongo import ReturnDocument
16
+
17
+ log = logging.getLogger(__name__)
18
+
19
+
20
+ class MigrationError(Exception):
21
+ """
22
+ Raised on migration execution error.
23
+
24
+ :param msg str: A human readable message (a reason)
25
+ :param output str: An optionnal array of logging output
26
+ :param exc Exception: An optionnal underlying exception
27
+ """
28
+
29
+ def __init__(self, msg, output=None, exc=None, traceback=None):
30
+ super().__init__(msg)
31
+ self.msg = msg
32
+ self.output = output
33
+ self.exc = exc
34
+ self.traceback = traceback
35
+
36
+
37
+ class Record(dict):
38
+ """
39
+ A simple wrapper to migrations document
40
+ """
41
+
42
+ __getattr__ = dict.get
43
+
44
+ def load(self):
45
+ specs = {"filename": self["filename"]}
46
+ self.clear()
47
+ data = get_db().migrations.find_one(specs)
48
+ self.update(data or specs)
49
+
50
+ def exists(self):
51
+ return bool(self._id)
52
+
53
+ def __bool__(self):
54
+ return self.exists()
55
+
56
+ @property
57
+ def collection(self):
58
+ return get_db().migrations
59
+
60
+ @property
61
+ def status(self):
62
+ """
63
+ Status is the status of the last operation.
64
+
65
+ Will be `None` if the record doesn't exist.
66
+ Returns "success" or "error".
67
+ """
68
+ if not self.exists():
69
+ return None
70
+ op = self.ops[-1]
71
+ return "success" if op["success"] else "error"
72
+
73
+ @property
74
+ def last_date(self):
75
+ if not self.exists():
76
+ return
77
+ op = self.ops[-1]
78
+ return op["date"]
79
+
80
+ @property
81
+ def ok(self):
82
+ """
83
+ Is true if the migration is considered as successfully applied
84
+ """
85
+ if not self.exists():
86
+ return False
87
+ op = self.ops[-1]
88
+ return op["success"] and op["type"] in ("migrate", "record")
89
+
90
+ def add(self, _type, migration, output, state, success):
91
+ script = inspect.getsource(migration)
92
+ return Record(
93
+ self.collection.find_one_and_update(
94
+ {"filename": self.filename},
95
+ {
96
+ "$push": {
97
+ "ops": {
98
+ "date": datetime.utcnow(),
99
+ "type": _type,
100
+ "script": script,
101
+ "output": output,
102
+ "state": state,
103
+ "success": success,
104
+ }
105
+ }
106
+ },
107
+ upsert=True,
108
+ return_document=ReturnDocument.AFTER,
109
+ )
110
+ )
111
+
112
+ def delete(self):
113
+ return self.collection.delete_one({"_id": self._id})
114
+
115
+
116
+ class Migration:
117
+ def __init__(self, filename):
118
+ self.filename = filename
119
+ self._record = None
120
+ # Load module immediately - migration must exist on disk
121
+ module = load_migration(self.filename)
122
+ if module is None:
123
+ raise FileNotFoundError(f"Migration {self.filename} file not found")
124
+ # Extract and store the migrate function
125
+ if not hasattr(module, "migrate"):
126
+ raise MigrationError(
127
+ f"Migration {self.filename} is missing required migrate() function"
128
+ )
129
+ self.module = module
130
+ self.migrate = module.migrate
131
+
132
+ @property
133
+ def collection(self):
134
+ return get_db().migrations
135
+
136
+ @property
137
+ def db_query(self):
138
+ return {"filename": self.filename}
139
+
140
+ @property
141
+ def label(self):
142
+ return self.filename
143
+
144
+ @property
145
+ def record(self):
146
+ if self._record is None:
147
+ specs = {"filename": self.filename}
148
+ data = get_db().migrations.find_one(specs)
149
+ self._record = Record(data or specs)
150
+ return self._record
151
+
152
+ def __eq__(self, value):
153
+ return isinstance(value, Migration) and getattr(value, "filename") == self.filename
154
+
155
+ def execute(self, recordonly=False, dryrun=False):
156
+ """
157
+ Execute a migration
158
+
159
+ If recordonly is True, the migration is only recorded
160
+ If dryrun is True, the migration is neither executed nor recorded
161
+ """
162
+ q = queue.Queue()
163
+ logger = getattr(self.module, "log", logging.getLogger(self.module.__name__))
164
+
165
+ # Logs only go to the queue handler are not shown.
166
+ # They will be formatted below to be shown all at once at the end
167
+ # of the migration.
168
+ logger.addHandler(QueueHandler(q))
169
+ logger.propagate = False
170
+
171
+ out = [["info", "Recorded only"]] if recordonly else []
172
+
173
+ if not recordonly and not dryrun:
174
+ db = get_db()
175
+ try:
176
+ self.migrate(db)
177
+ out = _extract_output(q)
178
+ except Exception as e:
179
+ out = _extract_output(q)
180
+ tb = traceback.format_exc()
181
+ self.add_record("migrate", out, False, traceback=tb)
182
+ raise MigrationError(
183
+ "Error while executing migration", output=out, exc=e, traceback=tb
184
+ )
185
+
186
+ if not dryrun:
187
+ self.add_record("migrate", out, True)
188
+
189
+ return out
190
+
191
+ def add_record(self, type, output, success, traceback=None):
192
+ script = inspect.getsource(self.module)
193
+ return Record(
194
+ self.collection.find_one_and_update(
195
+ self.db_query,
196
+ {
197
+ "$push": {
198
+ "ops": {
199
+ "date": datetime.utcnow(),
200
+ "type": type,
201
+ "script": script,
202
+ "output": output,
203
+ "success": success,
204
+ "traceback": traceback,
205
+ }
206
+ }
207
+ },
208
+ upsert=True,
209
+ return_document=ReturnDocument.AFTER,
210
+ )
211
+ )
212
+
213
+
214
+ def get(filename):
215
+ """Get a migration"""
216
+ return Migration(filename)
217
+
218
+
219
+ def unrecord(filename):
220
+ """
221
+ Delete a migration record from database
222
+
223
+ :returns: True if record was deleted, False if it didn't exist
224
+ """
225
+ specs = {"filename": filename}
226
+ db = get_db()
227
+ record = db.migrations.find_one(specs)
228
+ if not record:
229
+ return False
230
+ return bool(db.migrations.delete_one(specs).deleted_count)
231
+
232
+
233
+ def list_available():
234
+ """
235
+ List available migrations from udata/migrations
236
+
237
+ Returns a list of Migration objects sorted by filename
238
+ """
239
+ from importlib.resources import files
240
+
241
+ migrations_path = files("udata").joinpath("migrations")
242
+
243
+ migrations = [Migration(item.name) for item in migrations_path.iterdir() if item.is_file()]
244
+
245
+ return sorted(migrations, key=lambda m: m.filename)
246
+
247
+
248
+ def load_migration(filename):
249
+ """
250
+ Load a migration from its python file
251
+
252
+ :returns: the loaded module or None if file doesn't exist
253
+ """
254
+ from importlib.resources import files
255
+
256
+ basename = os.path.splitext(os.path.basename(filename))[0]
257
+ name = f"udata.migrations.{basename}"
258
+
259
+ try:
260
+ script = files("udata").joinpath("migrations", filename).read_bytes()
261
+ except Exception:
262
+ # Return None if file doesn't exist instead of raising
263
+ return None
264
+
265
+ spec = importlib.util.spec_from_loader(name, loader=None)
266
+ module = importlib.util.module_from_spec(spec)
267
+ exec(script, module.__dict__)
268
+ module.__file__ = str(files("udata").joinpath("migrations", filename))
269
+ return module
270
+
271
+
272
+ def _extract_output(q):
273
+ """Extract log output from a QueueHandler queue"""
274
+ out = []
275
+ while not q.empty():
276
+ record = q.get()
277
+ # Use list instead of tuple to have the same data before and after mongo persist
278
+ out.append([record.levelname.lower(), record.getMessage()])
279
+ return out
@@ -1,30 +1,19 @@
1
- from udata.api import API, api, fields
1
+ from udata.api import API, api
2
2
  from udata.auth import current_user
3
3
 
4
- from .actions import get_notifications
4
+ from .models import Notification
5
5
 
6
6
  notifs = api.namespace("notifications", "Notifications API")
7
7
 
8
- notifications_fields = api.model(
9
- "Notification",
10
- {
11
- "type": fields.String(description="The notification type", readonly=True),
12
- "created_on": fields.ISODateTime(
13
- description="The notification creation datetime", readonly=True
14
- ),
15
- "details": fields.Raw(
16
- description="Key-Value details depending on notification type", readonly=True
17
- ),
18
- },
19
- )
20
-
21
8
 
22
9
  @notifs.route("/", endpoint="notifications")
23
10
  class NotificationsAPI(API):
24
11
  @api.secure
25
- @api.doc("get_notifications")
26
- @api.marshal_list_with(notifications_fields)
12
+ @api.doc("list_notifications")
13
+ @api.expect(Notification.__index_parser__)
14
+ @api.marshal_with(Notification.__page_fields__)
27
15
  def get(self):
28
16
  """List all current user pending notifications"""
29
17
  user = current_user._get_current_object()
30
- return get_notifications(user)
18
+ notifications = Notification.objects(user=user)
19
+ return Notification.apply_pagination(Notification.apply_sort_filters(notifications))
@@ -0,0 +1,56 @@
1
+ from flask_restx.inputs import boolean
2
+ from mongoengine import NULLIFY
3
+
4
+ from udata.api_fields import field, generate_fields
5
+ from udata.core.organization.notifications import MembershipRequestNotificationDetails
6
+ from udata.core.user.api_fields import user_ref_fields
7
+ from udata.core.user.models import User
8
+ from udata.models import db
9
+ from udata.mongo.datetime_fields import Datetimed
10
+ from udata.mongo.queryset import UDataQuerySet
11
+
12
+
13
+ class NotificationQuerySet(UDataQuerySet):
14
+ def with_organization_in_details(self, organization):
15
+ """This function must be updated to handle new details cases"""
16
+ return self(details__request_organization=organization)
17
+
18
+ def with_user_in_details(self, user):
19
+ """This function must be updated to handle new details cases"""
20
+ return self(details__request_user=user)
21
+
22
+
23
+ def is_handled(base_query, filter_value):
24
+ if filter_value is None:
25
+ return base_query
26
+ if filter_value is True:
27
+ return base_query.filter(handled_at__ne=None)
28
+ return base_query.filter(handled_at=None)
29
+
30
+
31
+ @generate_fields()
32
+ class Notification(Datetimed, db.Document):
33
+ meta = {
34
+ "ordering": ["-created_at"],
35
+ "queryset_class": NotificationQuerySet,
36
+ }
37
+
38
+ id = field(db.AutoUUIDField(primary_key=True))
39
+ handled_at = field(
40
+ db.DateTimeField(),
41
+ sortable=True,
42
+ auditable=False,
43
+ filterable={"key": "handled", "query": is_handled, "type": boolean},
44
+ )
45
+ user = field(
46
+ db.ReferenceField(User, reverse_delete_rule=NULLIFY),
47
+ nested_fields=user_ref_fields,
48
+ readonly=True,
49
+ allow_null=True,
50
+ auditable=False,
51
+ filterable={},
52
+ )
53
+ details = field(
54
+ db.GenericEmbeddedDocumentField(choices=(MembershipRequestNotificationDetails,)),
55
+ generic=True,
56
+ )
@@ -0,0 +1,25 @@
1
+ import logging
2
+ from datetime import datetime, timedelta
3
+
4
+ from flask import current_app
5
+
6
+ from udata.features.notifications.models import Notification
7
+ from udata.tasks import job
8
+
9
+ log = logging.getLogger(__name__)
10
+
11
+
12
+ @job("delete-expired-notifications")
13
+ def delete_expired_notifications(self):
14
+ # Delete expired notifications
15
+ handled_at = datetime.utcnow() - timedelta(
16
+ days=current_app.config["DAYS_AFTER_NOTIFICATION_EXPIRED"]
17
+ )
18
+ notifications_to_delete = Notification.objects(
19
+ handled_at__lte=handled_at,
20
+ )
21
+ count = notifications_to_delete.count()
22
+ for notification in notifications_to_delete:
23
+ notification.delete()
24
+
25
+ log.info(f"Deleted {count} expired notifications")
@@ -7,7 +7,6 @@ from mongoengine.errors import DoesNotExist
7
7
  from mongoengine.queryset import QuerySet
8
8
 
9
9
  from .connection import create_connections
10
- from .json import override_json_encoder
11
10
  from .pagination import ListFieldPagination, Pagination
12
11
  from .wtf import WtfBaseField
13
12
 
@@ -108,9 +107,6 @@ class MongoEngine(object):
108
107
 
109
108
  app.extensions = getattr(app, "extensions", {})
110
109
 
111
- # Make documents JSON serializable
112
- override_json_encoder(app)
113
-
114
110
  if "mongoengine" not in app.extensions:
115
111
  app.extensions["mongoengine"] = {}
116
112