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/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, **multi_to_dict(request.args))
28
+ args = search_parser.parse_args()
29
+ return search.query(ReuseSearch, **args)
@@ -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)}/", **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(
@@ -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.exception("Unable to parse GeoJSON")
68
- raise ValueError(self.gettext("Not a valid GeoJSON"))
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:
@@ -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(db.StringField(required=False))
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(db.StringField())
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="Do not send a mail to notify the user of the deletion",
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
- user.mark_as_deleted(notify=not args["no_mail"], delete_comments=args["delete_comments"])
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
 
@@ -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", required=True),
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", required=True),
39
- "slug": fields.String(description="The user permalink string", required=True),
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"), auditable=False
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(db.StringField())
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)}/", **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, 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,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
 
@@ -6,7 +6,7 @@ from mongoengine.queryset import QuerySet
6
6
 
7
7
  class Pagination(object):
8
8
  def __init__(self, iterable, page, per_page):
9
- if page < 1:
9
+ if page < 1 or per_page < 1:
10
10
  abort(404)
11
11
 
12
12
  self.iterable = iterable
@@ -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 Markup, current_app, request
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)