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.
- udata/api/__init__.py +2 -0
- udata/api_fields.py +120 -19
- udata/app.py +18 -20
- udata/auth/__init__.py +4 -7
- udata/auth/forms.py +3 -3
- udata/auth/views.py +13 -6
- udata/commands/dcat.py +1 -1
- udata/commands/serve.py +3 -11
- udata/core/activity/api.py +5 -6
- udata/core/badges/tests/test_tasks.py +0 -2
- udata/core/csv.py +5 -0
- udata/core/dataservices/api.py +8 -1
- udata/core/dataservices/apiv2.py +3 -6
- udata/core/dataservices/models.py +5 -2
- udata/core/dataservices/rdf.py +2 -1
- udata/core/dataservices/tasks.py +6 -2
- udata/core/dataset/api.py +30 -4
- udata/core/dataset/api_fields.py +1 -1
- udata/core/dataset/apiv2.py +1 -1
- udata/core/dataset/constants.py +2 -9
- udata/core/dataset/models.py +21 -9
- udata/core/dataset/permissions.py +31 -0
- udata/core/dataset/rdf.py +18 -16
- udata/core/dataset/tasks.py +16 -7
- udata/core/discussions/api.py +15 -1
- udata/core/discussions/models.py +6 -0
- udata/core/legal/__init__.py +0 -0
- udata/core/legal/mails.py +128 -0
- udata/core/organization/api.py +16 -5
- udata/core/organization/api_fields.py +3 -3
- udata/core/organization/apiv2.py +3 -4
- udata/core/organization/mails.py +1 -1
- udata/core/organization/models.py +40 -7
- udata/core/organization/notifications.py +84 -0
- udata/core/organization/permissions.py +1 -1
- udata/core/organization/tasks.py +3 -0
- udata/core/pages/models.py +49 -0
- udata/core/pages/tests/test_api.py +165 -1
- udata/core/post/api.py +25 -70
- udata/core/post/constants.py +8 -0
- udata/core/post/models.py +109 -17
- udata/core/post/tests/test_api.py +140 -3
- udata/core/post/tests/test_models.py +24 -0
- udata/core/reports/api.py +18 -0
- udata/core/reports/models.py +42 -2
- udata/core/reuse/api.py +8 -0
- udata/core/reuse/apiv2.py +3 -6
- udata/core/reuse/models.py +1 -1
- udata/core/spatial/forms.py +2 -2
- udata/core/topic/models.py +8 -2
- udata/core/user/api.py +10 -3
- udata/core/user/api_fields.py +3 -3
- udata/core/user/models.py +33 -8
- udata/features/notifications/api.py +7 -18
- udata/features/notifications/models.py +59 -0
- udata/features/notifications/tasks.py +25 -0
- udata/features/transfer/actions.py +2 -0
- udata/features/transfer/models.py +17 -0
- udata/features/transfer/notifications.py +96 -0
- udata/flask_mongoengine/engine.py +0 -4
- udata/flask_mongoengine/pagination.py +1 -1
- udata/frontend/markdown.py +2 -1
- udata/harvest/actions.py +20 -0
- udata/harvest/api.py +24 -7
- udata/harvest/backends/base.py +27 -1
- udata/harvest/backends/ckan/harvesters.py +21 -4
- udata/harvest/backends/dcat.py +4 -1
- udata/harvest/commands.py +33 -0
- udata/harvest/filters.py +17 -6
- udata/harvest/models.py +16 -0
- udata/harvest/permissions.py +27 -0
- udata/harvest/tests/ckan/test_ckan_backend.py +33 -0
- udata/harvest/tests/test_actions.py +46 -2
- udata/harvest/tests/test_api.py +161 -6
- udata/harvest/tests/test_base_backend.py +86 -1
- udata/harvest/tests/test_dcat_backend.py +68 -3
- udata/harvest/tests/test_filters.py +6 -0
- udata/i18n.py +1 -4
- udata/mail.py +14 -0
- udata/migrations/2021-08-17-harvest-integrity.py +23 -16
- udata/migrations/2025-10-31-create-membership-request-notifications.py +55 -0
- udata/migrations/2025-12-04-add-uuid-to-discussion-messages.py +28 -0
- udata/migrations/2025-12-16-create-transfer-request-notifications.py +69 -0
- udata/migrations/2026-01-14-add-default-kind-to-posts.py +17 -0
- udata/mongo/slug_fields.py +1 -1
- udata/rdf.py +65 -11
- udata/routing.py +2 -2
- udata/settings.py +11 -0
- udata/tasks.py +2 -0
- udata/templates/mail/message.html +3 -1
- udata/tests/api/__init__.py +7 -17
- udata/tests/api/test_activities_api.py +36 -0
- udata/tests/api/test_datasets_api.py +69 -0
- udata/tests/api/test_organizations_api.py +0 -3
- udata/tests/api/test_reports_api.py +157 -0
- udata/tests/api/test_user_api.py +1 -1
- udata/tests/apiv2/test_dataservices.py +14 -0
- udata/tests/apiv2/test_organizations.py +9 -0
- udata/tests/apiv2/test_reuses.py +11 -0
- udata/tests/cli/test_cli_base.py +0 -1
- udata/tests/dataservice/test_dataservice_tasks.py +29 -0
- udata/tests/dataset/test_dataset_model.py +13 -1
- udata/tests/dataset/test_dataset_rdf.py +164 -5
- udata/tests/dataset/test_dataset_tasks.py +25 -0
- udata/tests/frontend/test_auth.py +58 -1
- udata/tests/frontend/test_csv.py +0 -3
- udata/tests/helpers.py +31 -27
- udata/tests/organization/test_notifications.py +67 -2
- udata/tests/search/test_search_integration.py +70 -0
- udata/tests/site/test_site_csv_exports.py +22 -10
- udata/tests/test_activity.py +9 -9
- udata/tests/test_api_fields.py +10 -0
- udata/tests/test_discussions.py +5 -5
- udata/tests/test_legal_mails.py +359 -0
- 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_transfer.py +181 -2
- udata/tests/test_uris.py +33 -0
- udata/translations/ar/LC_MESSAGES/udata.mo +0 -0
- udata/translations/ar/LC_MESSAGES/udata.po +309 -158
- udata/translations/de/LC_MESSAGES/udata.mo +0 -0
- udata/translations/de/LC_MESSAGES/udata.po +313 -160
- udata/translations/es/LC_MESSAGES/udata.mo +0 -0
- udata/translations/es/LC_MESSAGES/udata.po +312 -160
- udata/translations/fr/LC_MESSAGES/udata.mo +0 -0
- udata/translations/fr/LC_MESSAGES/udata.po +475 -202
- udata/translations/it/LC_MESSAGES/udata.mo +0 -0
- udata/translations/it/LC_MESSAGES/udata.po +317 -162
- udata/translations/pt/LC_MESSAGES/udata.mo +0 -0
- udata/translations/pt/LC_MESSAGES/udata.po +315 -161
- udata/translations/sr/LC_MESSAGES/udata.mo +0 -0
- udata/translations/sr/LC_MESSAGES/udata.po +323 -164
- udata/translations/udata.pot +169 -124
- udata/uris.py +0 -2
- udata/utils.py +23 -0
- udata-14.7.3.dev4.dist-info/METADATA +109 -0
- {udata-14.0.3.dev1.dist-info → udata-14.7.3.dev4.dist-info}/RECORD +142 -135
- udata/core/post/forms.py +0 -30
- udata/flask_mongoengine/json.py +0 -38
- 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-14.0.3.dev1.dist-info/METADATA +0 -132
- {udata-14.0.3.dev1.dist-info → udata-14.7.3.dev4.dist-info}/WHEEL +0 -0
- {udata-14.0.3.dev1.dist-info → udata-14.7.3.dev4.dist-info}/entry_points.txt +0 -0
- {udata-14.0.3.dev1.dist-info → udata-14.7.3.dev4.dist-info}/licenses/LICENSE +0 -0
- {udata-14.0.3.dev1.dist-info → udata-14.7.3.dev4.dist-info}/top_level.txt +0 -0
udata/core/reuse/apiv2.py
CHANGED
|
@@ -1,9 +1,6 @@
|
|
|
1
|
-
from flask import request
|
|
2
|
-
|
|
3
1
|
from udata import search
|
|
4
2
|
from udata.api import API, apiv2
|
|
5
3
|
from udata.core.reuse.models import Reuse
|
|
6
|
-
from udata.utils import multi_to_dict
|
|
7
4
|
|
|
8
5
|
from .api_fields import reuse_permissions_fields
|
|
9
6
|
from .search import ReuseSearch
|
|
@@ -14,7 +11,7 @@ apiv2.inherit("Reuse (read)", Reuse.__read_fields__)
|
|
|
14
11
|
|
|
15
12
|
ns = apiv2.namespace("reuses", "Reuse related operations")
|
|
16
13
|
|
|
17
|
-
search_parser = ReuseSearch.as_request_parser()
|
|
14
|
+
search_parser = ReuseSearch.as_request_parser(store_missing=False)
|
|
18
15
|
|
|
19
16
|
DEFAULT_SORTING = "-created_at"
|
|
20
17
|
|
|
@@ -28,5 +25,5 @@ class ReuseSearchAPI(API):
|
|
|
28
25
|
@apiv2.marshal_with(Reuse.__page_fields__)
|
|
29
26
|
def get(self):
|
|
30
27
|
"""Search all reuses"""
|
|
31
|
-
search_parser.parse_args()
|
|
32
|
-
return search.query(ReuseSearch, **
|
|
28
|
+
args = search_parser.parse_args()
|
|
29
|
+
return search.query(ReuseSearch, **args)
|
udata/core/reuse/models.py
CHANGED
|
@@ -199,7 +199,7 @@ class Reuse(db.Datetimed, Auditable, WithMetrics, ReuseBadgeMixin, Linkable, Own
|
|
|
199
199
|
cls.before_save.send(document)
|
|
200
200
|
|
|
201
201
|
def self_web_url(self, **kwargs):
|
|
202
|
-
return cdata_url(f"/reuses/{self._link_id(**kwargs)}
|
|
202
|
+
return cdata_url(f"/reuses/{self._link_id(**kwargs)}", **kwargs)
|
|
203
203
|
|
|
204
204
|
def self_api_url(self, **kwargs):
|
|
205
205
|
return url_for(
|
udata/core/spatial/forms.py
CHANGED
|
@@ -64,8 +64,8 @@ class GeomField(Field):
|
|
|
64
64
|
self.data = geojson.GeoJSON.to_instance(value)
|
|
65
65
|
except Exception:
|
|
66
66
|
self.data = None
|
|
67
|
-
log.
|
|
68
|
-
raise
|
|
67
|
+
log.warning(f"Unable to parse GeoJSON: {value}")
|
|
68
|
+
raise validators.ValidationError(self.gettext("Not a valid GeoJSON"))
|
|
69
69
|
|
|
70
70
|
def pre_validate(self, form):
|
|
71
71
|
if self.data:
|
udata/core/topic/models.py
CHANGED
|
@@ -16,7 +16,10 @@ __all__ = ("Topic", "TopicElement")
|
|
|
16
16
|
|
|
17
17
|
class TopicElement(Auditable, db.Document):
|
|
18
18
|
title = field(db.StringField(required=False))
|
|
19
|
-
description = field(
|
|
19
|
+
description = field(
|
|
20
|
+
db.StringField(required=False),
|
|
21
|
+
markdown=True,
|
|
22
|
+
)
|
|
20
23
|
tags = field(db.ListField(db.StringField()))
|
|
21
24
|
extras = field(db.ExtrasField())
|
|
22
25
|
element = field(db.GenericReferenceField(choices=["Dataset", "Reuse", "Dataservice"]))
|
|
@@ -63,7 +66,10 @@ class Topic(db.Datetimed, Auditable, Linkable, db.Document, Owned):
|
|
|
63
66
|
db.SlugField(max_length=255, required=True, populate_from="name", update=True, follow=True),
|
|
64
67
|
auditable=False,
|
|
65
68
|
)
|
|
66
|
-
description = field(
|
|
69
|
+
description = field(
|
|
70
|
+
db.StringField(),
|
|
71
|
+
markdown=True,
|
|
72
|
+
)
|
|
67
73
|
tags = field(db.ListField(db.StringField()))
|
|
68
74
|
color = field(db.IntField())
|
|
69
75
|
|
udata/core/user/api.py
CHANGED
|
@@ -8,6 +8,7 @@ from udata.core.dataset.api_fields import community_resource_fields, dataset_fie
|
|
|
8
8
|
from udata.core.discussions.actions import discussions_for
|
|
9
9
|
from udata.core.discussions.api import discussion_fields
|
|
10
10
|
from udata.core.followers.api import FollowAPI
|
|
11
|
+
from udata.core.legal.mails import add_send_legal_notice_argument, send_legal_notice_on_deletion
|
|
11
12
|
from udata.core.storages.api import (
|
|
12
13
|
image_parser,
|
|
13
14
|
parse_uploaded_image,
|
|
@@ -265,11 +266,14 @@ class UserAvatarAPI(API):
|
|
|
265
266
|
return {"image": user.avatar}
|
|
266
267
|
|
|
267
268
|
|
|
268
|
-
delete_parser = api.parser()
|
|
269
|
+
delete_parser = add_send_legal_notice_argument(api.parser())
|
|
269
270
|
delete_parser.add_argument(
|
|
270
271
|
"no_mail",
|
|
271
272
|
type=bool,
|
|
272
|
-
help=
|
|
273
|
+
help=(
|
|
274
|
+
"Do not send the simple deletion notification email. "
|
|
275
|
+
"Note: automatically set to True when send_legal_notice=True to avoid sending duplicate emails."
|
|
276
|
+
),
|
|
273
277
|
location="args",
|
|
274
278
|
default=False,
|
|
275
279
|
)
|
|
@@ -321,8 +325,11 @@ class UserAPI(API):
|
|
|
321
325
|
api.abort(
|
|
322
326
|
403, "You cannot delete yourself with this API. " + 'Use the "me" API instead.'
|
|
323
327
|
)
|
|
328
|
+
send_legal_notice_on_deletion(user, args)
|
|
324
329
|
|
|
325
|
-
|
|
330
|
+
# Skip simple notification if legal notice is sent (to avoid duplicate emails)
|
|
331
|
+
skip_notification = args["no_mail"] or args["send_legal_notice"]
|
|
332
|
+
user.mark_as_deleted(notify=not skip_notification, delete_comments=args["delete_comments"])
|
|
326
333
|
return "", 204
|
|
327
334
|
|
|
328
335
|
|
udata/core/user/api_fields.py
CHANGED
|
@@ -9,7 +9,7 @@ user_ref_fields = api.inherit(
|
|
|
9
9
|
{
|
|
10
10
|
"first_name": fields.String(description="The user first name", readonly=True),
|
|
11
11
|
"last_name": fields.String(description="The user larst name", readonly=True),
|
|
12
|
-
"slug": fields.String(description="The user permalink string",
|
|
12
|
+
"slug": fields.String(description="The user permalink string", readonly=True),
|
|
13
13
|
"uri": fields.String(
|
|
14
14
|
attribute=lambda u: u.self_api_url(),
|
|
15
15
|
description="The API URI for this user",
|
|
@@ -35,8 +35,8 @@ from udata.core.organization.api_fields import member_email_with_visibility_chec
|
|
|
35
35
|
user_fields = api.model(
|
|
36
36
|
"User",
|
|
37
37
|
{
|
|
38
|
-
"id": fields.String(description="The user identifier",
|
|
39
|
-
"slug": fields.String(description="The user permalink string",
|
|
38
|
+
"id": fields.String(description="The user identifier", readonly=True),
|
|
39
|
+
"slug": fields.String(description="The user permalink string", readonly=True),
|
|
40
40
|
"first_name": fields.String(description="The user first name", required=True),
|
|
41
41
|
"last_name": fields.String(description="The user last name", required=True),
|
|
42
42
|
"email": fields.Raw(
|
udata/core/user/models.py
CHANGED
|
@@ -12,17 +12,18 @@ from flask_security import MongoEngineUserDatastore, RoleMixin, UserMixin
|
|
|
12
12
|
from mongoengine.signals import post_save, pre_save
|
|
13
13
|
from werkzeug.utils import cached_property
|
|
14
14
|
|
|
15
|
-
from udata.api_fields import field
|
|
15
|
+
from udata.api_fields import field, generate_fields
|
|
16
16
|
from udata.core import storages
|
|
17
17
|
from udata.core.discussions.models import Discussion
|
|
18
18
|
from udata.core.linkable import Linkable
|
|
19
19
|
from udata.core.storages import avatars, default_image_basename
|
|
20
20
|
from udata.frontend.markdown import mdstrip
|
|
21
|
+
from udata.i18n import lazy_gettext as _
|
|
21
22
|
from udata.models import Follow, WithMetrics, db
|
|
22
23
|
from udata.uris import cdata_url
|
|
23
24
|
|
|
24
25
|
from . import mails
|
|
25
|
-
from .constants import AVATAR_SIZES
|
|
26
|
+
from .constants import AVATAR_SIZES, BIGGEST_AVATAR_SIZE
|
|
26
27
|
|
|
27
28
|
__all__ = ("User", "Role", "datastore")
|
|
28
29
|
|
|
@@ -44,9 +45,12 @@ class UserSettings(db.EmbeddedDocument):
|
|
|
44
45
|
prefered_language = db.StringField()
|
|
45
46
|
|
|
46
47
|
|
|
48
|
+
@generate_fields()
|
|
47
49
|
class User(WithMetrics, UserMixin, Linkable, db.Document):
|
|
48
50
|
slug = field(
|
|
49
|
-
db.SlugField(max_length=255, required=True, populate_from="fullname"),
|
|
51
|
+
db.SlugField(max_length=255, required=True, populate_from="fullname"),
|
|
52
|
+
auditable=False,
|
|
53
|
+
show_as_ref=True,
|
|
50
54
|
)
|
|
51
55
|
email = field(db.StringField(max_length=255, required=True, unique=True))
|
|
52
56
|
password = field(db.StringField())
|
|
@@ -54,15 +58,22 @@ class User(WithMetrics, UserMixin, Linkable, db.Document):
|
|
|
54
58
|
fs_uniquifier = field(db.StringField(max_length=64, unique=True, sparse=True))
|
|
55
59
|
roles = field(db.ListField(db.ReferenceField(Role), default=[]))
|
|
56
60
|
|
|
57
|
-
first_name = field(db.StringField(max_length=255, required=True))
|
|
58
|
-
last_name = field(db.StringField(max_length=255, required=True))
|
|
61
|
+
first_name = field(db.StringField(max_length=255, required=True), show_as_ref=True)
|
|
62
|
+
last_name = field(db.StringField(max_length=255, required=True), show_as_ref=True)
|
|
59
63
|
|
|
60
64
|
avatar_url = field(db.URLField())
|
|
61
65
|
avatar = field(
|
|
62
|
-
db.ImageField(fs=avatars, basename=default_image_basename, thumbnails=AVATAR_SIZES)
|
|
66
|
+
db.ImageField(fs=avatars, basename=default_image_basename, thumbnails=AVATAR_SIZES),
|
|
67
|
+
show_as_ref=True,
|
|
68
|
+
thumbnail_info={
|
|
69
|
+
"size": BIGGEST_AVATAR_SIZE,
|
|
70
|
+
},
|
|
63
71
|
)
|
|
64
72
|
website = field(db.URLField())
|
|
65
|
-
about = field(
|
|
73
|
+
about = field(
|
|
74
|
+
db.StringField(),
|
|
75
|
+
markdown=True,
|
|
76
|
+
)
|
|
66
77
|
|
|
67
78
|
prefered_language = field(db.StringField())
|
|
68
79
|
|
|
@@ -116,6 +127,8 @@ class User(WithMetrics, UserMixin, Linkable, db.Document):
|
|
|
116
127
|
"auto_create_index_on_save": True,
|
|
117
128
|
}
|
|
118
129
|
|
|
130
|
+
verbose_name = _("account")
|
|
131
|
+
|
|
119
132
|
__metrics_keys__ = [
|
|
120
133
|
"datasets",
|
|
121
134
|
"reuses",
|
|
@@ -142,7 +155,7 @@ class User(WithMetrics, UserMixin, Linkable, db.Document):
|
|
|
142
155
|
return self.has_role("admin")
|
|
143
156
|
|
|
144
157
|
def self_web_url(self, **kwargs):
|
|
145
|
-
return cdata_url(f"/users/{self._link_id(**kwargs)}
|
|
158
|
+
return cdata_url(f"/users/{self._link_id(**kwargs)}", **kwargs)
|
|
146
159
|
|
|
147
160
|
def self_api_url(self, **kwargs):
|
|
148
161
|
return url_for(
|
|
@@ -193,6 +206,14 @@ class User(WithMetrics, UserMixin, Linkable, db.Document):
|
|
|
193
206
|
"""Return the number of followers of the user."""
|
|
194
207
|
return self.metrics.get("followers", 0)
|
|
195
208
|
|
|
209
|
+
@field(description="Link to the API endpoint for this user", show_as_ref=True)
|
|
210
|
+
def uri(self, *args, **kwargs):
|
|
211
|
+
return self.self_api_url(*args, **kwargs)
|
|
212
|
+
|
|
213
|
+
@field(description="Link to the udata web page for this user", show_as_ref=True)
|
|
214
|
+
def page(self, *args, **kwargs):
|
|
215
|
+
return self.self_web_url(*args, **kwargs)
|
|
216
|
+
|
|
196
217
|
def generate_api_key(self):
|
|
197
218
|
payload = {
|
|
198
219
|
"user": str(self.id),
|
|
@@ -297,6 +318,10 @@ class User(WithMetrics, UserMixin, Linkable, db.Document):
|
|
|
297
318
|
discussion.save()
|
|
298
319
|
Follow.objects(follower=self).delete()
|
|
299
320
|
Follow.objects(following=self).delete()
|
|
321
|
+
# Remove related notifications
|
|
322
|
+
from udata.features.notifications.models import Notification
|
|
323
|
+
|
|
324
|
+
Notification.objects.with_user_in_details(self).delete()
|
|
300
325
|
|
|
301
326
|
from udata.models import ContactPoint
|
|
302
327
|
|
|
@@ -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,59 @@
|
|
|
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.features.transfer.notifications import TransferRequestNotificationDetails
|
|
9
|
+
from udata.models import db
|
|
10
|
+
from udata.mongo.datetime_fields import Datetimed
|
|
11
|
+
from udata.mongo.queryset import UDataQuerySet
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class NotificationQuerySet(UDataQuerySet):
|
|
15
|
+
def with_organization_in_details(self, organization):
|
|
16
|
+
"""This function must be updated to handle new details cases"""
|
|
17
|
+
return self(details__request_organization=organization)
|
|
18
|
+
|
|
19
|
+
def with_user_in_details(self, user):
|
|
20
|
+
"""This function must be updated to handle new details cases"""
|
|
21
|
+
return self(details__request_user=user)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def is_handled(base_query, filter_value):
|
|
25
|
+
if filter_value is None:
|
|
26
|
+
return base_query
|
|
27
|
+
if filter_value is True:
|
|
28
|
+
return base_query.filter(handled_at__ne=None)
|
|
29
|
+
return base_query.filter(handled_at=None)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@generate_fields()
|
|
33
|
+
class Notification(Datetimed, db.Document):
|
|
34
|
+
meta = {
|
|
35
|
+
"ordering": ["-created_at"],
|
|
36
|
+
"queryset_class": NotificationQuerySet,
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
id = field(db.AutoUUIDField(primary_key=True))
|
|
40
|
+
handled_at = field(
|
|
41
|
+
db.DateTimeField(),
|
|
42
|
+
sortable=True,
|
|
43
|
+
auditable=False,
|
|
44
|
+
filterable={"key": "handled", "query": is_handled, "type": boolean},
|
|
45
|
+
)
|
|
46
|
+
user = field(
|
|
47
|
+
db.ReferenceField(User, reverse_delete_rule=NULLIFY),
|
|
48
|
+
nested_fields=user_ref_fields,
|
|
49
|
+
readonly=True,
|
|
50
|
+
allow_null=True,
|
|
51
|
+
auditable=False,
|
|
52
|
+
filterable={},
|
|
53
|
+
)
|
|
54
|
+
details = field(
|
|
55
|
+
db.GenericEmbeddedDocumentField(
|
|
56
|
+
choices=(MembershipRequestNotificationDetails, TransferRequestNotificationDetails)
|
|
57
|
+
),
|
|
58
|
+
generic=True,
|
|
59
|
+
)
|
|
@@ -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")
|
|
@@ -36,6 +36,7 @@ def accept_transfer(transfer, comment=None):
|
|
|
36
36
|
transfer.status = "accepted"
|
|
37
37
|
transfer.response_comment = comment
|
|
38
38
|
transfer.save()
|
|
39
|
+
Transfer.after_handle.send(transfer)
|
|
39
40
|
|
|
40
41
|
subject = transfer.subject
|
|
41
42
|
recipient = transfer.recipient
|
|
@@ -59,5 +60,6 @@ def refuse_transfer(transfer, comment=None):
|
|
|
59
60
|
transfer.status = "refused"
|
|
60
61
|
transfer.response_comment = comment
|
|
61
62
|
transfer.save()
|
|
63
|
+
Transfer.after_handle.send(transfer)
|
|
62
64
|
|
|
63
65
|
return transfer
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import logging
|
|
2
2
|
from datetime import datetime
|
|
3
3
|
|
|
4
|
+
from blinker import Signal
|
|
5
|
+
from mongoengine.signals import post_save
|
|
6
|
+
|
|
4
7
|
from udata.i18n import lazy_gettext as _
|
|
5
8
|
from udata.mongo import db
|
|
6
9
|
|
|
@@ -30,6 +33,9 @@ class Transfer(db.Document):
|
|
|
30
33
|
responder = db.ReferenceField("User")
|
|
31
34
|
response_comment = db.StringField()
|
|
32
35
|
|
|
36
|
+
on_create = Signal()
|
|
37
|
+
after_handle = Signal()
|
|
38
|
+
|
|
33
39
|
meta = {
|
|
34
40
|
"indexes": [
|
|
35
41
|
"owner",
|
|
@@ -38,3 +44,14 @@ class Transfer(db.Document):
|
|
|
38
44
|
"status",
|
|
39
45
|
]
|
|
40
46
|
}
|
|
47
|
+
|
|
48
|
+
@classmethod
|
|
49
|
+
def post_save(cls, sender, document, **kwargs):
|
|
50
|
+
"""Handle post save signal for Transfer documents."""
|
|
51
|
+
# Only trigger on_create signal on creation, not on every save
|
|
52
|
+
if kwargs.get("created"):
|
|
53
|
+
cls.on_create.send(document)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
# Connect the post_save signal
|
|
57
|
+
post_save.connect(Transfer.post_save, sender=Transfer)
|
|
@@ -1,11 +1,107 @@
|
|
|
1
1
|
import logging
|
|
2
|
+
from datetime import datetime
|
|
2
3
|
|
|
4
|
+
from udata.api_fields import field, generate_fields
|
|
5
|
+
from udata.core.dataservices.models import Dataservice
|
|
6
|
+
from udata.core.dataset.models import Dataset
|
|
7
|
+
from udata.core.organization.models import Organization
|
|
8
|
+
from udata.core.reuse.models import Reuse
|
|
9
|
+
from udata.core.user.models import User
|
|
3
10
|
from udata.features.notifications.actions import notifier
|
|
4
11
|
from udata.models import Transfer
|
|
12
|
+
from udata.mongo import db
|
|
5
13
|
|
|
6
14
|
log = logging.getLogger(__name__)
|
|
7
15
|
|
|
8
16
|
|
|
17
|
+
@generate_fields()
|
|
18
|
+
class TransferRequestNotificationDetails(db.EmbeddedDocument):
|
|
19
|
+
transfer_owner = field(
|
|
20
|
+
db.GenericReferenceField(choices=(User, Organization), required=True),
|
|
21
|
+
readonly=True,
|
|
22
|
+
auditable=False,
|
|
23
|
+
allow_null=True,
|
|
24
|
+
filterable={},
|
|
25
|
+
)
|
|
26
|
+
transfer_recipient = field(
|
|
27
|
+
db.GenericReferenceField(choices=(User, Organization), required=True),
|
|
28
|
+
readonly=True,
|
|
29
|
+
auditable=False,
|
|
30
|
+
allow_null=True,
|
|
31
|
+
filterable={},
|
|
32
|
+
)
|
|
33
|
+
transfer_subject = field(
|
|
34
|
+
db.GenericReferenceField(choices=(Dataset, Dataservice, Reuse), required=True),
|
|
35
|
+
readonly=True,
|
|
36
|
+
auditable=False,
|
|
37
|
+
allow_null=True,
|
|
38
|
+
filterable={},
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@Transfer.on_create.connect
|
|
43
|
+
def on_transfer_created(transfer, **kwargs):
|
|
44
|
+
"""Create notification when a new transfer request is created"""
|
|
45
|
+
|
|
46
|
+
from udata.features.notifications.models import Notification
|
|
47
|
+
|
|
48
|
+
recipient = transfer.recipient
|
|
49
|
+
owner = transfer.owner
|
|
50
|
+
users = []
|
|
51
|
+
|
|
52
|
+
if isinstance(recipient, User):
|
|
53
|
+
users = [recipient]
|
|
54
|
+
elif isinstance(recipient, Organization):
|
|
55
|
+
users = [member.user for member in recipient.members if member.role == "admin"]
|
|
56
|
+
|
|
57
|
+
for user in users:
|
|
58
|
+
try:
|
|
59
|
+
# we don't want notifications for the same transfer, if the previous one is stil no handled
|
|
60
|
+
existing = Notification.objects(
|
|
61
|
+
user=user,
|
|
62
|
+
details__transfer_recipient=recipient,
|
|
63
|
+
details__transfer_owner=owner,
|
|
64
|
+
details__transfer_subject=transfer.subject,
|
|
65
|
+
handled_at=None,
|
|
66
|
+
).first()
|
|
67
|
+
|
|
68
|
+
if not existing:
|
|
69
|
+
notification = Notification(
|
|
70
|
+
user=user,
|
|
71
|
+
details=TransferRequestNotificationDetails(
|
|
72
|
+
transfer_owner=owner,
|
|
73
|
+
transfer_recipient=recipient,
|
|
74
|
+
transfer_subject=transfer.subject,
|
|
75
|
+
),
|
|
76
|
+
)
|
|
77
|
+
notification.created_at = transfer.created
|
|
78
|
+
notification.save()
|
|
79
|
+
except Exception as e:
|
|
80
|
+
log.error(
|
|
81
|
+
f"Error creating notification for admin user {user.id} "
|
|
82
|
+
f"and recipient {recipient.id}: {e}"
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@Transfer.after_handle.connect
|
|
87
|
+
def on_handle_transfer(transfer, **kwargs):
|
|
88
|
+
"""Update handled_at timestamp on related notifications when a transfer is handled"""
|
|
89
|
+
from udata.features.notifications.models import Notification
|
|
90
|
+
|
|
91
|
+
# Find all non handled notifications related to this transfer
|
|
92
|
+
notifications = Notification.objects(
|
|
93
|
+
details__transfer_subject=transfer.subject,
|
|
94
|
+
details__transfer_owner=transfer.owner,
|
|
95
|
+
details__transfer_recipient=transfer.recipient,
|
|
96
|
+
handled_at=None,
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
# Update handled_at for all matching notifications
|
|
100
|
+
for notification in notifications:
|
|
101
|
+
notification.handled_at = datetime.utcnow()
|
|
102
|
+
notification.save()
|
|
103
|
+
|
|
104
|
+
|
|
9
105
|
@notifier("transfer_request")
|
|
10
106
|
def transfer_request_notifications(user):
|
|
11
107
|
"""Notify user about pending transfer requests"""
|
|
@@ -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
|
|
udata/frontend/markdown.py
CHANGED
|
@@ -7,8 +7,9 @@ import html2text
|
|
|
7
7
|
import mistune
|
|
8
8
|
from bleach.css_sanitizer import CSSSanitizer
|
|
9
9
|
from bleach.linkifier import LinkifyFilter
|
|
10
|
-
from flask import
|
|
10
|
+
from flask import current_app, request
|
|
11
11
|
from jinja2.filters import do_striptags, do_truncate
|
|
12
|
+
from markupsafe import Markup
|
|
12
13
|
from werkzeug.local import LocalProxy
|
|
13
14
|
|
|
14
15
|
from udata.i18n import _
|
udata/harvest/actions.py
CHANGED
|
@@ -317,3 +317,23 @@ def attach(domain, filename):
|
|
|
317
317
|
count += 1
|
|
318
318
|
|
|
319
319
|
return AttachResult(count, errors)
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def detach(dataset: Dataset):
|
|
323
|
+
"""Detach a dataset from its harvest source
|
|
324
|
+
|
|
325
|
+
The dataset will be cleaned from harvested information
|
|
326
|
+
and will no longer be updated or archived by harvesting.
|
|
327
|
+
"""
|
|
328
|
+
dataset.harvest = None
|
|
329
|
+
for resource in dataset.resources:
|
|
330
|
+
resource.harvest = None
|
|
331
|
+
dataset.save()
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def detach_all_from_source(source: HarvestSource):
|
|
335
|
+
"""Detach all datasets linked to a harvest source"""
|
|
336
|
+
datasets = Dataset.objects.filter(harvest__source_id=str(source.id))
|
|
337
|
+
for dataset in datasets:
|
|
338
|
+
detach(dataset)
|
|
339
|
+
return len(datasets)
|