udata 14.5.1.dev9__py3-none-any.whl → 14.6.1.dev5__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 (32) hide show
  1. udata/api_fields.py +27 -4
  2. udata/core/badges/tests/test_tasks.py +0 -2
  3. udata/core/dataservices/apiv2.py +1 -1
  4. udata/core/dataset/models.py +15 -3
  5. udata/core/dataset/rdf.py +10 -14
  6. udata/core/organization/apiv2.py +1 -1
  7. udata/core/organization/models.py +25 -5
  8. udata/core/post/models.py +2 -0
  9. udata/core/post/tests/test_api.py +5 -1
  10. udata/core/reuse/apiv2.py +1 -1
  11. udata/core/user/models.py +21 -6
  12. udata/features/notifications/models.py +4 -1
  13. udata/features/transfer/models.py +16 -0
  14. udata/features/transfer/notifications.py +74 -0
  15. udata/harvest/backends/ckan/harvesters.py +10 -2
  16. udata/migrations/2021-08-17-harvest-integrity.py +23 -16
  17. udata/migrations/2025-12-16-create-transfer-request-notifications.py +66 -0
  18. udata/tasks.py +1 -0
  19. udata/tests/apiv2/test_dataservices.py +14 -0
  20. udata/tests/apiv2/test_organizations.py +9 -0
  21. udata/tests/apiv2/test_reuses.py +11 -0
  22. udata/tests/dataset/test_dataset_rdf.py +49 -0
  23. udata/tests/search/test_search_integration.py +37 -0
  24. udata/tests/test_transfer.py +104 -1
  25. udata/utils.py +23 -0
  26. {udata-14.5.1.dev9.dist-info → udata-14.6.1.dev5.dist-info}/METADATA +2 -2
  27. {udata-14.5.1.dev9.dist-info → udata-14.6.1.dev5.dist-info}/RECORD +31 -29
  28. udata/tests/apiv2/test_search.py +0 -30
  29. {udata-14.5.1.dev9.dist-info → udata-14.6.1.dev5.dist-info}/WHEEL +0 -0
  30. {udata-14.5.1.dev9.dist-info → udata-14.6.1.dev5.dist-info}/entry_points.txt +0 -0
  31. {udata-14.5.1.dev9.dist-info → udata-14.6.1.dev5.dist-info}/licenses/LICENSE +0 -0
  32. {udata-14.5.1.dev9.dist-info → udata-14.6.1.dev5.dist-info}/top_level.txt +0 -0
udata/api_fields.py CHANGED
@@ -79,13 +79,15 @@ def convert_db_to_field(key, field, info) -> tuple[Callable | None, Callable | N
79
79
  user-supplied overrides, setting the readonly flag…), it's easier to have to do this only once at the end of the function.
80
80
 
