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.

Files changed (44) hide show
  1. udata/core/dataset/api.py +7 -1
  2. udata/core/dataset/apiv2.py +9 -5
  3. udata/core/dataset/models.py +26 -0
  4. udata/core/discussions/api.py +2 -4
  5. udata/core/discussions/tasks.py +3 -1
  6. udata/core/organization/api_fields.py +1 -0
  7. udata/core/organization/apiv2.py +60 -1
  8. udata/core/organization/models.py +1 -1
  9. udata/harvest/backends/base.py +2 -2
  10. udata/harvest/models.py +1 -1
  11. udata/harvest/tasks.py +6 -3
  12. udata/harvest/tests/factories.py +1 -0
  13. udata/harvest/tests/test_models.py +1 -1
  14. udata/models/__init__.py +2 -1
  15. udata/models/extras_fields.py +41 -0
  16. udata/settings.py +1 -2
  17. udata/static/chunks/{11.a23c110811a9ac943478.js → 11.c0ccea08914b6b41568e.js} +3 -3
  18. udata/static/chunks/{11.a23c110811a9ac943478.js.map → 11.c0ccea08914b6b41568e.js.map} +1 -1
  19. udata/static/chunks/{13.0889e093f8664e38568c.js → 13.526a25163ababaa44409.js} +2 -2
  20. udata/static/chunks/{13.0889e093f8664e38568c.js.map → 13.526a25163ababaa44409.js.map} +1 -1
  21. udata/static/chunks/{16.f41599478d3e97ad9a30.js → 16.7901839b4227881947f6.js} +2 -2
  22. udata/static/chunks/{16.f41599478d3e97ad9a30.js.map → 16.7901839b4227881947f6.js.map} +1 -1
  23. udata/static/chunks/{19.2b534a26af8b17e9170b.js → 19.471d5a2a08eef6e5338a.js} +3 -3
  24. udata/static/chunks/{19.2b534a26af8b17e9170b.js.map → 19.471d5a2a08eef6e5338a.js.map} +1 -1
  25. udata/static/chunks/{5.bd822f0e9689f45bd582.js → 5.98904a7a544eeb258e23.js} +3 -3
  26. udata/static/chunks/{5.bd822f0e9689f45bd582.js.map → 5.98904a7a544eeb258e23.js.map} +1 -1
  27. udata/static/chunks/{6.16bb24fb8240f2746488.js → 6.e56975229e6065f68d2a.js} +3 -3
  28. udata/static/chunks/{6.16bb24fb8240f2746488.js.map → 6.e56975229e6065f68d2a.js.map} +1 -1
  29. udata/static/chunks/{9.3e752966ff14e47e11f2.js → 9.534426728626f11f4571.js} +2 -2
  30. udata/static/chunks/{9.3e752966ff14e47e11f2.js.map → 9.534426728626f11f4571.js.map} +1 -1
  31. udata/static/common.js +1 -1
  32. udata/static/common.js.map +1 -1
  33. udata/templates/mail/membership_refused.html +1 -1
  34. udata/templates/mail/membership_request.html +1 -1
  35. udata/templates/mail/new_member.html +2 -2
  36. udata/templates/mail/new_member.txt +1 -1
  37. udata/tests/apiv2/test_datasets.py +69 -0
  38. udata/tests/apiv2/test_organizations.py +193 -0
  39. {udata-7.0.4.dev27543.dist-info → udata-7.0.4.dev27593.dist-info}/METADATA +8 -2
  40. {udata-7.0.4.dev27543.dist-info → udata-7.0.4.dev27593.dist-info}/RECORD +44 -43
  41. {udata-7.0.4.dev27543.dist-info → udata-7.0.4.dev27593.dist-info}/LICENSE +0 -0
  42. {udata-7.0.4.dev27543.dist-info → udata-7.0.4.dev27593.dist-info}/WHEEL +0 -0
  43. {udata-7.0.4.dev27543.dist-info → udata-7.0.4.dev27593.dist-info}/entry_points.txt +0 -0
  44. {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
- return form.save()
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')
@@ -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
- dataset.save(signal_kwargs={'ignores': ['post_save']})
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
- try:
252
- for key in data:
255
+ for key in data:
256
+ try:
253
257
  del dataset.extras[key]
254
- except KeyError:
255
- apiv2.abort(404, 'Key not found in existing extras')
258
+ except KeyError:
259
+ pass
256
260
  dataset.save(signal_kwargs={'ignores': ['post_save']})
257
261
  return dataset.extras, 204
258
262
 
@@ -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
 
@@ -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
- on_new_discussion, on_new_discussion_comment, on_discussion_closed,
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
 
@@ -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
- else:
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))
@@ -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 udata.core.contact_point.api_fields import contact_point_fields
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.ExtrasField()
117
+ extras = db.OrganizationExtrasField()
118
118
 
119
119
  deleted = db.DateTimeField()
120
120
 
@@ -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 > 0:
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
- elif items == 0:
30
- backend.finalize()
33
+
31
34
 
32
35
 
33
36
  @task(ignore_result=False, route='low.harvest')
@@ -22,6 +22,7 @@ class HarvestSourceFactory(ModelFactory):
22
22
  name = factory.Faker('name')
23
23
  url = factory.Faker('url')
24
24
  description = factory.Faker('text')
25
+ backend = 'factory'
25
26
 
26
27
 
27
28
  class HarvestJobFactory(ModelFactory):
@@ -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
@@ -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
- # TODO: ideally, this should be set to True in order to reveal exceptions in delayed tasks
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