udata 7.0.4.dev27543__py2.py3-none-any.whl → 7.0.4.dev27593__py2.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/core/dataset/api.py +7 -1
- udata/core/dataset/apiv2.py +9 -5
- udata/core/dataset/models.py +26 -0
- udata/core/discussions/api.py +2 -4
- udata/core/discussions/tasks.py +3 -1
- udata/core/organization/api_fields.py +1 -0
- udata/core/organization/apiv2.py +60 -1
- udata/core/organization/models.py +1 -1
- udata/harvest/backends/base.py +2 -2
- udata/harvest/models.py +1 -1
- udata/harvest/tasks.py +6 -3
- udata/harvest/tests/factories.py +1 -0
- udata/harvest/tests/test_models.py +1 -1
- udata/models/__init__.py +2 -1
- udata/models/extras_fields.py +41 -0
- udata/settings.py +1 -2
- udata/static/chunks/{11.a23c110811a9ac943478.js → 11.c0ccea08914b6b41568e.js} +3 -3
- udata/static/chunks/{11.a23c110811a9ac943478.js.map → 11.c0ccea08914b6b41568e.js.map} +1 -1
- udata/static/chunks/{13.0889e093f8664e38568c.js → 13.526a25163ababaa44409.js} +2 -2
- udata/static/chunks/{13.0889e093f8664e38568c.js.map → 13.526a25163ababaa44409.js.map} +1 -1
- udata/static/chunks/{16.f41599478d3e97ad9a30.js → 16.7901839b4227881947f6.js} +2 -2
- udata/static/chunks/{16.f41599478d3e97ad9a30.js.map → 16.7901839b4227881947f6.js.map} +1 -1
- udata/static/chunks/{19.2b534a26af8b17e9170b.js → 19.471d5a2a08eef6e5338a.js} +3 -3
- udata/static/chunks/{19.2b534a26af8b17e9170b.js.map → 19.471d5a2a08eef6e5338a.js.map} +1 -1
- udata/static/chunks/{5.bd822f0e9689f45bd582.js → 5.98904a7a544eeb258e23.js} +3 -3
- udata/static/chunks/{5.bd822f0e9689f45bd582.js.map → 5.98904a7a544eeb258e23.js.map} +1 -1
- udata/static/chunks/{6.16bb24fb8240f2746488.js → 6.e56975229e6065f68d2a.js} +3 -3
- udata/static/chunks/{6.16bb24fb8240f2746488.js.map → 6.e56975229e6065f68d2a.js.map} +1 -1
- udata/static/chunks/{9.3e752966ff14e47e11f2.js → 9.534426728626f11f4571.js} +2 -2
- udata/static/chunks/{9.3e752966ff14e47e11f2.js.map → 9.534426728626f11f4571.js.map} +1 -1
- udata/static/common.js +1 -1
- udata/static/common.js.map +1 -1
- udata/templates/mail/membership_refused.html +1 -1
- udata/templates/mail/membership_request.html +1 -1
- udata/templates/mail/new_member.html +2 -2
- udata/templates/mail/new_member.txt +1 -1
- udata/tests/apiv2/test_datasets.py +69 -0
- udata/tests/apiv2/test_organizations.py +193 -0
- {udata-7.0.4.dev27543.dist-info → udata-7.0.4.dev27593.dist-info}/METADATA +8 -2
- {udata-7.0.4.dev27543.dist-info → udata-7.0.4.dev27593.dist-info}/RECORD +44 -43
- {udata-7.0.4.dev27543.dist-info → udata-7.0.4.dev27593.dist-info}/LICENSE +0 -0
- {udata-7.0.4.dev27543.dist-info → udata-7.0.4.dev27593.dist-info}/WHEEL +0 -0
- {udata-7.0.4.dev27543.dist-info → udata-7.0.4.dev27593.dist-info}/entry_points.txt +0 -0
- {udata-7.0.4.dev27543.dist-info → udata-7.0.4.dev27593.dist-info}/top_level.txt +0 -0
udata/core/dataset/api.py
CHANGED
|
@@ -19,6 +19,7 @@ These changes might lead to backward compatibility breakage meaning:
|
|
|
19
19
|
|
|
20
20
|
import os
|
|
21
21
|
import logging
|
|
22
|
+
import mongoengine
|
|
22
23
|
from datetime import datetime
|
|
23
24
|
|
|
24
25
|
from bson.objectid import ObjectId
|
|
@@ -229,7 +230,12 @@ class DatasetAPI(API):
|
|
|
229
230
|
DatasetEditPermission(dataset).test()
|
|
230
231
|
dataset.last_modified_internal = datetime.utcnow()
|
|
231
232
|
form = api.validate(DatasetForm, dataset)
|
|
232
|
-
|
|
233
|
+
# As validation for some fields (ie. extras) is at model
|
|
234
|
+
# level instead form level, we use mongoengine errors here.
|
|
235
|
+
try:
|
|
236
|
+
return form.save()
|
|
237
|
+
except mongoengine.errors.ValidationError as e:
|
|
238
|
+
api.abort(400, e.message)
|
|
233
239
|
|
|
234
240
|
@api.secure
|
|
235
241
|
@api.doc('delete_dataset')
|
udata/core/dataset/apiv2.py
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import logging
|
|
2
|
+
import mongoengine
|
|
2
3
|
|
|
3
4
|
from flask import url_for, request, abort
|
|
4
5
|
from flask_restx import marshal
|
|
@@ -235,7 +236,10 @@ class DatasetExtrasAPI(API):
|
|
|
235
236
|
data.pop(key)
|
|
236
237
|
# then update the extras with the remaining payload
|
|
237
238
|
dataset.extras.update(data)
|
|
238
|
-
|
|
239
|
+
try:
|
|
240
|
+
dataset.save(signal_kwargs={'ignores': ['post_save']})
|
|
241
|
+
except mongoengine.errors.ValidationError as e:
|
|
242
|
+
apiv2.abort(400, e.message)
|
|
239
243
|
return dataset.extras
|
|
240
244
|
|
|
241
245
|
@apiv2.secure
|
|
@@ -248,11 +252,11 @@ class DatasetExtrasAPI(API):
|
|
|
248
252
|
if dataset.deleted:
|
|
249
253
|
apiv2.abort(410, 'Dataset has been deleted')
|
|
250
254
|
DatasetEditPermission(dataset).test()
|
|
251
|
-
|
|
252
|
-
|
|
255
|
+
for key in data:
|
|
256
|
+
try:
|
|
253
257
|
del dataset.extras[key]
|
|
254
|
-
|
|
255
|
-
|
|
258
|
+
except KeyError:
|
|
259
|
+
pass
|
|
256
260
|
dataset.save(signal_kwargs={'ignores': ['post_save']})
|
|
257
261
|
return dataset.extras, 204
|
|
258
262
|
|
udata/core/dataset/models.py
CHANGED
|
@@ -11,6 +11,7 @@ from flask import current_app
|
|
|
11
11
|
from mongoengine import DynamicEmbeddedDocument, ValidationError as MongoEngineValidationError
|
|
12
12
|
from mongoengine.signals import pre_save, post_save
|
|
13
13
|
from mongoengine.fields import DateTimeField
|
|
14
|
+
from pydoc import locate
|
|
14
15
|
from stringdist import rdlevenshtein
|
|
15
16
|
from werkzeug.utils import cached_property
|
|
16
17
|
import requests
|
|
@@ -553,6 +554,31 @@ class Dataset(WithMetrics, BadgeMixin, db.Owned, db.Document):
|
|
|
553
554
|
if self.frequency in LEGACY_FREQUENCIES:
|
|
554
555
|
self.frequency = LEGACY_FREQUENCIES[self.frequency]
|
|
555
556
|
|
|
557
|
+
for key, value in self.extras.items():
|
|
558
|
+
if not key.startswith('custom:'):
|
|
559
|
+
continue
|
|
560
|
+
if not self.organization:
|
|
561
|
+
raise MongoEngineValidationError(
|
|
562
|
+
'Custom metadatas are only accessible to dataset owned by on organization.')
|
|
563
|
+
custom_meta = key.split(':')[1]
|
|
564
|
+
org_custom = self.organization.extras.get('custom', [])
|
|
565
|
+
custom_present = False
|
|
566
|
+
for custom in org_custom:
|
|
567
|
+
if custom['title'] != custom_meta:
|
|
568
|
+
continue
|
|
569
|
+
custom_present = True
|
|
570
|
+
if custom['type'] == 'choice':
|
|
571
|
+
if value not in custom['choices']:
|
|
572
|
+
raise MongoEngineValidationError(
|
|
573
|
+
'Custom metadata choice is not defined by organization.')
|
|
574
|
+
else:
|
|
575
|
+
if not isinstance(value, locate(custom['type'])):
|
|
576
|
+
raise MongoEngineValidationError(
|
|
577
|
+
'Custom metadata is not of the right type.')
|
|
578
|
+
if not custom_present:
|
|
579
|
+
raise MongoEngineValidationError(
|
|
580
|
+
'Dataset\'s organization did not define the requested custom metadata.')
|
|
581
|
+
|
|
556
582
|
def url_for(self, *args, **kwargs):
|
|
557
583
|
return endpoint_for('datasets.show', 'api.dataset', dataset=self, *args, **kwargs)
|
|
558
584
|
|
udata/core/discussions/api.py
CHANGED
|
@@ -14,10 +14,8 @@ from udata.core.user.api_fields import user_ref_fields
|
|
|
14
14
|
from .forms import DiscussionCreateForm, DiscussionCommentForm
|
|
15
15
|
from .models import Message, Discussion
|
|
16
16
|
from .permissions import CloseDiscussionPermission
|
|
17
|
-
from .signals import
|
|
18
|
-
|
|
19
|
-
on_discussion_deleted
|
|
20
|
-
)
|
|
17
|
+
from .signals import on_discussion_deleted
|
|
18
|
+
|
|
21
19
|
|
|
22
20
|
ns = api.namespace('discussions', 'Discussion related operations')
|
|
23
21
|
|
udata/core/discussions/tasks.py
CHANGED
|
@@ -16,8 +16,10 @@ log = get_logger(__name__)
|
|
|
16
16
|
def owner_recipients(discussion):
|
|
17
17
|
if getattr(discussion.subject, 'organization', None):
|
|
18
18
|
return [m.user for m in discussion.subject.organization.members]
|
|
19
|
-
|
|
19
|
+
elif getattr(discussion.subject, 'owner', None):
|
|
20
20
|
return [discussion.subject.owner]
|
|
21
|
+
else:
|
|
22
|
+
return []
|
|
21
23
|
|
|
22
24
|
|
|
23
25
|
@connect(on_new_discussion, by_id=True)
|
|
@@ -87,6 +87,7 @@ org_fields = api.model('Organization', {
|
|
|
87
87
|
'badges': fields.List(fields.Nested(badge_fields),
|
|
88
88
|
description='The organization badges',
|
|
89
89
|
readonly=True),
|
|
90
|
+
'extras': fields.Raw(description='Extras attributes as key-value pairs'),
|
|
90
91
|
})
|
|
91
92
|
|
|
92
93
|
org_page_fields = api.model('OrganizationPage', fields.pager(org_fields))
|
udata/core/organization/apiv2.py
CHANGED
|
@@ -1,11 +1,15 @@
|
|
|
1
|
+
import mongoengine
|
|
1
2
|
from flask import request
|
|
2
3
|
|
|
3
4
|
from udata import search
|
|
4
5
|
from udata.api import apiv2, API
|
|
5
6
|
from udata.utils import multi_to_dict
|
|
7
|
+
from udata.core.contact_point.api_fields import contact_point_fields
|
|
6
8
|
from .search import OrganizationSearch
|
|
7
9
|
from .api_fields import org_page_fields, org_fields, member_fields
|
|
8
|
-
from
|
|
10
|
+
from .permissions import (
|
|
11
|
+
EditOrganizationPermission, OrganizationPrivatePermission
|
|
12
|
+
)
|
|
9
13
|
|
|
10
14
|
apiv2.inherit('OrganizationPage', org_page_fields)
|
|
11
15
|
apiv2.inherit('Organization', org_fields)
|
|
@@ -29,3 +33,58 @@ class OrganizationSearchAPI(API):
|
|
|
29
33
|
'''Search all organizations'''
|
|
30
34
|
search_parser.parse_args()
|
|
31
35
|
return search.query(OrganizationSearch, **multi_to_dict(request.args))
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@ns.route('/<org:org>/extras/', endpoint='organization_extras')
|
|
39
|
+
@apiv2.response(400, 'Wrong payload format, dict expected')
|
|
40
|
+
@apiv2.response(400, 'Wrong payload format, list expected')
|
|
41
|
+
@apiv2.response(404, 'Organization not found')
|
|
42
|
+
@apiv2.response(410, 'Organization has been deleted')
|
|
43
|
+
class OrganizationExtrasAPI(API):
|
|
44
|
+
@apiv2.doc('get_organization_extras')
|
|
45
|
+
def get(self, org):
|
|
46
|
+
'''Get an organization extras given its identifier'''
|
|
47
|
+
if org.deleted:
|
|
48
|
+
apiv2.abort(410, 'Organization has been deleted')
|
|
49
|
+
return org.extras
|
|
50
|
+
|
|
51
|
+
@apiv2.secure
|
|
52
|
+
@apiv2.doc('update_organization_extras')
|
|
53
|
+
def put(self, org):
|
|
54
|
+
'''Update a given organization extras'''
|
|
55
|
+
data = request.json
|
|
56
|
+
if not isinstance(data, dict):
|
|
57
|
+
apiv2.abort(400, 'Wrong payload format, dict expected')
|
|
58
|
+
if org.deleted:
|
|
59
|
+
apiv2.abort(410, 'Organization has been deleted')
|
|
60
|
+
EditOrganizationPermission(org).test()
|
|
61
|
+
# first remove extras key associated to a None value in payload
|
|
62
|
+
for key in [k for k in data if data[k] is None]:
|
|
63
|
+
org.extras.pop(key, None)
|
|
64
|
+
data.pop(key)
|
|
65
|
+
|
|
66
|
+
# then update the extras with the remaining payload
|
|
67
|
+
org.extras.update(data)
|
|
68
|
+
try:
|
|
69
|
+
org.save()
|
|
70
|
+
except mongoengine.errors.ValidationError as e:
|
|
71
|
+
apiv2.abort(400, e.message)
|
|
72
|
+
return org.extras
|
|
73
|
+
|
|
74
|
+
@apiv2.secure
|
|
75
|
+
@apiv2.doc('delete_organization_extras')
|
|
76
|
+
def delete(self, org):
|
|
77
|
+
'''Delete a given organization extras key on a given organization'''
|
|
78
|
+
data = request.json
|
|
79
|
+
if not isinstance(data, list):
|
|
80
|
+
apiv2.abort(400, 'Wrong payload format, list expected')
|
|
81
|
+
if org.deleted:
|
|
82
|
+
apiv2.abort(410, 'Organization has been deleted')
|
|
83
|
+
EditOrganizationPermission(org).test()
|
|
84
|
+
for key in data:
|
|
85
|
+
try:
|
|
86
|
+
del org.extras[key]
|
|
87
|
+
except KeyError:
|
|
88
|
+
pass
|
|
89
|
+
org.save()
|
|
90
|
+
return org.extras, 204
|
|
@@ -114,7 +114,7 @@ class Organization(WithMetrics, BadgeMixin, db.Datetimed, db.Document):
|
|
|
114
114
|
|
|
115
115
|
ext = db.MapField(db.GenericEmbeddedDocumentField())
|
|
116
116
|
zone = db.StringField()
|
|
117
|
-
extras = db.
|
|
117
|
+
extras = db.OrganizationExtrasField()
|
|
118
118
|
|
|
119
119
|
deleted = db.DateTimeField()
|
|
120
120
|
|
udata/harvest/backends/base.py
CHANGED
|
@@ -156,7 +156,7 @@ class BaseBackend(object):
|
|
|
156
156
|
self.job.errors.append(error)
|
|
157
157
|
self.job.status = 'failed'
|
|
158
158
|
self.end()
|
|
159
|
-
return
|
|
159
|
+
return None
|
|
160
160
|
except Exception as e:
|
|
161
161
|
self.job.status = 'failed'
|
|
162
162
|
error = HarvestError(message=safe_unicode(e))
|
|
@@ -164,7 +164,7 @@ class BaseBackend(object):
|
|
|
164
164
|
self.end()
|
|
165
165
|
msg = 'Initialization failed for "{0.name}" ({0.backend})'
|
|
166
166
|
log.exception(msg.format(self.source))
|
|
167
|
-
return
|
|
167
|
+
return None
|
|
168
168
|
|
|
169
169
|
if self.max_items:
|
|
170
170
|
self.job.items = self.job.items[:self.max_items]
|
udata/harvest/models.py
CHANGED
|
@@ -94,7 +94,7 @@ class HarvestSource(db.Owned, db.Document):
|
|
|
94
94
|
populate_from='name', update=True)
|
|
95
95
|
description = db.StringField()
|
|
96
96
|
url = db.StringField(required=True)
|
|
97
|
-
backend = db.StringField()
|
|
97
|
+
backend = db.StringField(required=True)
|
|
98
98
|
config = db.DictField()
|
|
99
99
|
periodic_task = db.ReferenceField('PeriodicTask',
|
|
100
100
|
reverse_delete_rule=db.NULLIFY)
|
udata/harvest/tasks.py
CHANGED
|
@@ -19,15 +19,18 @@ def harvest(self, ident):
|
|
|
19
19
|
Backend = backends.get(current_app, source.backend)
|
|
20
20
|
backend = Backend(source)
|
|
21
21
|
items = backend.perform_initialization()
|
|
22
|
-
if items
|
|
22
|
+
if items is None:
|
|
23
|
+
pass
|
|
24
|
+
elif items == 0:
|
|
25
|
+
backend.finalize()
|
|
26
|
+
else:
|
|
23
27
|
finalize = harvest_job_finalize.s(backend.job.id)
|
|
24
28
|
items = [
|
|
25
29
|
harvest_job_item.s(backend.job.id, item.remote_id)
|
|
26
30
|
for item in backend.job.items
|
|
27
31
|
]
|
|
28
32
|
chord(items)(finalize)
|
|
29
|
-
|
|
30
|
-
backend.finalize()
|
|
33
|
+
|
|
31
34
|
|
|
32
35
|
|
|
33
36
|
@task(ignore_result=False, route='low.harvest')
|
udata/harvest/tests/factories.py
CHANGED
|
@@ -12,7 +12,7 @@ log = logging.getLogger(__name__)
|
|
|
12
12
|
@pytest.mark.usefixtures('clean_db')
|
|
13
13
|
class HarvestSourceTest:
|
|
14
14
|
def test_defaults(self):
|
|
15
|
-
source = HarvestSource.objects.create(name='Test', url=faker.url())
|
|
15
|
+
source = HarvestSource.objects.create(name='Test', url=faker.url(), backend='factory')
|
|
16
16
|
assert source.name == 'Test'
|
|
17
17
|
assert source.slug == 'test'
|
|
18
18
|
|
udata/models/__init__.py
CHANGED
|
@@ -17,7 +17,7 @@ from udata.errors import ConfigError
|
|
|
17
17
|
from .badges_field import BadgesField
|
|
18
18
|
from .taglist_field import TagListField
|
|
19
19
|
from .datetime_fields import DateField, DateRange, Datetimed
|
|
20
|
-
from .extras_fields import ExtrasField
|
|
20
|
+
from .extras_fields import ExtrasField, OrganizationExtrasField
|
|
21
21
|
from .slug_fields import SlugField
|
|
22
22
|
from .url_field import URLField
|
|
23
23
|
from .uuid_fields import AutoUUIDField
|
|
@@ -36,6 +36,7 @@ class UDataMongoEngine(MongoEngine):
|
|
|
36
36
|
self.DateField = DateField
|
|
37
37
|
self.Datetimed = Datetimed
|
|
38
38
|
self.ExtrasField = ExtrasField
|
|
39
|
+
self.OrganizationExtrasField = OrganizationExtrasField
|
|
39
40
|
self.SlugField = SlugField
|
|
40
41
|
self.AutoUUIDField = AutoUUIDField
|
|
41
42
|
self.Document = UDataDocument
|
udata/models/extras_fields.py
CHANGED
|
@@ -60,3 +60,44 @@ class ExtrasField(DictField):
|
|
|
60
60
|
if isinstance(value, EmbeddedDocument):
|
|
61
61
|
return value
|
|
62
62
|
return super(ExtrasField, self).to_python(value)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class OrganizationExtrasField(ExtrasField):
|
|
66
|
+
def __init__(self, **kwargs):
|
|
67
|
+
super(OrganizationExtrasField, self).__init__()
|
|
68
|
+
|
|
69
|
+
def validate(self, values):
|
|
70
|
+
super(ExtrasField, self).validate(values)
|
|
71
|
+
|
|
72
|
+
errors = {}
|
|
73
|
+
|
|
74
|
+
mandatory_keys = ["title", "description", "type"]
|
|
75
|
+
optional_keys = ["choices"]
|
|
76
|
+
valid_types = ["str", "int", "float", "bool", "datetime", "date", "choice"]
|
|
77
|
+
|
|
78
|
+
for elem in values.get('custom', []):
|
|
79
|
+
# Check if all mandatory keys are in the dictionary
|
|
80
|
+
if not all(key in elem for key in mandatory_keys):
|
|
81
|
+
errors['custom'] = 'The dictionary does not contain the mandatory keys: \'title\', \'description\', \'type\'.'
|
|
82
|
+
|
|
83
|
+
# Check if the dictionary contains only keys that are either mandatory or optional
|
|
84
|
+
if not all(key in mandatory_keys + optional_keys for key in elem):
|
|
85
|
+
errors['custom'] = 'The dictionary does contains extra keys than allowed ones.'
|
|
86
|
+
|
|
87
|
+
# Check if the "type" value is one of the valid types
|
|
88
|
+
if elem.get("type") not in valid_types:
|
|
89
|
+
errors['type'] = ('Type \'{type}\' of \'{title}\' should be one of: {types}'
|
|
90
|
+
.format(type=elem.get("type"), title=elem.get("title"), types=valid_types))
|
|
91
|
+
|
|
92
|
+
# Check if the "choices" key is present only if the type is "choice" and it's not an empty list
|
|
93
|
+
is_choices_valid = True
|
|
94
|
+
if elem.get("type") == "choice":
|
|
95
|
+
is_choices_valid = "choices" in elem and isinstance(elem["choices"], list) and len(
|
|
96
|
+
elem["choices"]) > 0
|
|
97
|
+
elif "choices" in elem:
|
|
98
|
+
is_choices_valid = False
|
|
99
|
+
if not is_choices_valid:
|
|
100
|
+
errors['choices'] = 'The \'choices\' key must be an non empty list and can only be present when type \'choice\' is selected.'
|
|
101
|
+
|
|
102
|
+
if errors:
|
|
103
|
+
self.error('Custom extras error', errors=errors)
|
udata/settings.py
CHANGED
|
@@ -470,8 +470,7 @@ class Testing(object):
|
|
|
470
470
|
WTF_CSRF_ENABLED = False
|
|
471
471
|
AUTO_INDEX = False
|
|
472
472
|
CELERY_TASK_ALWAYS_EAGER = True
|
|
473
|
-
|
|
474
|
-
CELERY_TASK_EAGER_PROPAGATES = False
|
|
473
|
+
CELERY_TASK_EAGER_PROPAGATES = True
|
|
475
474
|
TEST_WITH_PLUGINS = False
|
|
476
475
|
PLUGINS = []
|
|
477
476
|
TEST_WITH_THEME = False
|