udata 8.0.1.dev28882__py2.py3-none-any.whl → 8.0.1.dev28979__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/api/__init__.py CHANGED
@@ -22,7 +22,7 @@ from udata.auth import (
22
22
  from udata.utils import safe_unicode
23
23
  from udata.mongo.errors import FieldValidationError
24
24
 
25
- from . import fields, oauth2
25
+ from . import fields
26
26
  from .signals import on_api_call
27
27
 
28
28
 
@@ -129,6 +129,7 @@ class UDataApi(Api):
129
129
  @wraps(func)
130
130
  def wrapper(*args, **kwargs):
131
131
  from udata.core.user.models import User
132
+ from udata.api.oauth2 import check_credentials
132
133
 
133
134
  if current_user.is_authenticated:
134
135
  return func(*args, **kwargs)
@@ -143,7 +144,7 @@ class UDataApi(Api):
143
144
  if not login_user(user, False):
144
145
  self.abort(401, 'Inactive user')
145
146
  else:
146
- oauth2.check_credentials()
147
+ check_credentials()
147
148
  return func(*args, **kwargs)
148
149
  return wrapper
149
150
 
@@ -324,6 +325,7 @@ def init_app(app):
324
325
  import udata.core.user.api # noqa
325
326
  import udata.core.dataset.api # noqa
326
327
  import udata.core.dataset.apiv2 # noqa
328
+ import udata.core.dataservices.api # noqa
327
329
  import udata.core.discussions.api # noqa
328
330
  import udata.core.reuse.api # noqa
329
331
  import udata.core.reuse.apiv2 # noqa
@@ -350,5 +352,6 @@ def init_app(app):
350
352
  app.register_blueprint(apiv1_blueprint)
351
353
  app.register_blueprint(apiv2_blueprint)
352
354
 
353
- oauth2.init_app(app)
355
+ from udata.api.oauth2 import init_app as oauth2_init_app
356
+ oauth2_init_app(app)
354
357
  cors.init_app(app)
udata/api/oauth2.py CHANGED
@@ -34,6 +34,7 @@ from werkzeug.security import gen_salt
34
34
 
35
35
  from udata.app import csrf
36
36
  from udata.auth import current_user, login_required, login_user
37
+ from udata.core.organization.models import Organization
37
38
  from udata.i18n import I18nBlueprint, lazy_gettext as _
38
39
  from udata.mongo import db
39
40
  from udata.core.storages import images, default_image_basename
@@ -66,7 +67,7 @@ class OAuth2Client(ClientMixin, db.Datetimed, db.Document):
66
67
  description = db.StringField()
67
68
 
68
69
  owner = db.ReferenceField('User')
69
- organization = db.ReferenceField('Organization', reverse_delete_rule=db.NULLIFY)
70
+ organization = db.ReferenceField(Organization, reverse_delete_rule=db.NULLIFY)
70
71
  image = db.ImageField(fs=images, basename=default_image_basename,
71
72
  thumbnails=[150, 25])
72
73
 
udata/api_fields.py ADDED
@@ -0,0 +1,254 @@
1
+ from udata.api import api
2
+ import flask_restx.fields as restx_fields
3
+ import udata.api.fields as custom_restx_fields
4
+ from bson import ObjectId
5
+ import mongoengine
6
+ import mongoengine.fields as mongo_fields
7
+
8
+ from udata.mongo.errors import FieldValidationError
9
+
10
+ def convert_db_to_field(key, field, info = {}):
11
+ '''
12
+ This function maps a Mongo field to a Flask RestX field.
13
+ Most of the types are a simple 1-to-1 mapping except lists and references that requires
14
+ more work.
15
+ We currently only map the params that we use from Mongo to RestX (for example min_length / max_length…).
16
+
17
+ In the first part of the function we save the RestX constructor as a lambda because we need to call it with the
18
+ params. Since merging the params involve a litte bit of work (merging default params with read/write params and then with
19
+ user-supplied overrides, setting the readonly flag…), it's easier to have do this one time at the end of the function.
20
+ '''
21
+ info = { **getattr(field, '__additional_field_info__', {}), **info }
22
+
23
+ params = {}
24
+ params['required'] = field.required
25
+
26
+ read_params = {}
27
+ write_params = {}
28
+
29
+ constructor = None
30
+ constructor_read = None
31
+ constructor_write = None
32
+
33
+ if info.get('convert_to'):
34
+ # TODO: this is currently never used. We may remove it if the auto-conversion
35
+ # is always good enough.
36
+ return info.get('convert_to'), info.get('convert_to')
37
+ elif isinstance(field, mongo_fields.StringField):
38
+ constructor = restx_fields.String
39
+ params['min_length'] = field.min_length
40
+ params['max_length'] = field.max_length
41
+ elif isinstance(field, mongo_fields.FloatField):
42
+ constructor = restx_fields.Float
43
+ params['min'] = field.min # TODO min_value?
44
+ params['max'] = field.max
45
+ elif isinstance(field, mongo_fields.BooleanField):
46
+ constructor = restx_fields.Boolean
47
+ elif isinstance(field, mongo_fields.DateTimeField):
48
+ constructor = custom_restx_fields.ISODateTime
49
+ elif isinstance(field, mongo_fields.DictField):
50
+ constructor = restx_fields.Raw
51
+ elif isinstance(field, mongo_fields.ListField):
52
+ # For lists, we convert the inner value from Mongo to RestX then we create
53
+ # the `List` RestX type with this converted inner value.
54
+ field_read, field_write = convert_db_to_field(f"{key}.inner", field.field, info.get('inner_field_info', {}))
55
+ constructor_read = lambda **kwargs: restx_fields.List(field_read, **kwargs)
56
+ constructor_write = lambda **kwargs: restx_fields.List(field_write, **kwargs)
57
+ elif isinstance(field, mongo_fields.ReferenceField):
58
+ # For reference we accept while writing a String representing the ID of the referenced model.
59
+ # For reading, if the user supplied a `nested_fields` (RestX model), we use it to convert
60
+ # the referenced model, if not we return a String (and RestX will call the `str()` of the model
61
+ # when returning from an endpoint)
62
+ nested_fields = info.get('nested_fields')
63
+ if nested_fields is None:
64
+ # If there is no `nested_fields` convert the object to the string representation.
65
+ constructor_read = restx_fields.String
66
+ else:
67
+ constructor_read = lambda **kwargs: restx_fields.Nested(nested_fields, **kwargs)
68
+
69
+ write_params['description'] = "ID of the reference"
70
+ constructor_write = restx_fields.String
71
+ elif isinstance(field, mongo_fields.EmbeddedDocumentField):
72
+ nested_fields = info.get('nested_fields')
73
+ if nested_fields is None:
74
+ raise ValueError(f"EmbeddedDocumentField `{key}` requires a `nested_fields` param to serialize/deserialize.")
75
+
76
+ constructor = lambda **kwargs: restx_fields.Nested(nested_fields, **kwargs)
77
+ else:
78
+ raise ValueError(f"Unsupported MongoEngine field type {field.__class__.__name__}")
79
+
80
+ read_params = {**params, **read_params, **info}
81
+ write_params = {**params, **write_params, **info}
82
+
83
+ read = constructor_read(**read_params) if constructor_read else constructor(**read_params)
84
+ if write_params.get('readonly', False):
85
+ write = None
86
+ else:
87
+ write = constructor_write(**write_params) if constructor_write else constructor(**write_params)
88
+ return read, write
89
+
90
+ def generate_fields(**kwargs):
91
+ '''
92
+ This decorator will create two auto-generated attributes on the class `__read_fields__` and `__write_fields__`
93
+ that can be used in API endpoint inside `expect()` and `marshall_with()`.
94
+ '''
95
+ def wrapper(cls):
96
+ read_fields = {}
97
+ write_fields = {}
98
+ sortables = []
99
+
100
+ read_fields['id'] = restx_fields.String(required=True)
101
+
102
+ for key, field in cls._fields.items():
103
+ info = getattr(field, '__additional_field_info__', None)
104
+ if info is None: continue
105
+
106
+ if info.get('sortable', False):
107
+ sortables.append(key)
108
+
109
+ read, write = convert_db_to_field(key, field)
110
+
111
+ if read:
112
+ read_fields[key] = read
113
+ if write:
114
+ write_fields[key] = write
115
+
116
+ # The goal of this loop is to fetch all functions (getters) of the class
117
+ # If a function has an `__additional_field_info__` attribute it means
118
+ # it has been decorated with `@function_field()` and should be included
119
+ # in the API response.
120
+ for method_name in dir(cls):
121
+ if method_name == 'objects': continue
122
+ if method_name.startswith('_'): continue
123
+ if method_name in read_fields: continue # Do not override if the attribute is also callable like for Extras
124
+
125
+ method = getattr(cls, method_name)
126
+ if not callable(method): continue
127
+
128
+ info = getattr(method, '__additional_field_info__', None)
129
+ if info is None: continue
130
+
131
+ def make_lambda(method):
132
+ '''
133
+ Factory function to create a lambda with the correct scope.
134
+ If we don't have this factory function, the `method` will be the
135
+ last method assigned in this loop?
136
+ '''
137
+ return lambda o: method(o)
138
+
139
+ read_fields[method_name] = restx_fields.String(attribute=make_lambda(method), **{ 'readonly':True, **info })
140
+
141
+
142
+ cls.__read_fields__ = api.model(f"{cls.__name__} (read)", read_fields, **kwargs)
143
+ cls.__write_fields__ = api.model(f"{cls.__name__} (write)", write_fields, **kwargs)
144
+
145
+ mask = kwargs.pop('mask', None)
146
+ if mask is not None:
147
+ mask = 'data{{{0}}},*'.format(mask)
148
+ cls.__page_fields__ = api.model(f"{cls.__name__}Page", custom_restx_fields.pager(cls.__read_fields__), mask=mask, **kwargs)
149
+
150
+ # Parser for index sort/filters
151
+ paginable = kwargs.get('paginable', True)
152
+ parser = api.parser()
153
+
154
+ if paginable:
155
+ parser.add_argument('page', type=int, location='args', default=1, help='The page to display')
156
+ parser.add_argument('page_size', type=int, location='args', default=20, help='The page size')
157
+
158
+ if sortables:
159
+ choices = sortables + ['-' + k for k in sortables]
160
+ parser.add_argument('sort', type=str, location='args', choices=choices, help='The field (and direction) on which sorting apply')
161
+
162
+ cls.__index_parser__ = parser
163
+ def apply_sort_filters_and_pagination(base_query):
164
+ args = cls.__index_parser__.parse_args()
165
+
166
+ if sortables and args['sort']:
167
+ base_query = base_query.order_by(args['sort'])
168
+
169
+ if paginable:
170
+ base_query = base_query.paginate(args['page'], args['page_size'])
171
+
172
+ return base_query
173
+
174
+ cls.apply_sort_filters_and_pagination = apply_sort_filters_and_pagination
175
+ return cls
176
+ return wrapper
177
+
178
+
179
+ def function_field(**info):
180
+ def inner(func):
181
+ func.__additional_field_info__ = info
182
+ return func
183
+
184
+ return inner
185
+
186
+ def field(inner, **kwargs):
187
+ '''
188
+ Simple decorator to mark a field as visible for the API fields.
189
+ We can pass additional arguments that will be forward to the RestX field constructor.
190
+ '''
191
+ inner.__additional_field_info__ = kwargs
192
+ return inner
193
+
194
+
195
+ def patch(obj, request):
196
+ '''
197
+ Patch the object with the data from the request.
198
+ Only fields decorated with the `field()` decorator will be read (and not readonly).
199
+ '''
200
+ for key, value in request.json.items():
201
+ field = obj.__write_fields__.get(key)
202
+ if field is not None and not field.readonly:
203
+ model_attribute = getattr(obj.__class__, key)
204
+ if isinstance(model_attribute, mongoengine.fields.ListField) and isinstance(model_attribute.field, mongoengine.fields.ReferenceField):
205
+ # TODO `wrap_primary_key` do Mongo request, do a first pass to fetch all documents before calling it (to avoid multiple queries).
206
+ value = [wrap_primary_key(key, model_attribute.field, id) for id in value]
207
+ if isinstance(model_attribute, mongoengine.fields.ReferenceField):
208
+ value = wrap_primary_key(key, model_attribute, value)
209
+
210
+
211
+ info = getattr(model_attribute, '__additional_field_info__', {})
212
+
213
+ # `check` field attribute allows to do validation from the request before setting
214
+ # the attribute
215
+ check = info.get('check', None)
216
+ if check is not None:
217
+ check(**{key: value}) # TODO add other model attributes in function parameters
218
+
219
+ setattr(obj, key, value)
220
+
221
+ return obj
222
+
223
+ def wrap_primary_key(field_name: str, foreign_field: mongoengine.fields.ReferenceField, value: str):
224
+ '''
225
+ We need to wrap the `String` inside an `ObjectId` most of the time. If the foreign ID is a `String` we need to get
226
+ a `DBRef` from the database.
227
+
228
+ TODO: we only check the document reference if the ID is a `String` field (not in the case of a classic `ObjectId`).
229
+ '''
230
+ document_type = foreign_field.document_type()
231
+ id_field_name = document_type.__class__._meta["id_field"]
232
+
233
+ id_field = getattr(document_type.__class__, id_field_name)
234
+
235
+ # Get the foreign document from MongoDB because the othewise it fails during read
236
+ # Also useful to get a DBRef for non ObjectId references (see below)
237
+ foreign_document = document_type.__class__.objects(**{id_field_name: value}).first()
238
+ if foreign_document is None:
239
+ raise FieldValidationError(field=field_name, message=f"Unknown reference '{value}'")
240
+
241
+ if isinstance(id_field, mongoengine.fields.ObjectIdField):
242
+ return ObjectId(value)
243
+ elif isinstance(id_field, mongoengine.fields.StringField):
244
+ # Right now I didn't find a simpler way to make mongoengine happy.
245
+ # For references, it expects `ObjectId`, `DBRef`, `LazyReference` or `document` but since
246
+ # the primary key a StringField (not an `ObjectId`) we cannot create an `ObjectId`, I didn't find
247
+ # a way to create a `DBRef` nor a `LazyReference` so I create a simple document with only the ID field.
248
+ # We could use a simple dict as follow instead:
249
+ # { 'id': value }
250
+ # … but it may be important to check before-hand that the reference point to a correct document.
251
+ return foreign_document.to_dbref()
252
+ else:
253
+ raise ValueError(f"Unknown ID field type {id_field.__class__} for {document_type.__class__} (ID field name is {id_field_name}, value was {value})")
254
+
@@ -1,14 +1,15 @@
1
1
  import logging
2
- import weakref
3
2
 
4
3
  from datetime import datetime
5
4
 
6
5
  from mongoengine.signals import post_save
7
6
 
7
+ from udata.api_fields import field
8
8
  from udata.auth import current_user
9
9
  from udata.mongo import db
10
10
 
11
11
  from .signals import on_badge_added, on_badge_removed
12
+ from .fields import badge_fields
12
13
 
13
14
  log = logging.getLogger(__name__)
14
15
 
File without changes
@@ -0,0 +1,84 @@
1
+ from datetime import datetime
2
+ from flask import request
3
+ from flask_login import current_user
4
+ import mongoengine
5
+
6
+ from udata.api import api, API
7
+ from udata.api_fields import patch
8
+ from udata.core.dataset.permissions import OwnablePermission
9
+ from .models import Dataservice
10
+ from udata.models import db
11
+
12
+ ns = api.namespace('dataservices', 'Dataservices related operations (beta)')
13
+
14
+ @ns.route('/', endpoint='dataservices')
15
+ class DataservicesAPI(API):
16
+ '''Dataservices collection endpoint'''
17
+ @api.doc('list_dataservices')
18
+ @api.expect(Dataservice.__index_parser__)
19
+ @api.marshal_with(Dataservice.__page_fields__)
20
+ def get(self):
21
+ '''List or search all dataservices'''
22
+ query = Dataservice.objects.visible()
23
+
24
+ return Dataservice.apply_sort_filters_and_pagination(query)
25
+
26
+ @api.secure
27
+ @api.doc('create_dataservice', responses={400: 'Validation error'})
28
+ @api.expect(Dataservice.__write_fields__)
29
+ @api.marshal_with(Dataservice.__read_fields__, code=201)
30
+ def post(self):
31
+ dataservice = patch(Dataservice(), request)
32
+ if not dataservice.owner and not dataservice.organization:
33
+ dataservice.owner = current_user._get_current_object()
34
+
35
+ try:
36
+ dataservice.save()
37
+ except mongoengine.errors.ValidationError as e:
38
+ api.abort(400, e.message)
39
+
40
+ return dataservice, 201
41
+
42
+ @ns.route('/<dataservice:dataservice>/', endpoint='dataservice')
43
+ class DataserviceAPI(API):
44
+ @api.doc('get_dataservice')
45
+ @api.marshal_with(Dataservice.__read_fields__)
46
+ def get(self, dataservice):
47
+ if dataservice.deleted_at and not OwnablePermission(dataservice).can():
48
+ api.abort(410, 'Dataservice has been deleted')
49
+ return dataservice
50
+
51
+ @api.secure
52
+ @api.doc('update_dataservice', responses={400: 'Validation error'})
53
+ @api.expect(Dataservice.__write_fields__)
54
+ @api.marshal_with(Dataservice.__read_fields__)
55
+ def patch(self, dataservice):
56
+ if dataservice.deleted_at:
57
+ api.abort(410, 'dataservice has been deleted')
58
+
59
+ OwnablePermission(dataservice).test()
60
+
61
+ patch(dataservice, request)
62
+ dataservice.modified_at = datetime.utcnow()
63
+
64
+ try:
65
+ dataservice.save()
66
+ return dataservice
67
+ except mongoengine.errors.ValidationError as e:
68
+ api.abort(400, e.message)
69
+
70
+ @api.secure
71
+ @api.doc('delete_dataservice')
72
+ @api.response(204, 'dataservice deleted')
73
+ def delete(self, dataservice):
74
+ if dataservice.deleted_at:
75
+ api.abort(410, 'dataservice has been deleted')
76
+
77
+ OwnablePermission(dataservice).test()
78
+
79
+ dataservice.deleted_at = datetime.utcnow()
80
+ dataservice.modified_at = datetime.utcnow()
81
+ dataservice.save()
82
+
83
+ return '', 204
84
+
@@ -0,0 +1,130 @@
1
+ from datetime import datetime
2
+ from udata.api_fields import field, function_field, generate_fields
3
+ from udata.core.dataset.models import Dataset
4
+ from udata.core.metrics.models import WithMetrics
5
+ from udata.core.owned import Owned, OwnedQuerySet
6
+ from udata.i18n import lazy_gettext as _
7
+ import udata.core.contact_point.api_fields as contact_api_fields
8
+ import udata.core.dataset.api_fields as datasets_api_fields
9
+
10
+ from udata.models import db
11
+ from udata.uris import endpoint_for
12
+
13
+ # "frequency"
14
+ # "harvest"
15
+ # "internal"
16
+ # "page"
17
+ # "quality" # Peut-être pas dans une v1 car la qualité sera probablement calculé différemment
18
+ # "datasets" # objet : liste de datasets liés à une API
19
+ # "spatial"
20
+ # "temporal_coverage"
21
+
22
+ DATASERVICE_FORMATS = ['REST', 'WMS', 'WSL']
23
+
24
+
25
+ class DataserviceQuerySet(OwnedQuerySet):
26
+ def visible(self):
27
+ return self(archived_at=None, deleted_at=None, private=False)
28
+
29
+ def hidden(self):
30
+ return self(db.Q(private=True) |
31
+ db.Q(deleted_at__ne=None) |
32
+ db.Q(archived_at__ne=None))
33
+
34
+ @generate_fields()
35
+ class Dataservice(WithMetrics, Owned, db.Document):
36
+ meta = {
37
+ 'indexes': [
38
+ '$title',
39
+ ] + Owned.meta['indexes'],
40
+ 'queryset_class': DataserviceQuerySet,
41
+ 'auto_create_index_on_save': True
42
+ }
43
+
44
+ title = field(
45
+ db.StringField(required=True),
46
+ example="My awesome API",
47
+ sortable=True,
48
+ )
49
+ acronym = field(
50
+ db.StringField(max_length=128),
51
+ )
52
+ # /!\ do not set directly the slug when creating or updating a dataset
53
+ # this will break the search indexation
54
+ slug = field(
55
+ db.SlugField(max_length=255, required=True, populate_from='title', update=True, follow=True),
56
+ readonly=True,
57
+ )
58
+ description = field(
59
+ db.StringField(default=''),
60
+ description="In markdown"
61
+ )
62
+ base_api_url = field(
63
+ db.URLField(required=True),
64
+ sortable=True,
65
+ )
66
+ endpoint_description_url = field(db.URLField())
67
+ authorization_request_url = field(db.URLField())
68
+ availability= field(
69
+ db.FloatField(min=0, max=100),
70
+ example='99.99'
71
+ )
72
+ rate_limiting = field(db.StringField())
73
+ is_restricted = field(db.BooleanField())
74
+ has_token = field(db.BooleanField())
75
+ format = field(db.StringField(choices=DATASERVICE_FORMATS))
76
+
77
+ license = field(
78
+ db.ReferenceField('License'),
79
+ allow_null=True,
80
+ )
81
+
82
+ tags = field(
83
+ db.TagListField(),
84
+ )
85
+
86
+ private = field(
87
+ db.BooleanField(default=False),
88
+ description='Is the dataservice private to the owner or the organization'
89
+ )
90
+
91
+ extras = field(db.ExtrasField())
92
+
93
+ contact_point = field(
94
+ db.ReferenceField('ContactPoint', reverse_delete_rule=db.NULLIFY),
95
+ nested_fields=contact_api_fields.contact_point_fields,
96
+ allow_null=True,
97
+ )
98
+
99
+ created_at = field(
100
+ db.DateTimeField(verbose_name=_('Creation date'), default=datetime.utcnow, required=True),
101
+ readonly=True,
102
+ )
103
+ metadata_modified_at = field(
104
+ db.DateTimeField(verbose_name=_('Last modification date'), default=datetime.utcnow, required=True),
105
+ readonly=True,
106
+ )
107
+ deleted_at = field(db.DateTimeField(), readonly=True)
108
+ archived_at = field(db.DateTimeField(), readonly=True)
109
+
110
+ datasets = field(
111
+ db.ListField(
112
+ field(
113
+ db.ReferenceField(Dataset),
114
+ nested_fields=datasets_api_fields.dataset_fields,
115
+ )
116
+ )
117
+ )
118
+
119
+ @function_field(description="Link to the API endpoint for this dataservice")
120
+ def self_api_url(self):
121
+ return endpoint_for('api.dataservice', dataservice=self, _external=True)
122
+
123
+ def self_web_url():
124
+ pass
125
+
126
+ # TODO
127
+ # frequency = db.StringField(choices=list(UPDATE_FREQUENCIES.keys()))
128
+ # temporal_coverage = db.EmbeddedDocumentField(db.DateRange)
129
+ # spatial = db.EmbeddedDocumentField(SpatialCoverage)
130
+ # harvest = db.EmbeddedDocumentField(HarvestDatasetMetadata)
@@ -1,5 +1,4 @@
1
- from datetime import date, timedelta
2
-
1
+ from udata.api_fields import field
3
2
  from udata.mongo import db
4
3
 
5
4
 
@@ -7,7 +6,7 @@ __all__ = ('WithMetrics',)
7
6
 
8
7
 
9
8
  class WithMetrics(object):
10
- metrics = db.DictField()
9
+ metrics = field(db.DictField())
11
10
 
12
11
  __metrics_keys__ = []
13
12
 
@@ -5,9 +5,11 @@ from blinker import Signal
5
5
  from mongoengine.signals import pre_save, post_save
6
6
  from werkzeug.utils import cached_property
7
7
 
8
+ from udata.core.badges.models import BadgeMixin
9
+ from udata.core.metrics.models import WithMetrics
8
10
  from udata.core.storages import avatars, default_image_basename
9
11
  from udata.frontend.markdown import mdstrip
10
- from udata.models import db, BadgeMixin, WithMetrics
12
+ from udata.mongo import db
11
13
  from udata.i18n import lazy_gettext as _
12
14
  from udata.uris import endpoint_for
13
15
  from .constants import ASSOCIATION, CERTIFIED, COMPANY, LOCAL_AUTHORITY, LOGO_SIZES, ORG_BID_SIZE_LIMIT, ORG_ROLES, DEFAULT_ROLE, MEMBERSHIP_STATUS, LOGO_MAX_SIZE, PUBLIC_SERVICE
udata/core/owned.py CHANGED
@@ -4,7 +4,15 @@ from blinker import signal
4
4
  from mongoengine import NULLIFY, Q, post_save
5
5
  from mongoengine.fields import ReferenceField
6
6
 
7
+ from udata.api_fields import field
8
+ from udata.core.organization.models import Organization
9
+ from udata.core.user.models import User
7
10
  from udata.mongo.queryset import UDataQuerySet
11
+ from udata.core.user.api_fields import user_ref_fields
12
+ from udata.core.organization.api_fields import org_ref_fields
13
+ from udata.core.organization.permissions import OrganizationPrivatePermission
14
+ from udata.mongo.errors import FieldValidationError
15
+ from udata.i18n import lazy_gettext as _
8
16
 
9
17
  log = logging.getLogger(__name__)
10
18
 
@@ -15,14 +23,42 @@ class OwnedQuerySet(UDataQuerySet):
15
23
  for owner in owners:
16
24
  qs |= Q(owner=owner) | Q(organization=owner)
17
25
  return self(qs)
26
+
27
+ def check_owner_is_current_user(owner):
28
+ from udata.auth import current_user, admin_permission
29
+ if current_user.is_authenticated and owner and not admin_permission and current_user.id != owner:
30
+ raise FieldValidationError(_('You can only set yourself as owner'), field="owner")
31
+
32
+ def check_organization_is_valid_for_current_user(organization):
33
+ from udata.auth import current_user
34
+ from udata.models import Organization
35
+
36
+ org = Organization.objects(id=organization).first()
37
+ if org is None:
38
+ raise FieldValidationError(_("Unknown organization"), field="organization")
39
+
40
+ if current_user.is_authenticated and org and not OrganizationPrivatePermission(org).can():
41
+ raise FieldValidationError(_("Permission denied for this organization"), field="organization")
18
42
 
19
43
 
20
44
  class Owned(object):
21
45
  '''
22
46
  A mixin to factorize owning behvaior between users and organizations.
23
47
  '''
24
- owner = ReferenceField('User', reverse_delete_rule=NULLIFY)
25
- organization = ReferenceField('Organization', reverse_delete_rule=NULLIFY)
48
+ owner = field(
49
+ ReferenceField(User, reverse_delete_rule=NULLIFY),
50
+ nested_fields=user_ref_fields,
51
+ description="Only present if organization is not set. Can only be set to the current authenticated user.",
52
+ check=check_owner_is_current_user,
53
+ allow_null=True,
54
+ )
55
+ organization = field(
56
+ ReferenceField(Organization, reverse_delete_rule=NULLIFY),
57
+ nested_fields=org_ref_fields,
58
+ description="Only present if owner is not set. Can only be set to an organization of the current authenticated user.",
59
+ check=check_organization_is_valid_for_current_user,
60
+ allow_null=True,
61
+ )
26
62
 
27
63
  on_owner_change = signal('Owned.on_owner_change')
28
64
 
@@ -38,6 +74,7 @@ class Owned(object):
38
74
  '''
39
75
  Verify owner consistency and fetch original owner before the new one erase it.
40
76
  '''
77
+
41
78
  changed_fields = self._get_changed_fields()
42
79
  if 'organization' in changed_fields and 'owner' in changed_fields:
43
80
  # Ownership changes (org to owner or the other way around) have already been made
@@ -268,7 +268,6 @@ class CswDcatBackend(DcatBackend):
268
268
  headers=headers).content)