81
81
  """
82
+ from udata.mongo.engine import db
83
+
82
84
  params: dict = {}
83
85
  params["required"] = field.required
84
86
 
85
87
  read_params: dict = {}
86
88
  write_params: dict = {}
87
89
 
88
- constructor: Callable
90
+ constructor: Callable | None = None
89
91
  constructor_read: Callable | None = None
90
92
  constructor_write: Callable | None = None
91
93
 
@@ -204,13 +206,34 @@ def convert_db_to_field(key, field, info) -> tuple[Callable | None, Callable | N
204
206
  def constructor_write(**kwargs):
205
207
  return restx_fields.List(field_write, **kwargs)
206
208
 
207
- elif isinstance(
208
- field, (mongo_fields.GenericReferenceField, mongoengine.fields.GenericLazyReferenceField)
209
- ):
209
+ elif isinstance(field, mongoengine.fields.GenericLazyReferenceField):
210
210
 
211
211
  def constructor(**kwargs):
212
212
  return restx_fields.Nested(lazy_reference, **kwargs)
213
213
 
214
+ elif isinstance(field, mongo_fields.GenericReferenceField):
215
+ if field.choices:
216
+ generic_fields = {}
217
+ for cls in field.choices:
218
+ cls = db.resolve_model(cls) if isinstance(cls, str) else cls
219
+ generic_fields[cls.__name__] = convert_db_to_field(
220
+ f"{key}.{cls.__name__}",
221
+ # Instead of having GenericReferenceField() we'll create fields for each
222
+ # of the subclasses with ReferenceField(Organization)…
223
+ mongoengine.fields.ReferenceField(cls),
224
+ info,
225
+ )
226
+
227
+ def constructor_read(**kwargs):
228
+ return GenericField({k: v[0].model for k, v in generic_fields.items()}, **kwargs)
229
+
230
+ def constructor_write(**kwargs):
231
+ return GenericField({k: v[1].model for k, v in generic_fields.items()}, **kwargs)
232
+ else:
233
+
234
+ def constructor(**kwargs):
235
+ return restx_fields.Nested(lazy_reference, **kwargs)
236
+
214
237
  elif isinstance(field, mongo_fields.ReferenceField | mongo_fields.LazyReferenceField):
215
238
  # For reference we accept while writing a String representing the ID of the referenced model.
216
239
  # For reading, if the user supplied a `nested_fields` (RestX model), we use it to convert
@@ -1,5 +1,3 @@
1
- import udata.core.dataservices.tasks # noqa
2
- import udata.core.dataset.tasks # noqa
3
1
  from udata.core.badges.tasks import update_badges
4
2
  from udata.core.constants import HVD
5
3
  from udata.core.dataservices.factories import DataserviceFactory
@@ -15,7 +15,7 @@ apiv2.inherit("AccessAudience (read)", AccessAudience.__read_fields__)
15
15
 
16
16
  ns = apiv2.namespace("dataservices", "Dataservice related operations")
17
17
 
18
- search_parser = DataserviceSearch.as_request_parser()
18
+ search_parser = DataserviceSearch.as_request_parser(store_missing=False)
19
19
 
20
20
 
21
21
  @ns.route("/search/", endpoint="dataservice_search")
@@ -15,17 +15,19 @@ from mongoengine.fields import DateTimeField
15
15
  from mongoengine.signals import post_save, pre_init, pre_save
16
16
  from werkzeug.utils import cached_property
17
17
 
18
- from udata.api_fields import field
18
+ from udata.api_fields import field, generate_fields
19
19
  from udata.app import cache
20
20
  from udata.core import storages
21
21
  from udata.core.access_type.constants import AccessType
22
22
  from udata.core.access_type.models import WithAccessType, check_only_one_condition_per_role
23
23
  from udata.core.activity.models import Auditable
24
24
  from udata.core.constants import HVD
25
+ from udata.core.dataset.api_fields import temporal_coverage_fields
25
26
  from udata.core.dataset.preview import TabularAPIPreview
26
27
  from udata.core.linkable import Linkable
27
28
  from udata.core.metrics.helpers import get_stock_metrics
28
29
  from udata.core.owned import Owned, OwnedQuerySet
30
+ from udata.core.spatial.api_fields import spatial_coverage_fields
29
31
  from udata.frontend.markdown import mdstrip
30
32
  from udata.i18n import lazy_gettext as _
31
33
  from udata.models import Badge, BadgeMixin, BadgesList, SpatialCoverage, WithMetrics, db
@@ -89,6 +91,7 @@ def get_json_ld_extra(key, value):
89
91
  }
90
92
 
91
93
 
94
+ @generate_fields()
92
95
  class HarvestDatasetMetadata(db.EmbeddedDocument):
93
96
  backend = db.StringField()
94
97
  created_at = db.DateTimeField()
@@ -114,6 +117,7 @@ class HarvestResourceMetadata(db.EmbeddedDocument):
114
117
  dct_identifier = db.StringField()
115
118
 
116
119
 
120
+ @generate_fields()
117
121
  class Schema(db.EmbeddedDocument):
118
122
  """
119
123
  Schema can only be two things right now:
@@ -482,6 +486,7 @@ class ResourceMixin(object):
482
486
  return result
483
487
 
484
488
 
489
+ @generate_fields()
485
490
  class Resource(ResourceMixin, WithMetrics, db.EmbeddedDocument):
