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.
- udata/api/__init__.py +2 -8
- udata/api_fields.py +35 -4
- udata/app.py +30 -50
- udata/auth/__init__.py +29 -6
- udata/auth/forms.py +8 -6
- udata/auth/views.py +6 -3
- udata/commands/__init__.py +2 -14
- udata/commands/db.py +13 -25
- udata/commands/info.py +0 -16
- udata/commands/serve.py +3 -11
- udata/commands/tests/test_fixtures.py +9 -9
- udata/core/access_type/api.py +1 -1
- udata/core/access_type/constants.py +12 -8
- udata/core/activity/api.py +5 -6
- udata/core/avatars/api.py +43 -0
- udata/core/avatars/test_avatar_api.py +30 -0
- udata/core/badges/tests/test_commands.py +6 -6
- udata/core/csv.py +5 -0
- udata/core/dataservices/models.py +15 -3
- udata/core/dataservices/tasks.py +7 -0
- udata/core/dataset/api.py +2 -0
- udata/core/dataset/models.py +2 -2
- udata/core/dataset/permissions.py +31 -0
- udata/core/dataset/tasks.py +50 -10
- udata/core/discussions/models.py +1 -0
- udata/core/metrics/__init__.py +0 -6
- udata/core/organization/api.py +8 -5
- udata/core/organization/mails.py +1 -1
- udata/core/organization/models.py +9 -1
- udata/core/organization/notifications.py +84 -0
- udata/core/organization/permissions.py +1 -1
- udata/core/organization/tasks.py +3 -0
- udata/core/pages/tests/test_api.py +32 -0
- udata/core/post/api.py +24 -69
- udata/core/post/models.py +84 -16
- udata/core/post/tests/test_api.py +24 -1
- udata/core/reports/api.py +18 -0
- udata/core/reports/models.py +42 -2
- udata/core/reuse/models.py +1 -1
- udata/core/reuse/tasks.py +7 -0
- udata/core/site/models.py +2 -6
- udata/core/spatial/commands.py +2 -4
- udata/core/spatial/forms.py +2 -2
- udata/core/spatial/models.py +0 -10
- udata/core/spatial/tests/test_api.py +1 -36
- udata/core/user/models.py +15 -2
- udata/cors.py +2 -5
- udata/db/migrations.py +279 -0
- udata/features/notifications/api.py +7 -18
- udata/features/notifications/models.py +56 -0
- udata/features/notifications/tasks.py +25 -0
- udata/flask_mongoengine/engine.py +0 -4
- udata/frontend/__init__.py +3 -122
- udata/frontend/markdown.py +2 -1
- udata/harvest/actions.py +24 -9
- udata/harvest/api.py +30 -22
- udata/harvest/backends/__init__.py +21 -9
- udata/harvest/backends/base.py +29 -3
- udata/harvest/backends/ckan/harvesters.py +13 -2
- udata/harvest/backends/dcat.py +3 -0
- udata/harvest/backends/maaf.py +1 -0
- udata/harvest/commands.py +39 -4
- udata/harvest/filters.py +17 -6
- udata/harvest/forms.py +9 -6
- udata/harvest/models.py +16 -0
- udata/harvest/permissions.py +27 -0
- udata/harvest/tasks.py +3 -5
- udata/harvest/tests/ckan/test_ckan_backend.py +35 -2
- udata/harvest/tests/ckan/test_ckan_backend_errors.py +1 -1
- udata/harvest/tests/ckan/test_ckan_backend_filters.py +1 -1
- udata/harvest/tests/ckan/test_dkan_backend.py +1 -1
- udata/harvest/tests/dcat/udata.xml +6 -6
- udata/harvest/tests/factories.py +1 -1
- udata/harvest/tests/test_actions.py +63 -8
- udata/harvest/tests/test_api.py +278 -123
- udata/harvest/tests/test_base_backend.py +88 -1
- udata/harvest/tests/test_dcat_backend.py +60 -13
- udata/harvest/tests/test_filters.py +6 -0
- udata/i18n.py +11 -273
- udata/mail.py +5 -1
- udata/migrations/2025-10-31-create-membership-request-notifications.py +55 -0
- udata/migrations/2025-11-13-delete-user-email-index.py +25 -0
- udata/migrations/2025-12-04-add-uuid-to-discussion-messages.py +28 -0
- udata/models/__init__.py +0 -8
- udata/mongo/slug_fields.py +1 -1
- udata/rdf.py +45 -6
- udata/routing.py +2 -10
- udata/sentry.py +4 -10
- udata/settings.py +23 -17
- udata/tasks.py +4 -3
- udata/templates/mail/message.html +5 -31
- udata/tests/__init__.py +28 -12
- udata/tests/api/__init__.py +108 -21
- udata/tests/api/test_activities_api.py +36 -0
- udata/tests/api/test_auth_api.py +121 -95
- udata/tests/api/test_base_api.py +7 -4
- udata/tests/api/test_dataservices_api.py +29 -1
- udata/tests/api/test_datasets_api.py +45 -21
- udata/tests/api/test_organizations_api.py +192 -197
- udata/tests/api/test_reports_api.py +157 -0
- udata/tests/api/test_reuses_api.py +147 -147
- udata/tests/api/test_security_api.py +12 -12
- udata/tests/api/test_swagger.py +4 -4
- udata/tests/api/test_tags_api.py +8 -8
- udata/tests/api/test_user_api.py +13 -1
- udata/tests/apiv2/test_swagger.py +4 -4
- udata/tests/apiv2/test_topics.py +1 -1
- udata/tests/cli/test_cli_base.py +8 -9
- udata/tests/dataset/test_dataset_commands.py +4 -4
- udata/tests/dataset/test_dataset_model.py +66 -26
- udata/tests/dataset/test_dataset_rdf.py +99 -5
- udata/tests/dataset/test_resource_preview.py +0 -1
- udata/tests/frontend/test_auth.py +24 -1
- udata/tests/frontend/test_csv.py +0 -3
- udata/tests/helpers.py +37 -27
- udata/tests/organization/test_notifications.py +67 -2
- udata/tests/plugin.py +6 -261
- udata/tests/site/test_site_csv_exports.py +22 -10
- udata/tests/test_activity.py +9 -9
- udata/tests/test_cors.py +1 -1
- udata/tests/test_dcat_commands.py +2 -2
- udata/tests/test_discussions.py +5 -5
- udata/tests/test_migrations.py +181 -481
- udata/tests/test_notifications.py +15 -57
- udata/tests/test_notifications_task.py +43 -0
- udata/tests/test_owned.py +81 -1
- udata/tests/test_storages.py +25 -19
- udata/tests/test_topics.py +77 -61
- udata/tests/test_uris.py +33 -0
- udata/tests/workers/test_jobs_commands.py +23 -23
- udata/translations/ar/LC_MESSAGES/udata.mo +0 -0
- udata/translations/ar/LC_MESSAGES/udata.po +187 -108
- udata/translations/de/LC_MESSAGES/udata.mo +0 -0
- udata/translations/de/LC_MESSAGES/udata.po +187 -108
- udata/translations/es/LC_MESSAGES/udata.mo +0 -0
- udata/translations/es/LC_MESSAGES/udata.po +187 -108
- udata/translations/fr/LC_MESSAGES/udata.mo +0 -0
- udata/translations/fr/LC_MESSAGES/udata.po +188 -109
- udata/translations/it/LC_MESSAGES/udata.mo +0 -0
- udata/translations/it/LC_MESSAGES/udata.po +187 -108
- udata/translations/pt/LC_MESSAGES/udata.mo +0 -0
- udata/translations/pt/LC_MESSAGES/udata.po +187 -108
- udata/translations/sr/LC_MESSAGES/udata.mo +0 -0
- udata/translations/sr/LC_MESSAGES/udata.po +187 -108
- udata/translations/udata.pot +215 -106
- udata/uris.py +0 -2
- udata/utils.py +5 -0
- udata-14.4.1.dev7.dist-info/METADATA +109 -0
- {udata-13.0.1.dev12.dist-info → udata-14.4.1.dev7.dist-info}/RECORD +153 -166
- {udata-13.0.1.dev12.dist-info → udata-14.4.1.dev7.dist-info}/entry_points.txt +3 -5
- udata/core/followers/views.py +0 -15
- udata/core/post/forms.py +0 -30
- udata/entrypoints.py +0 -93
- udata/features/identicon/__init__.py +0 -0
- udata/features/identicon/api.py +0 -13
- udata/features/identicon/backends.py +0 -131
- udata/features/identicon/tests/__init__.py +0 -0
- udata/features/identicon/tests/test_backends.py +0 -18
- udata/features/territories/__init__.py +0 -49
- udata/features/territories/api.py +0 -25
- udata/features/territories/models.py +0 -51
- udata/flask_mongoengine/json.py +0 -38
- udata/migrations/__init__.py +0 -367
- udata/templates/mail/base.html +0 -105
- udata/templates/mail/base.txt +0 -6
- udata/templates/mail/button.html +0 -3
- udata/templates/mail/layouts/1-column.html +0 -19
- udata/templates/mail/layouts/2-columns.html +0 -20
- udata/templates/mail/layouts/center-panel.html +0 -16
- udata/tests/cli/test_db_cli.py +0 -68
- udata/tests/features/territories/__init__.py +0 -20
- udata/tests/features/territories/test_territories_api.py +0 -185
- udata/tests/frontend/test_hooks.py +0 -149
- udata-13.0.1.dev12.dist-info/METADATA +0 -133
- {udata-13.0.1.dev12.dist-info → udata-14.4.1.dev7.dist-info}/WHEEL +0 -0
- {udata-13.0.1.dev12.dist-info → udata-14.4.1.dev7.dist-info}/licenses/LICENSE +0 -0
- {udata-13.0.1.dev12.dist-info → udata-14.4.1.dev7.dist-info}/top_level.txt +0 -0
udata/core/spatial/models.py
CHANGED
|
@@ -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.
|
|
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": [
|
|
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)}
|
|
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,
|
|
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
|
-
|
|
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
|
|
1
|
+
from udata.api import API, api
|
|
2
2
|
from udata.auth import current_user
|
|
3
3
|
|
|
4
|
-
from .
|
|
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("
|
|
26
|
-
@api.
|
|
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
|
-
|
|
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
|
|