269
269
 
270
270
  return graphs
271
-
272
271
 
273
272
 
274
273
  class CswIso19139DcatBackend(DcatBackend):
@@ -295,6 +294,7 @@ class CswIso19139DcatBackend(DcatBackend):
295
294
  transform = ET.XSLT(xsl)
296
295
 
297
296
  # Start querying and parsing graph
297
+ # Filter on dataset or serie records
298
298
  body = '''<csw:GetRecords xmlns:csw="http://www.opengis.net/cat/csw/2.0.2"
299
299
  xmlns:gmd="http://www.isotc211.org/2005/gmd"
300
300
  service="CSW" version="2.0.2" resultType="results"
@@ -304,10 +304,16 @@ class CswIso19139DcatBackend(DcatBackend):
304
304
  <csw:ElementSetName>full</csw:ElementSetName>
305
305
  <csw:Constraint version="1.1.0">
306
306
  <ogc:Filter xmlns:ogc="http://www.opengis.net/ogc">
307
- <ogc:PropertyIsEqualTo>
308
- <ogc:PropertyName>dc:type</ogc:PropertyName>
309
- <ogc:Literal>dataset</ogc:Literal>
310
- </ogc:PropertyIsEqualTo>
307
+ <ogc:Or xmlns:ogc="http://www.opengis.net/ogc">
308
+ <ogc:PropertyIsEqualTo>
309
+ <ogc:PropertyName>dc:type</ogc:PropertyName>
310
+ <ogc:Literal>dataset</ogc:Literal>
311
+ </ogc:PropertyIsEqualTo>
312
+ <ogc:PropertyIsEqualTo>
313
+ <ogc:PropertyName>dc:type</ogc:PropertyName>
314
+ <ogc:Literal>series</ogc:Literal>
315
+ </ogc:PropertyIsEqualTo>
316
+ </ogc:Or>
311
317
  </ogc:Filter>
312
318
  </csw:Constraint>
313
319
  </csw:Query>
@@ -319,7 +325,7 @@ class CswIso19139DcatBackend(DcatBackend):
319
325
  start = 1
320
326
 
321
327
  response = self.post(url, data=body.format(start=start, schema=self.ISO_SCHEMA),
322
- headers=headers)
328
+ headers=headers)
323
329
  response.raise_for_status()
324
330
 
325
331
  tree_before_transform = ET.fromstring(response.content)
@@ -351,12 +357,12 @@ class CswIso19139DcatBackend(DcatBackend):
351
357
  next_record = self.next_record_if_should_continue(start, search_results)
352
358
  if not next_record:
353
359
  break
354
-
360
+
355
361
  start = next_record
356
362
  page += 1
357
363
 
358
364
  response = self.post(url, data=body.format(start=start, schema=self.ISO_SCHEMA),
359
- headers=headers)
365
+ headers=headers)
360
366
  response.raise_for_status()
361
367
 
362
368
  tree_before_transform = ET.fromstring(response.content)
udata/routing.py CHANGED
@@ -10,6 +10,7 @@ from werkzeug.urls import url_quote
10
10
  from udata import models
11
11
  from udata.mongo import db
12
12
  from udata.core.spatial.models import GeoZone
13
+ from udata.core.dataservices.models import Dataservice
13
14
  from udata.i18n import ISO_639_1_CODES
14
15
 
15
16
 
@@ -121,6 +122,10 @@ class DatasetConverter(ModelConverter):
121
122
  model = models.Dataset
122
123
 
123
124
 
125
+ class DataserviceConverter(ModelConverter):
126
+ model = Dataservice
127
+
128
+
124
129
  class CommunityResourceConverter(ModelConverter):
125
130
  model = models.CommunityResource
126
131
 
@@ -222,6 +227,7 @@ def init_app(app):
222
227
  app.url_map.converters['pathlist'] = PathListConverter
223
228
  app.url_map.converters['uuid'] = UUIDConverter
224
229
  app.url_map.converters['dataset'] = DatasetConverter
230
+ app.url_map.converters['dataservice'] = DataserviceConverter
225
231
  app.url_map.converters['crid'] = CommunityResourceConverter
226
232
  app.url_map.converters['org'] = OrganizationConverter
227
233
  app.url_map.converters['reuse'] = ReuseConverter
@@ -29,5 +29,8 @@ class APITestCase(FrontTestCase):
29
29
  def put(self, url, data=None, json=True, *args, **kwargs):
30
30
  return self.api.put(url, data=data, json=json, *args, **kwargs)
31
31
 
32
+ def patch(self, url, data=None, json=True, *args, **kwargs):
33
+ return self.api.patch(url, data=data, json=json, *args, **kwargs)
34
+
32
35
  def delete(self, url, data=None, *args, **kwargs):
33
36
  return self.api.delete(url, data=data, *args, **kwargs)
@@ -0,0 +1,236 @@
1
+ from pprint import pprint
2
+ from flask import url_for
3
+
4
+ from udata.core.dataservices.models import Dataservice
5
+ from udata.core.dataset.factories import (DatasetFactory, LicenseFactory)
6
+ from udata.i18n import gettext as _
7
+ from udata.core.user.factories import UserFactory
8
+ from udata.core.organization.factories import OrganizationFactory
9
+ from udata.core.organization.models import Member
10
+
11
+ from . import APITestCase
12
+
13
+ class DataserviceAPITest(APITestCase):
14
+ modules = []
15
+
16
+ def test_dataservice_api_create(self):
17
+ user = self.login()
18
+ datasets = DatasetFactory.create_batch(3)
19
+ license = LicenseFactory.create()
20
+
21
+ response = self.post(url_for('api.dataservices'), {
22
+ 'title': 'My API',
23
+ 'base_api_url': 'https://example.org',
24
+ })
25
+ self.assert201(response)
26
+ self.assertEqual(Dataservice.objects.count(), 1)
27
+
28
+ dataservice = Dataservice.objects.first()
29
+
30
+ response = self.get(url_for('api.dataservice', dataservice=dataservice))
31
+ self.assert200(response)
32
+
33
+ self.assertEqual(response.json['title'], 'My API')
34
+ self.assertEqual(response.json['base_api_url'], 'https://example.org')
35
+ self.assertEqual(response.json['owner']['id'], str(user.id))
36
+
37
+ response = self.patch(url_for('api.dataservice', dataservice=dataservice), {
38
+ 'title': 'Updated title',
39
+ 'tags': ['hello', 'world'],
40
+ 'private': True,
41
+ 'datasets': [datasets[0].id, datasets[2].id],
42
+ 'license': license.id,
43
+ 'extras': {
44
+ 'foo': 'bar',
45
+ },
46
+ })
47
+ self.assert200(response)
48
+
49
+ self.assertEqual(response.json['title'], 'Updated title')
50
+ self.assertEqual(response.json['base_api_url'], 'https://example.org')
51
+ self.assertEqual(response.json['tags'], ['hello', 'world'])
52
+ self.assertEqual(response.json['private'], True)
53
+ self.assertEqual(response.json['datasets'][0]['title'], datasets[0].title)
54
+ self.assertEqual(response.json['datasets'][1]['title'], datasets[2].title)
55
+ self.assertEqual(response.json['extras'], {
56
+ 'foo': 'bar',
57
+ })
58
+ self.assertEqual(response.json['license'], license.title)
59
+ self.assertEqual(response.json['self_api_url'], 'http://local.test/api/1/dataservices/updated-title/')
60
+ dataservice.reload()
61
+ self.assertEqual(dataservice.title, 'Updated title')
62
+ self.assertEqual(dataservice.base_api_url, 'https://example.org')
63
+ self.assertEqual(dataservice.tags, ['hello', 'world'])
64
+ self.assertEqual(dataservice.private, True)
65
+ self.assertEqual(dataservice.datasets[0].title, datasets[0].title)
66
+ self.assertEqual(dataservice.datasets[1].title, datasets[2].title)
67
+ self.assertEqual(dataservice.extras, {
68
+ 'foo': 'bar',
69
+ })
70
+ self.assertEqual(dataservice.license.title, license.title)
71
+ self.assertEqual(dataservice.self_api_url(), 'http://local.test/api/1/dataservices/updated-title/')
72
+
73
+ response = self.delete(url_for('api.dataservice', dataservice=dataservice))
74
+ self.assert204(response)
75
+
76
+ self.assertEqual(Dataservice.objects.count(), 1)
77
+
78
+ dataservice.reload()
79
+ self.assertEqual(dataservice.title, 'Updated title')
80
+ self.assertEqual(dataservice.base_api_url, 'https://example.org')
81
+ self.assertIsNotNone(dataservice.deleted_at)
82
+
83
+ # response = self.get(url_for('api.dataservice', dataservice=dataservice))
84
+ # self.assert410(response)
85
+
86
+
87
+ def test_dataservice_api_index(self):
88
+ self.login()
89
+ self.post(url_for('api.dataservices'), {
90
+ 'title': 'B',
91
+ 'base_api_url': 'https://example.org/B',
92
+ })
93
+ self.post(url_for('api.dataservices'), {
94
+ 'title': 'C',
95
+ 'base_api_url': 'https://example.org/C',
96
+ })
97
+ self.post(url_for('api.dataservices'), {
98
+ 'title': 'A',
99
+ 'base_api_url': 'https://example.org/A',
100
+ })
101
+ response = self.post(url_for('api.dataservices'), {
102
+ 'title': 'X',
103
+ 'base_api_url': 'https://example.org/X',
104
+ 'private': True,
105
+ })
106
+
107
+ self.assertEqual(Dataservice.objects.count(), 4)
108
+
109
+ response = self.get(url_for('api.dataservices'))
110
+ self.assert200(response)
111
+
112
+ self.assertEqual(response.json['previous_page'], None)
113
+ self.assertEqual(response.json['next_page'], None)
114
+ self.assertEqual(response.json['page'], 1)
115
+ self.assertEqual(response.json['total'], 3)
116
+ self.assertEqual(len(response.json['data']), 3)
117
+ self.assertEqual(response.json['data'][0]['title'], 'B')
118
+ self.assertEqual(response.json['data'][1]['title'], 'C')
119
+ self.assertEqual(response.json['data'][2]['title'], 'A')
120
+
121
+ response = self.get(url_for('api.dataservices', sort='title'))
122
+ self.assert200(response)
123
+
124
+ self.assertEqual(response.json['previous_page'], None)
125
+ self.assertEqual(response.json['next_page'], None)
126
+ self.assertEqual(response.json['page'], 1)
127
+ self.assertEqual(response.json['total'], 3)
128
+ self.assertEqual(len(response.json['data']), 3)
129
+ self.assertEqual(response.json['data'][0]['title'], 'A')
130
+ self.assertEqual(response.json['data'][1]['title'], 'B')
131
+ self.assertEqual(response.json['data'][2]['title'], 'C')
132
+
133
+ response = self.get(url_for('api.dataservices', sort='-title'))
134
+ self.assert200(response)
135
+
136
+ self.assertEqual(response.json['previous_page'], None)
137
+ self.assertEqual(response.json['next_page'], None)
138
+ self.assertEqual(response.json['page'], 1)
139
+ self.assertEqual(response.json['total'], 3)
140
+ self.assertEqual(len(response.json['data']), 3)
141
+ self.assertEqual(response.json['data'][0]['title'], 'C')
142
+ self.assertEqual(response.json['data'][1]['title'], 'B')
143
+ self.assertEqual(response.json['data'][2]['title'], 'A')
144
+
145
+
146
+ response = self.get(url_for('api.dataservices', page_size=1))
147
+ self.assert200(response)
148
+
149
+ self.assertEqual(response.json['previous_page'], None)
150
+ assert response.json['next_page'].endswith(url_for('api.dataservices', page_size=1, page=2))
151
+ self.assertEqual(response.json['page'], 1)
152
+ self.assertEqual(response.json['total'], 3)
153
+ self.assertEqual(len(response.json['data']), 1)
154
+ self.assertEqual(response.json['data'][0]['title'], 'B')
155
+
156
+ def test_dataservice_api_create_with_validation_error(self):
157
+ self.login()
158
+ response = self.post(url_for('api.dataservices'), {
159
+ 'base_api_url': 'https://example.org',
160
+ })
161
+ self.assert400(response)
162
+ self.assertEqual(Dataservice.objects.count(), 0)
163
+
164
+ def test_dataservice_api_create_with_unkwown_license(self):
165
+ self.login()
166
+ response = self.post(url_for('api.dataservices'), {
167
+ 'title': 'My title',
168
+ 'base_api_url': 'https://example.org',
169
+ 'license': 'unwkown-license',
170
+ })
171
+ self.assert400(response)
172
+ self.assertEqual(response.json['errors']['license'], ["Unknown reference 'unwkown-license'"])
173
+ self.assertEqual(Dataservice.objects.count(), 0)
174
+
175
+
176
+ def test_dataservice_api_create_with_unkwown_contact_point(self):
177
+ self.login()
178
+
179
+ response = self.post(url_for('api.dataservices'), {
180
+ 'title': 'My title',
181
+ 'base_api_url': 'https://example.org',
182
+ 'contact_point': '66212433e42ab56639ad516e',
183
+ })
184
+ self.assert400(response)
185
+ self.assertEqual(response.json['errors']['contact_point'], ["Unknown reference '66212433e42ab56639ad516e'"])
186
+ self.assertEqual(Dataservice.objects.count(), 0)
187
+
188
+
189
+ def test_dataservice_api_create_with_custom_user_or_org(self):
190
+ other = UserFactory()
191
+ other_member = Member(user=other, role='editor')
192
+ other_org = OrganizationFactory(members=[other_member])
193
+
194
+ me = self.login()
195
+ me_member = Member(user=me, role='editor')
196
+ me_org = OrganizationFactory(members=[me_member])
197
+
198
+ response = self.post(url_for('api.dataservices'), {
199
+ 'title': 'My title',
200
+ 'base_api_url': 'https://example.org',
201
+ 'owner': other.id,
202
+ })
203
+ self.assert400(response)
204
+ self.assertEqual(response.json['errors']['owner'], [_("You can only set yourself as owner")])
205
+ self.assertEqual(Dataservice.objects.count(), 0)
206
+
207
+ response = self.post(url_for('api.dataservices'), {
208
+ 'title': 'My title',
209
+ 'base_api_url': 'https://example.org',
210
+ 'organization': other_org.id,
211
+ })
212
+ self.assert400(response)
213
+ self.assertEqual(response.json['errors']['organization'], [_("Permission denied for this organization")])
214
+ self.assertEqual(Dataservice.objects.count(), 0)
215
+
216
+
217
+ response = self.post(url_for('api.dataservices'), {
218
+ 'title': 'My title',
219
+ 'base_api_url': 'https://example.org',
220
+ 'owner': me.id,
221
+ })
222
+ self.assert201(response)
223
+ dataservice = Dataservice.objects(id=response.json['id']).first()
224
+ self.assertEqual(dataservice.owner.id, me.id)
225
+ self.assertEqual(dataservice.organization, None)
226
+
227
+
228
+ response = self.post(url_for('api.dataservices'), {
229
+ 'title': 'My title',
230
+ 'base_api_url': 'https://example.org',
231
+ 'organization': me_org.id,
232
+ })
233
+ self.assert201(response)
234
+ dataservice = Dataservice.objects(id=response.json['id']).first()
235
+ self.assertEqual(dataservice.owner, None)
236
+ self.assertEqual(dataservice.organization.id, me_org.id)
udata/tests/plugin.py CHANGED
@@ -192,6 +192,11 @@ class ApiClient(object):
192
192
  return self.client.put(url, data or {}, *args, **kwargs)
193
193
  return self.perform('put', url, data=data or {}, *args, **kwargs)
194
194
 
195
+ def patch(self, url, data=None, json=True, *args, **kwargs):
196
+ if not json:
197
+ return self.client.patch(url, data or {}, *args, **kwargs)
198
+ return self.perform('patch', url, data=data or {}, *args, **kwargs)
199
+
195
200
  def delete(self, url, data=None, *args, **kwargs):
196
201
  return self.perform('delete', url, data=data or {}, *args, **kwargs)
197
202
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: udata
3
- Version: 8.0.1.dev28882
3
+ Version: 8.0.1.dev28979
4
4
  Summary: Open data portal
5
5
  Home-page: https://github.com/opendatateam/udata
6
6
  Author: Opendata Team
@@ -137,8 +137,10 @@ It is collectively taken care of by members of the
137
137
 
138
138
  ## Current (in progress)
139
139
 
140
+ - Add dataservices in beta [#2986](https://github.com/opendatateam/udata/pull/2986)
140
141
  - Remove deprecated `metrics_for` route [#3022](https://github.com/opendatateam/udata/pull/3022)
141
142
  - Fix spatial coverage fetching perfs. Need to schedule `compute-geozones-metrics` [#3018](https://github.com/opendatateam/udata/pull/3018)
143
+ - Allow for series in CSW ISO 19139 DCAT backend [#3028](https://github.com/opendatateam/udata/pull/3028)
142
144
 
143
145
  ## 8.0.0 (2024-04-23)
144
146
 
@@ -1,6 +1,7 @@
1
1
  tasks/__init__.py,sha256=CnVhb_TV-6nMhxVR6itnBmvuU2OSCs02AfNB4irVBTE,8132
2
2
  tasks/helpers.py,sha256=k_HiuiEJNgQLvWdeHqczPOAcrYpFjEepBeKo7EQzY8M,994
3
3
  udata/__init__.py,sha256=qPn-jr9CTcAEJ6KD95BxAliMbSxwtelLc8fyakV7UO0,101
4
+ udata/api_fields.py,sha256=myHkndhedV3g9cn2FWBu1Hx24Fy-fxaGedEP1ZMOoi0,11506
4
5
  udata/app.py,sha256=6upwrImLaWrSYtsXPW1zH84_oRxp3B6XFuocMe2D6NU,7329
5
6
  udata/assets.py,sha256=aMa-MnAEXVSTavptSn2V8sUE6jL_N0MrYCQ6_QpsuHs,645
6
7
  udata/entrypoints.py,sha256=8bZUvZ8ICO3kZxa8xDUaen6YS_OAGNKHFvaD7d8BOk0,2687
@@ -9,7 +10,7 @@ udata/factories.py,sha256=6en2qdHHoR6rEvNEUeH_0-T-vR7x7GUBGgNEm9vuylE,492
9
10
  udata/i18n.py,sha256=E-JoY57Cv4vgPvChkWjpwZAbuYVI8e6BO6raeu_3_Pw,8352
10
11
  udata/mail.py,sha256=dAMcbEtk5e54alpQezvF5adDrRPgdaT36QEdHD_5v50,2145
11
12
  udata/rdf.py,sha256=QiHcK2iklbtPkl6UFdBxTj0oaiY41en9k-kmbIVJ8Xw,9893
12
- udata/routing.py,sha256=lU1XNL-YQgx1GN3pjKue1tIjHzjO3QdTiF9aaLNLitg,7044
13
+ udata/routing.py,sha256=Qhpf5p97fs1SoJXtDctc1FPk0MeOKLn_C0Z1dP4ZNJA,7234
13
14
  udata/sentry.py,sha256=KiZz0PpmYpZMvykH9UAbHpF4xBY0Q-8DeiEbXEHDUdw,2683
14
15
  udata/settings.py,sha256=5cU5_oiPiuS8RS98F9qfCp37qbjjN_g8ipoyZjDQP0M,17298
15
16
  udata/sitemap.py,sha256=pjtR2lU3gRHvK8l1Lw8B9wrqMMTYC5adUfFh0pUp_Q4,977
@@ -23,11 +24,11 @@ udata/worker.py,sha256=K-Wafye5-uXP4kQlffRKws2J9YbJ6m6n2QjcVsY8Nsg,118
23
24
  udata/wsgi.py,sha256=P7AJvZ5JqY4uRSBOzaFiBniChWIU9RVQ-Y0PN4vCCMY,77
24
25
  udata/admin/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
25
26
  udata/admin/views.py,sha256=wMlpnC1aINW-6JDk6-kQXhcTYBZH-5wajEuWzVDcIKA,331
26
- udata/api/__init__.py,sha256=BPpjd6jmGm3nAKl3QpB7qIP72_3dAoO__3-X0CHPhzw,11236
27
+ udata/api/__init__.py,sha256=I40g3PLG4s-zGNQfjB8_KQGzfs3ZyUrZdajd20vQ9ks,11388
27
28
  udata/api/commands.py,sha256=oK2p1VdUvULDdYuvYYpYvY_bdkPJy-KfROfoX71oOuA,3277
28
29
  udata/api/errors.py,sha256=Sy_f3WVrNTUPZjCOogIVgocDdUjnKz149KDi4mMA_Lg,240
29
30
  udata/api/fields.py,sha256=l-Fa27-easR86qgof2bk130jq1N1pNUgGmQzok1UI3Q,3094
30
- udata/api/oauth2.py,sha256=ERppP1FtigiAJXO01IjhFFzG8ajPVD6gPcKpBrjHFO4,11726
31
+ udata/api/oauth2.py,sha256=LTtkpkNYrPl_7xf-JmP-TLwrk_RHajnHDFKo-3NPEd8,11780
31
32
  udata/api/parsers.py,sha256=Fo4krCaFao0D1QNqKpjWiFVvKVLd9b_2mon6RbbOXls,1485
32
33
  udata/api/signals.py,sha256=9zcw4NDdpJwhgsS5UyLtnls1w_RfqghYFULza6d4-cw,162
33
34
  udata/auth/__init__.py,sha256=ziR6gzkE1V4__3ynjE-_IPWHzmapOsZIrnaDAjDijsM,1958
@@ -52,7 +53,7 @@ udata/commands/worker.py,sha256=zek8YRxCESrZIULhx9D9RQOPFc4WwhYm2sF7-PPbEKY,4028
52
53
  udata/commands/tests/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
53
54
  udata/commands/tests/fixtures.py,sha256=h78grSpZDgOwvmezyD3qBpiSnpxaQ9tDeQOr3sB8EiY,1581
54
55
  udata/core/__init__.py,sha256=n7TBP0lkYJl-BLoc67V5fyaawrWmsrWG_mLIF49UFOY,385
55
- udata/core/owned.py,sha256=sLI4UpVuMukMEkC3K9Iwjl6P6Cz25J48M6VZc_-HZRQ,2718
56
+ udata/core/owned.py,sha256=ZOoRacTWHiCgZDEENu_7vRcoemZrNLmVVI3PVLuLK5E,4453
56
57
  udata/core/activity/__init__.py,sha256=4vB92owvzwn2VVxbFWGNcfESb6izDzvbj6lmLH4ssrU,299
57
58
  udata/core/activity/api.py,sha256=ohDEbzhbsmJ6YHQh3Aa8wYctaDQGuwKdg0kjh3lC028,3276
58
59
  udata/core/activity/models.py,sha256=kzy9kcixX3DUo-2YpVjGXVHR4cOZMl7AsPiDBZ52EDs,2118
@@ -64,7 +65,7 @@ udata/core/badges/commands.py,sha256=NMihQH6ABUlkYpR2lNFW4dkeaWOjpijW7voudww8zW4
64
65
  udata/core/badges/factories.py,sha256=rDQC28fKLtYA6l69EqiyOQyN4C2jVj87EdKBEo4sYsw,297
65
66
  udata/core/badges/fields.py,sha256=rpeyZcw5-oU8FZXPnzU9-rGrwW5vCyhwgei7aAR-n3I,255
66
67
  udata/core/badges/forms.py,sha256=8z28_rln9duvJ7IvKx9IFO9DAMe3cU2bv0FQRdiMFEw,511
67
- udata/core/badges/models.py,sha256=cQBpLudR-cRxlbN68z_05UbX0BXRu97n1vMMdat4Ry4,3176
68
+ udata/core/badges/models.py,sha256=GtfqdeyPkve4HZa4mOIhVcHIXr5IOFnASEFqazi-oQg,3229
68
69
  udata/core/badges/permissions.py,sha256=5jFKFG4OlVp0pL0xSa7AGO0Ttn0QMsy9xEY2jTfKVmE,552
69
70
  udata/core/badges/signals.py,sha256=T12kgKO1lmIbdXlKnRmedYRnJzC2PZIMkrtcA1U2mNo,237
70
71
  udata/core/badges/tasks.py,sha256=LA1Sd0B0uUa6OANqmx0t-kDNK99uGePwwtilsdj9Evw,431
@@ -77,6 +78,9 @@ udata/core/contact_point/api_fields.py,sha256=KQADF7Drnd1uT9ZWjLRKQPJh6GdGUXJkYf
77
78
  udata/core/contact_point/factories.py,sha256=ATuV1seBCGKY3CzvPDG5nxfBBqHu-3YtER0_fiQJx6c,248
78
79
  udata/core/contact_point/forms.py,sha256=ggLhSJ1IRn5MclrhydckjAxwr4fFZxgAD4huSSucSsA,598
79
80
  udata/core/contact_point/models.py,sha256=NlNKureCpzgTLJuGviZPjNx-ABYRp4j2L-ur9Gmixao,324
81
+ udata/core/dataservices/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
82
+ udata/core/dataservices/api.py,sha256=j0_fQdq_O3zBqY7PGapmNbeMjb2sS4x8jr3cTM-4i_Q,2803
83
+ udata/core/dataservices/models.py,sha256=G8m-YKHf32DdA-3Q4m-hlQCLZ8ZZUFKaRFD0YZtqvbE,4037
80
84
  udata/core/dataset/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
81
85
  udata/core/dataset/actions.py,sha256=3pzBg_qOR-w7fwPpTOKUHXWC9lkjALbOn1UQFmmT-s0,1199
82
86
  udata/core/dataset/activities.py,sha256=qQnHNL0hOB1IGtQl7JsnVOiUsWT0gm-pts9uDyR3bvU,1536
@@ -123,7 +127,7 @@ udata/core/jobs/forms.py,sha256=CB_z8JSyvSSJX1XhohsQ1S3kdOhTMgzsFTofso7AD60,1495
123
127
  udata/core/jobs/models.py,sha256=3vRfBp8e7KqJgKxTSSeMgfsMu07rAz1o_IfsYBFk9o4,1325
124
128
  udata/core/metrics/__init__.py,sha256=Q3nZVl7no0E1ygz7zNJH-KCa-m9FV6wAH2_y6zimcek,397
125
129
  udata/core/metrics/commands.py,sha256=74L6PUJyaTnr4w-_redN9ucY0v-3Vi4QumWHKZ__DYE,5143
126
- udata/core/metrics/models.py,sha256=v0xlLxl2Y9QdILW4DTZ1WzUy2a4XTNepdLNKmb8T8LY,287
130
+ udata/core/metrics/models.py,sha256=k4kgKjAViQ1XO1F6n-iRfF4eT9cHoGqjQP4e8vC2vmk,291
127
131
  udata/core/metrics/signals.py,sha256=Xs_k-9lWzsoZU5Joli-41FNgvIVvbAejkjSFB6DHxh0,176
128
132
  udata/core/metrics/tasks.py,sha256=Z7La3-zPB5j6qJoPKr_MgjNZhqscZCmNLPa711ZBkdY,797
129
133
  udata/core/organization/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
@@ -137,7 +141,7 @@ udata/core/organization/csv.py,sha256=x0afMfhWYvx_karwuw5jXqBhMbzkrsiEuAq1wTVurZ
137
141
  udata/core/organization/factories.py,sha256=5BABVcDhEChRhJsDfCDm8WyJG4l9j3H1_OFZa3VtlVs,646
138
142
  udata/core/organization/forms.py,sha256=JXXv4tQGbIbICti7RXLVZdnc6VujATmLhDrHIsFxBME,3550
139
143
  udata/core/organization/metrics.py,sha256=45NDcsFV-oJJQUuq6AyIDXjR-RNubwYWF5-Ke8KrPDY,777
140
- udata/core/organization/models.py,sha256=Y7iFRQY6m87nKZNUMK9qsZJXZ6xzA6t2q_YxAm8K7Dg,8315
144
+ udata/core/organization/models.py,sha256=aLEqb7F1J7I9TKzSrODMB2Ecf7IxMqYmlgrZvLIx1k8,8387
141
145
  udata/core/organization/notifications.py,sha256=j-2LIHZ5y5QuVietWAWOrAqf4v1HMCtSDZ0w7V-z_1c,763
142
146
  udata/core/organization/permissions.py,sha256=cNIPiPgXVW2hvKqbuKHA_62tX2xaT8jiVJ3BEfnsHn0,1299
143
147
  udata/core/organization/rdf.py,sha256=ZSoxyZDj_5g6hv7lbTIy6bIW3xwvQy27rWNgJwtZ6LE,1762
@@ -273,7 +277,7 @@ udata/harvest/signals.py,sha256=wlXTi1E7rIVyNvxw0yUqyN5gF3thg276LAOmAF9vDJY,1338
273
277
  udata/harvest/tasks.py,sha256=0VhefKCQJSU_puTpdKOpvt3WORXHAFWGEB-R_MhB12M,1981
274
278
  udata/harvest/backends/__init__.py,sha256=qcLhHKWO97TeWd93ZwymG_Cc9FO7sMM7h4fs6XYdtS8,447
275
279
  udata/harvest/backends/base.py,sha256=oaPQcQ0onIXH5ofUtWH5sM6_5_wSBLawHSOjeeoG6jQ,12258
276
- udata/harvest/backends/dcat.py,sha256=SCbWorkuNzM7OvzFzBh1Is4QTWquOcnCLa_yd3HbgW0,14846
280
+ udata/harvest/backends/dcat.py,sha256=q5v6sUm8xBFYH437S3MUTAu40Ecka1Y6Oj6VB6xl2B4,15300
277
281
  udata/harvest/tests/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
278
282
  udata/harvest/tests/factories.py,sha256=CbQORC1OJ1_Agtv_3LjCXysNumjMYlROwZPSEAHo8sM,2005
279
283
  udata/harvest/tests/test_actions.py,sha256=7xSpouCAcf5p_bd38zHCyPN7sKWUUZXA7IlpI-yNVrQ,27603
@@ -560,7 +564,7 @@ udata/tests/__init__.py,sha256=BezijRRI6dPPiEWWjLsJFLrhhfsTZgcctcxhVfp4j70,2332
560
564
  udata/tests/es-fake-result.json,sha256=z0CX9Gs-NRj49dmtdFvw8ZKsAbMhDt97Na6JX3ucX34,3155
561
565
  udata/tests/helpers.py,sha256=aaifyevJ1Z8CZ8htRrl8OCG5hGcaHfj0lL8iMEKds9w,6022
562
566
  udata/tests/models.py,sha256=_V0smMb1Z6p3aZv6PorzNN-HiNt_B46Ox1fqXrTJEqk,238
563
- udata/tests/plugin.py,sha256=puoSIbE5vL6eum0YAeGhE2ARwxWstC6ua-rXJC9Z5fI,11044
567
+ udata/tests/plugin.py,sha256=DXP0H1Sm2fc-okGSKKBOgH8D8x4fl4_1OVhakgQLz4w,11278
564
568
  udata/tests/schemas.json,sha256=szM1jDpkogfOG4xWbjIGjLgG8l9-ZyE3JKQtecJyD1E,4990
565
569
  udata/tests/test_activity.py,sha256=spWfhueuLze0kD-pAnegiL3_Kv5io155jQuFI4sjN7I,3258
566
570
  udata/tests/test_discussions.py,sha256=zPvKOdcTNGXrvHFp9zqjhKE2fqgUkhb_1F98egXYCL0,31036
@@ -579,10 +583,11 @@ udata/tests/test_topics.py,sha256=r7Y0BW0Z5obld9ASs2Ck9AhykgBtmMedZmL2Bfz_rDw,13
579
583
  udata/tests/test_transfer.py,sha256=Y0adyR3CswK2Vvao7DgpP_19b8K6XwNO2dvbKlmnlD0,8036
580
584
  udata/tests/test_uris.py,sha256=FQJvz7RXkr21tZ7rZp2uwgZUHk88PxhYId-jXSAl9GM,8519
581
585
  udata/tests/test_utils.py,sha256=5awzhJlbnLga0mRXNR2mBGW_kGuAIoQLEZEMQRKuaIM,7944
582
- udata/tests/api/__init__.py,sha256=Tz_WigHLDlnJNKOKzEAnJswkKiLtHlIpCE54-wgocgM,957
586
+ udata/tests/api/__init__.py,sha256=gIIB9OPiSs1A-u1bEIHlOesaCQzJ08SOLPuJd34LesE,1097
583
587
  udata/tests/api/test_auth_api.py,sha256=3Zhn2A29poZIcCJ_R9_-LkR3xOFUTw1aTquiZVXQ2F0,20306
584
588
  udata/tests/api/test_base_api.py,sha256=DRX5nuFIj51GFmMIAxUzoW1yiq1apNgr1vS4U4agzeg,2319
585
589
  udata/tests/api/test_contact_points.py,sha256=MJm8B06iaUqIZCqxll3NViFwUCxwqZZ4u9e9s1h8MgU,1056
590
+ udata/tests/api/test_dataservices_api.py,sha256=eIo0b18BSSRocyf9pTChzDnKotxxLMq8R1tX53JdH_M,9743
586
591
  udata/tests/api/test_datasets_api.py,sha256=zqRtC61NvYUNum4kBTjA1W87JAIobA4k564rm-U6c98,81704
587
592
  udata/tests/api/test_fields.py,sha256=OW85Z5MES5HeWOpapeem8OvR1cIcrqW-xMWpdZO4LZ8,1033
588
593
  udata/tests/api/test_follow_api.py,sha256=0h54P_Dfbo07u6tg0Rbai1WWgWb19ZLN2HGv4oLCWfg,3383
@@ -676,9 +681,9 @@ udata/translations/pt/LC_MESSAGES/udata.mo,sha256=uttB2K8VsqzkEQG-5HfTtFms_3LtV9
676
681
  udata/translations/pt/LC_MESSAGES/udata.po,sha256=8Ql1Lp7Z9KLnvp-qRxw-NhFu1p35Xj-q6Jg9JHsYhcw,43733
677
682
  udata/translations/sr/LC_MESSAGES/udata.mo,sha256=US8beNIMPxP5h-zD_jfP1TheDDd4DdRVS5UIiY5XVZ8,28553
678
683
  udata/translations/sr/LC_MESSAGES/udata.po,sha256=TM0yMDvKRljyOzgZZMlTX6OfpF6OC4Ngf_9Zc8n6ayA,50313
679
- udata-8.0.1.dev28882.dist-info/LICENSE,sha256=V8j_M8nAz8PvAOZQocyRDX7keai8UJ9skgmnwqETmdY,34520
680
- udata-8.0.1.dev28882.dist-info/METADATA,sha256=cEdat6StNS-anDd93aAv8DmBXGhqmqFEHjr-0fBl920,122297
681
- udata-8.0.1.dev28882.dist-info/WHEEL,sha256=DZajD4pwLWue70CAfc7YaxT1wLUciNBvN_TTcvXpltE,110
682
- udata-8.0.1.dev28882.dist-info/entry_points.txt,sha256=3SKiqVy4HUqxf6iWspgMqH8d88Htk6KoLbG1BU-UddQ,451
683
- udata-8.0.1.dev28882.dist-info/top_level.txt,sha256=39OCg-VWFWOq4gCKnjKNu-s3OwFlZIu_dVH8Gl6ndHw,12
684
- udata-8.0.1.dev28882.dist-info/RECORD,,
684
+ udata-8.0.1.dev28979.dist-info/LICENSE,sha256=V8j_M8nAz8PvAOZQocyRDX7keai8UJ9skgmnwqETmdY,34520
685
+ udata-8.0.1.dev28979.dist-info/METADATA,sha256=fpTuwFUj0yuxHPnVq5azKMEVD0IBEYgrwABWlmCkFbI,122487
686
+ udata-8.0.1.dev28979.dist-info/WHEEL,sha256=DZajD4pwLWue70CAfc7YaxT1wLUciNBvN_TTcvXpltE,110
687
+ udata-8.0.1.dev28979.dist-info/entry_points.txt,sha256=3SKiqVy4HUqxf6iWspgMqH8d88Htk6KoLbG1BU-UddQ,451
688
+ udata-8.0.1.dev28979.dist-info/top_level.txt,sha256=39OCg-VWFWOq4gCKnjKNu-s3OwFlZIu_dVH8Gl6ndHw,12
689
+ udata-8.0.1.dev28979.dist-info/RECORD,,