486
491
  """
487
492
  Local file, remote file or API provided by the original provider of the
@@ -533,6 +538,7 @@ class DatasetBadgeMixin(BadgeMixin):
533
538
  __badges__ = BADGES
534
539
 
535
540
 
541
+ @generate_fields()
536
542
  class Dataset(
537
543
  Auditable, WithMetrics, WithAccessType, DatasetBadgeMixin, Owned, Linkable, db.Document
538
544
  ):
@@ -560,8 +566,14 @@ class Dataset(
560
566
 
561
567
  frequency = field(db.EnumField(UpdateFrequency))
562
568
  frequency_date = field(db.DateTimeField(verbose_name=_("Future date of update")))
563
- temporal_coverage = field(db.EmbeddedDocumentField(db.DateRange))
564
- spatial = field(db.EmbeddedDocumentField(SpatialCoverage))
569
+ temporal_coverage = field(
570
+ db.EmbeddedDocumentField(db.DateRange),
571
+ nested_fields=temporal_coverage_fields,
572
+ )
573
+ spatial = field(
574
+ db.EmbeddedDocumentField(SpatialCoverage),
575
+ nested_fields=spatial_coverage_fields,
576
+ )
565
577
  schema = field(db.EmbeddedDocumentField(Schema))
566
578
 
567
579
  ext = field(db.MapField(db.GenericEmbeddedDocumentField()), auditable=False)
udata/core/dataset/rdf.py CHANGED
@@ -5,7 +5,7 @@ This module centralize dataset helpers for RDF/DCAT serialization and parsing
5
5
  import calendar
6
6
  import json
7
7
  import logging
8
- from datetime import date, datetime
8
+ from datetime import date
9
9
 
10
10
  from dateutil.parser import parse as parse_dt
11
11
  from flask import current_app
@@ -51,7 +51,7 @@ from udata.rdf import (
51
51
  themes_from_rdf,
52
52
  url_from_rdf,
53
53
  )
54
- from udata.utils import get_by, safe_unicode, to_naive_datetime
54
+ from udata.utils import get_by, safe_harvest_datetime, safe_unicode
55
55
 
56
56
  from .constants import OGC_SERVICE_FORMATS, UpdateFrequency
57
57
  from .models import Checksum, Dataset, License, Resource
@@ -729,12 +729,10 @@ def resource_from_rdf(graph_or_distrib, dataset=None, is_additionnal=False):
729
729
  resource.harvest = HarvestResourceMetadata()
730
730
  resource.harvest.issued_at = issued_at
731
731
 
732
- # In the past, we've encountered future `modified_at` during harvesting
733
- # do not save it. :FutureHarvestModifiedAt
734
- if modified_at and to_naive_datetime(modified_at) > datetime.utcnow():
735
- log.warning(f"Future `DCT.modified` date '{modified_at}' in resource")
736
- else:
737
- resource.harvest.modified_at = modified_at
732
+ # :FutureHarvestModifiedAt
733
+ resource.harvest.modified_at = safe_harvest_datetime(
734
+ modified_at, "DCT.modified (resource)", refuse_future=True
735
+ )
738
736
 
739
737
  resource.harvest.dct_identifier = identifier
740
738
  resource.harvest.uri = uri
@@ -845,12 +843,10 @@ def dataset_from_rdf(
845
843
  dataset.harvest.created_at = created_at
846
844
  dataset.harvest.issued_at = issued_at
847
845
 
848
- # In the past, we've encountered future `modified_at` during harvesting
849
- # do not save it. :FutureHarvestModifiedAt
850
- if modified_at and to_naive_datetime(modified_at) > datetime.utcnow():
851
- log.warning(f"Future `DCT.modified` date '{modified_at}' in dataset")
852
- else:
853
- dataset.harvest.modified_at = modified_at
846
+ # :FutureHarvestModifiedAt
847
+ dataset.harvest.modified_at = safe_harvest_datetime(
848
+ modified_at, "DCT.modified (dataset)", refuse_future=True
849
+ )
854
850
 
855
851
  return dataset
856
852
 
@@ -15,7 +15,7 @@ apiv2.inherit("ContactPoint", contact_point_fields)
15
15
 
16
16
 
17
17
  ns = apiv2.namespace("organizations", "Organization related operations")
18
- search_parser = OrganizationSearch.as_request_parser()
18
+ search_parser = OrganizationSearch.as_request_parser(store_missing=False)
19
19
 
20
20
  DEFAULT_SORTING = "-created_at"
21
21
 
@@ -7,7 +7,7 @@ from flask_babel import LazyString
7
7
  from mongoengine.signals import post_save, pre_save
8
8
  from werkzeug.utils import cached_property
9
9
 
10
- from udata.api_fields import field
10
+ from udata.api_fields import field, generate_fields
11
11
  from udata.core.activity.models import Auditable
12
12
  from udata.core.badges.models import Badge, BadgeMixin, BadgesList
13
13
  from udata.core.linkable import Linkable
@@ -21,6 +21,7 @@ from udata.uris import cdata_url
21
21
 
22
22
  from .constants import (
23
23
  ASSOCIATION,
24
+ BIGGEST_LOGO_SIZE,
24
25
  CERTIFIED,
25
26
  COMPANY,
26
27
  DEFAULT_ROLE,
@@ -44,6 +45,7 @@ BADGES: dict[str, LazyString] = {
44
45
  }
45
46
 
46
47
 
48
+ @generate_fields()
47
49
  class Team(db.EmbeddedDocument):
48
50
  name = db.StringField(required=True)
49
51
  slug = db.SlugField(
@@ -54,6 +56,7 @@ class Team(db.EmbeddedDocument):
54
56
  members = db.ListField(db.ReferenceField("User"))
55
57
 
56
58
 
59
+ @generate_fields()
57
60
  class Member(db.EmbeddedDocument):
58
61
  user = db.ReferenceField("User")
59
62
  role = db.StringField(choices=list(ORG_ROLES), default=DEFAULT_ROLE)
@@ -64,6 +67,7 @@ class Member(db.EmbeddedDocument):
64
67
  return ORG_ROLES[self.role]
65
68
 
66
69
 
70
+ @generate_fields()
67
71
  class MembershipRequest(db.EmbeddedDocument):
68
72
  """
