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.
- udata/api_fields.py +27 -4
- udata/core/badges/tests/test_tasks.py +0 -2
- udata/core/dataservices/apiv2.py +1 -1
- udata/core/dataset/models.py +15 -3
- udata/core/dataset/rdf.py +10 -14
- udata/core/organization/apiv2.py +1 -1
- udata/core/organization/models.py +25 -5
- udata/core/post/models.py +2 -0
- udata/core/post/tests/test_api.py +5 -1
- udata/core/reuse/apiv2.py +1 -1
- udata/core/user/models.py +21 -6
- udata/features/notifications/models.py +4 -1
- udata/features/transfer/models.py +16 -0
- udata/features/transfer/notifications.py +74 -0
- udata/harvest/backends/ckan/harvesters.py +10 -2
- udata/migrations/2021-08-17-harvest-integrity.py +23 -16
- udata/migrations/2025-12-16-create-transfer-request-notifications.py +66 -0
- udata/tasks.py +1 -0
- 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/dataset/test_dataset_rdf.py +49 -0
- udata/tests/search/test_search_integration.py +37 -0
- udata/tests/test_transfer.py +104 -1
- udata/utils.py +23 -0
- {udata-14.5.1.dev9.dist-info → udata-14.6.1.dev5.dist-info}/METADATA +2 -2
- {udata-14.5.1.dev9.dist-info → udata-14.6.1.dev5.dist-info}/RECORD +31 -29
- udata/tests/apiv2/test_search.py +0 -30
- {udata-14.5.1.dev9.dist-info → udata-14.6.1.dev5.dist-info}/WHEEL +0 -0
- {udata-14.5.1.dev9.dist-info → udata-14.6.1.dev5.dist-info}/entry_points.txt +0 -0
- {udata-14.5.1.dev9.dist-info → udata-14.6.1.dev5.dist-info}/licenses/LICENSE +0 -0
- {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
|
udata/core/dataservices/apiv2.py
CHANGED
|
@@ -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")
|
udata/core/dataset/models.py
CHANGED
|
@@ -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(
|
|
564
|
-
|
|
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
|
|
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,
|
|
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
|
-
#
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
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
|
-
#
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
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
|
|
udata/core/organization/apiv2.py
CHANGED
|
@@ -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(
|
|
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
|
-
|
|
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"),
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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()
|
|
20
|
+
harvest_jobs = HarvestJob.objects().no_cache()
|
|
21
|
+
total = harvest_jobs.count()
|
|
20
22
|
count = 0
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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()
|
|
37
|
+
harvest_jobs = HarvestJob.objects.filter(items__0__exists=True).no_cache()
|
|
38
|
+
total = harvest_jobs.count()
|
|
33
39
|
count = 0
|
|
34
|
-
|
|
35
|
-
for
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
|