69
73
  Pending organization membership requests
@@ -113,18 +117,22 @@ class OrganizationBadge(Badge):
113
117
 
114
118
 
115
119
  class OrganizationBadgeMixin(BadgeMixin):
116
- badges = field(BadgesList(OrganizationBadge), **BadgeMixin.default_badges_list_params)
120
+ badges = field(
121
+ BadgesList(OrganizationBadge), show_as_ref=True, **BadgeMixin.default_badges_list_params
122
+ )
117
123
  __badges__ = BADGES
118
124
 
119
125
 
126
+ @generate_fields()
120
127
  class Organization(
121
128
  Auditable, WithMetrics, OrganizationBadgeMixin, Linkable, db.Datetimed, db.Document
122
129
  ):
123
- name = field(db.StringField(required=True))
124
- acronym = field(db.StringField(max_length=128))
130
+ name = field(db.StringField(required=True), show_as_ref=True)
131
+ acronym = field(db.StringField(max_length=128), show_as_ref=True)
125
132
  slug = field(
126
133
  db.SlugField(max_length=255, required=True, populate_from="name", update=True, follow=True),
127
134
  auditable=False,
135
+ show_as_ref=True,
128
136
  )
129
137
  description = field(
130
138
  db.StringField(required=True),
@@ -138,7 +146,11 @@ class Organization(
138
146
  basename=default_image_basename,
139
147
  max_size=LOGO_MAX_SIZE,
140
148
  thumbnails=LOGO_SIZES,
141
- )
149
+ ),
150
+ show_as_ref=True,
151
+ thumbnail_info={
152
+ "size": BIGGEST_LOGO_SIZE,
153
+ },
142
154
  )
143
155
  business_number_id = field(db.StringField(max_length=ORG_BID_SIZE_LIMIT))
144
156
 
@@ -265,6 +277,14 @@ class Organization(
265
277
  return request
266
278
  return None
267
279
 
280
+ @field(description="Link to the API endpoint for this organization", show_as_ref=True)
281
+ def uri(self, *args, **kwargs):
282
+ return self.self_api_url(*args, **kwargs)
283
+
284
+ @field(description="Link to the udata web page for this organization", show_as_ref=True)
285
+ def page(self, *args, **kwargs):
286
+ return self.self_web_url(*args, **kwargs)
287
+
268
288
  @classmethod
269
289
  def get(cls, id_or_slug):
270
290
  obj = cls.objects(slug=id_or_slug).first()
udata/core/post/models.py CHANGED
@@ -4,6 +4,7 @@ from udata.api_fields import field, generate_fields
4
4
  from udata.core.dataset.api_fields import dataset_fields
5
5
  from udata.core.linkable import Linkable
6
6
  from udata.core.storages import default_image_basename, images
7
+ from udata.core.user.api_fields import user_ref_fields
7
8
  from udata.i18n import lazy_gettext as _
8
9
  from udata.mongo import db
9
10
  from udata.uris import cdata_url
@@ -82,6 +83,7 @@ class Post(db.Datetimed, Linkable, db.Document):
82
83
 
83
84
  owner = field(
84
85
  db.ReferenceField("User"),
86
+ nested_fields=user_ref_fields,
85
87
  readonly=True,
86
88
  allow_null=True,
87
89
  description="The owner user",
@@ -56,9 +56,13 @@ class PostsAPITest(APITestCase):
56
56
 
57
57
  def test_post_api_get(self):
58
58
  """It should fetch a post from the API"""
59
- post = PostFactory()
59
+ admin = AdminFactory()
60
+ post = PostFactory(owner=admin)
60
61
  response = self.get(url_for("api.post", post=post))
61
62
  assert200(response)
63
+ owner = response.json["owner"]
64
+ assert isinstance(owner, dict)
65
+ assert owner["id"] == str(admin.id)
62
66
 
63
67
  def test_post_api_create(self):
64
68
  """It should create a post from the API"""
udata/core/reuse/apiv2.py CHANGED
@@ -11,7 +11,7 @@ apiv2.inherit("Reuse (read)", Reuse.__read_fields__)
11
11
 
12
12
  ns = apiv2.namespace("reuses", "Reuse related operations")
13
13
 
14
- search_parser = ReuseSearch.as_request_parser()
14
+ search_parser = ReuseSearch.as_request_parser(store_missing=False)
15
15
 
16
16
  DEFAULT_SORTING = "-created_at"
17
17
 
udata/core/user/models.py CHANGED
@@ -12,7 +12,7 @@ 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
@@ -23,7 +23,7 @@ from udata.models import Follow, WithMetrics, db
23
23
  from udata.uris import cdata_url
24
24
 
25
25
  from . import mails
26
- from .constants import AVATAR_SIZES
26
+ from .constants import AVATAR_SIZES, BIGGEST_AVATAR_SIZE
27
27
 
28
28
  __all__ = ("User", "Role", "datastore")
29
29
 
@@ -45,9 +45,12 @@ class UserSettings(db.EmbeddedDocument):
45
45
  prefered_language = db.StringField()
46
46
 
47
47
 
48
+ @generate_fields()
48
49
  class User(WithMetrics, UserMixin, Linkable, db.Document):
49
50
  slug = field(
50
- 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,
51
54
  )
52
55
  email = field(db.StringField(max_length=255, required=True, unique=True))
53
56
  password = field(db.StringField())
@@ -55,12 +58,16 @@ class User(WithMetrics, UserMixin, Linkable, db.Document):
55
58
  fs_uniquifier = field(db.StringField(max_length=64, unique=True, sparse=True))
56
59
  roles = field(db.ListField(db.ReferenceField(Role), default=[]))
57
60
 
58
- first_name = field(db.StringField(max_length=255, required=True))
59
- 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)
60
63
 
61
64
  avatar_url = field(db.URLField())
62
65
  avatar = field(
63
- 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
+ },
64
71
  )
65
72
  website = field(db.URLField())
66
73
  about = field(
@@ -199,6 +206,14 @@ class User(WithMetrics, UserMixin, Linkable, db.Document):
199
206
  """Return the number of followers of the user."""
200
207
  return self.metrics.get("followers", 0)
201
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
+
202
217
  def generate_api_key(self):
203
218
  payload = {
204
219
  "user": str(self.id),
@@ -5,6 +5,7 @@ from udata.api_fields import field, generate_fields
5
5
  from udata.core.organization.notifications import MembershipRequestNotificationDetails
6
6
  from udata.core.user.api_fields import user_ref_fields
7
7
  from udata.core.user.models import User
8
+ from udata.features.transfer.notifications import TransferRequestNotificationDetails
8
9
  from udata.models import db
9
10
  from udata.mongo.datetime_fields import Datetimed
10
11
  from udata.mongo.queryset import UDataQuerySet
@@ -51,6 +52,8 @@ class Notification(Datetimed, db.Document):
51
52
  filterable={},
52
53
  )
53
54
  details = field(
54
- db.GenericEmbeddedDocumentField(choices=(MembershipRequestNotificationDetails,)),
55
+ db.GenericEmbeddedDocumentField(
56
+ choices=(MembershipRequestNotificationDetails, TransferRequestNotificationDetails)
57
+ ),
55
58
  generic=True,
56
59
  )
@@ -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,8 @@ class Transfer(db.Document):
30
33
  responder = db.ReferenceField("User")
31
34
  response_comment = db.StringField()
32
35
 
36
+ on_create = Signal()
37
+
33
38
  meta = {
34
39
  "indexes": [
35
40
  "owner",
@@ -38,3 +43,14 @@ class Transfer(db.Document):
38
43
  "status",
39
44
  ]
40
45
  }
46
+
47
+ @classmethod
48
+ def post_save(cls, sender, document, **kwargs):
49
+ """Handle post save signal for Transfer documents."""
50
+ # Only trigger on_create signal on creation, not on every save
51
+ if kwargs.get("created"):
52
+ cls.on_create.send(document)
53
+
54
+
55
+ # Connect the post_save signal
56
+ post_save.connect(Transfer.post_save, sender=Transfer)
@@ -1,11 +1,85 @@
1
1
  import logging
2
2
 
3
+ from udata.api_fields import field, generate_fields
4
+ from udata.core.dataservices.models import Dataservice
5
+ from udata.core.dataset.models import Dataset
6
+ from udata.core.organization.models import Organization
7
+ from udata.core.reuse.models import Reuse
8
+ from udata.core.user.models import User
3
9
  from udata.features.notifications.actions import notifier
4
10
  from udata.models import Transfer
11
+ from udata.mongo import db
5
12
 
6
13
  log = logging.getLogger(__name__)
7
14
 
8
15
 
16
+ @generate_fields()
17
+ class TransferRequestNotificationDetails(db.EmbeddedDocument):
18
+ transfer_owner = field(
19
+ db.GenericReferenceField(choices=(User, Organization), required=True),
20
+ readonly=True,
21
+ auditable=False,
22
+ allow_null=True,
23
+ filterable={},
24
+ )
25
+ transfer_recipient = field(
26
+ db.GenericReferenceField(choices=(User, Organization), required=True),
27
+ readonly=True,
28
+ auditable=False,
29
+ allow_null=True,
30
+ filterable={},
31
+ )
32
+ transfer_subject = field(
33
+ db.GenericReferenceField(choices=(Dataset, Dataservice, Reuse), required=True),
34
+ readonly=True,
35
+ auditable=False,
36
+ allow_null=True,
37
+ filterable={},
38
+ )
39
+
40
+
41
+ @Transfer.on_create.connect
42
+ def on_transfer_created(transfer, **kwargs):
43
+ """Create notification when a new transfer request is created"""
44
+
45
+ from udata.features.notifications.models import Notification
46
+
47
+ recipient = transfer.recipient
48
+ owner = transfer.owner
49
+ users = []
50
+
51
+ if isinstance(recipient, User):
52
+ users = [recipient]
53
+ elif isinstance(recipient, Organization):
54
+ users = [member.user for member in recipient.members if member.role == "admin"]
55
+
56
+ for user in users:
57
+ try:
58
+ existing = Notification.objects(
59
+ user=user,
60
+ details__transfer_recipient=recipient,
61
+ details__transfer_owner=owner,
62
+ details__transfer_subject=transfer.subject,
63
+ ).first()
64
+
65
+ if not existing:
66
+ notification = Notification(
67
+ user=user,
68
+ details=TransferRequestNotificationDetails(
69
+ transfer_owner=owner,
70
+ transfer_recipient=recipient,
71
+ transfer_subject=transfer.subject,
72
+ ),
73
+ )
74
+ notification.created_at = transfer.created
75
+ notification.save()
76
+ except Exception as e:
77
+ log.error(
78
+ f"Error creating notification for admin user {user.id} "
79
+ f"and recipient {recipient.id}: {e}"
80
+ )
81
+
82
+
9
83
  @notifier("transfer_request")
10
84
  def transfer_request_notifications(user):
11
85
  """Notify user about pending transfer requests"""
@@ -3,6 +3,8 @@ import logging
3
3
  from urllib.parse import urljoin
4
4
  from uuid import UUID
5
5
 
6
+ from dateutil.parser import ParserError
7
+
6
8
  from udata import uris
7
9
  from udata.core.dataset.constants import UpdateFrequency
8
10
  from udata.core.dataset.models import HarvestDatasetMetadata, HarvestResourceMetadata
@@ -202,10 +204,16 @@ class CkanBackend(BaseBackend):
202
204
  log.debug("frequency value not handled: %s", value)
203
205
  # Temporal coverage start
204
206
  elif key == "temporal_start":
205
- temporal_start = daterange_start(value)
207
+ try:
208
+ temporal_start = daterange_start(value)
209
+ except ParserError:
210
+ log.warning(f"Unparseable temporal_start value: '{value}'")
206
211
  # Temporal coverage end
207
212
  elif key == "temporal_end":
208
- temporal_end = daterange_end(value)
213
+ try:
214
+ temporal_end = daterange_end(value)
215
+ except ParserError:
216
+ log.warning(f"Unparseable temporal_end value: '{value}'")
209
217
  else:
210
218
  dataset.extras[extra["key"]] = value
211
219
 
@@ -5,6 +5,7 @@ Remove Harvest db integrity problems
5
5
 
6
6
  import logging
7
7
 
8
+ import click
8
9
  import mongoengine
9
10
 
10
11
  from udata.core.jobs.models import PeriodicTask
@@ -16,29 +17,35 @@ log = logging.getLogger(__name__)
16
17
  def migrate(db):
17
18
  log.info("Processing HarvestJob source references.")
18
19
 
19
- harvest_jobs = HarvestJob.objects().no_cache().all()
20
+ harvest_jobs = HarvestJob.objects().no_cache()
21
+ total = harvest_jobs.count()
20
22
  count = 0
21
- for harvest_job in harvest_jobs:
22
- try:
23
- harvest_job.source.id
24
- except mongoengine.errors.DoesNotExist:
25
- count += 1
26
- harvest_job.delete()
23
+ with click.progressbar(harvest_jobs, length=total, label="Checking sources refs") as jobs:
24
+ for harvest_job in jobs:
25
+ try:
26
+ if harvest_job.source is None:
27
+ raise mongoengine.errors.DoesNotExist()
28
+ harvest_job.source.id
29
+ except mongoengine.errors.DoesNotExist:
30
+ count += 1
31
+ harvest_job.delete()
27
32
 
28
33
  log.info(f"Completed, removed {count} HarvestJob objects")
29
34
 
30
35
  log.info("Processing HarvestJob items references.")
31
36
 
32
- harvest_jobs = HarvestJob.objects.filter(items__0__exists=True).no_cache().all()
37
+ harvest_jobs = HarvestJob.objects.filter(items__0__exists=True).no_cache()
38
+ total = harvest_jobs.count()
33
39
  count = 0
34
- for harvest_job in harvest_jobs:
35
- for item in harvest_job.items:
36
- try:
37
- item.dataset and item.dataset.id
38
- except mongoengine.errors.DoesNotExist:
39
- count += 1
40
- item.dataset = None
41
- harvest_job.save()
40
+ with click.progressbar(harvest_jobs, length=total, label="Checking items refs") as jobs:
41
+ for harvest_job in jobs:
42
+ for item in harvest_job.items:
43
+ try:
44
+ item.dataset and item.dataset.id
45
+ except mongoengine.errors.DoesNotExist:
46
+ count += 1
47
+ item.dataset = None
48
+ harvest_job.save()
42
49
 
43
50
  log.info(f"Completed, modified {count} HarvestJob objects")
44
51