udata 8.0.1.dev28859__py2.py3-none-any.whl → 8.0.1.dev28928__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
 
@@ -321,10 +322,10 @@ def init_app(app):
321
322
  # Load all core APIs
322
323
  import udata.core.activity.api # noqa
323
324
  import udata.core.spatial.api # noqa
324
- import udata.core.metrics.api # noqa
325
325
  import udata.core.user.api # noqa
326
326
  import udata.core.dataset.api # noqa
327
327
  import udata.core.dataset.apiv2 # noqa
328
+ import udata.core.dataservices.api # noqa
328
329
  import udata.core.discussions.api # noqa
329
330
  import udata.core.reuse.api # noqa
330
331
  import udata.core.reuse.apiv2 # noqa
@@ -351,5 +352,6 @@ def init_app(app):
351
352
  app.register_blueprint(apiv1_blueprint)
352
353
  app.register_blueprint(apiv2_blueprint)
353
354
 
354
- oauth2.init_app(app)
355
+ from udata.api.oauth2 import init_app as oauth2_init_app
356
+ oauth2_init_app(app)
355
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)
@@ -5,7 +5,7 @@ import click
5
5
  from flask import current_app
6
6
 
7
7
  from udata.commands import cli, success
8
- from udata.models import User, Dataset, Reuse, Organization, Site
8
+ from udata.models import User, Dataset, Reuse, Organization, Site, GeoZone
9
9
 
10
10
  log = logging.getLogger(__name__)
11
11
 
@@ -24,11 +24,12 @@ def grp():
24
24
  help='Compute datasets metrics')
25
25
  @click.option('-r', '--reuses', is_flag=True, help='Compute reuses metrics')
26
26
  @click.option('-u', '--users', is_flag=True, help='Compute users metrics')
27
+ @click.option('-g', '--geozones', is_flag=True, help='Compute geo levels metrics')
27
28
  @click.option('--drop', is_flag=True, help='Clear old metrics before computing new ones')
28
29
  def update(site=False, organizations=False, users=False, datasets=False,
29
- reuses=False, drop=False):
30
+ reuses=False, geozones = False, drop=False):
30
31
  '''Update all metrics for the current date'''
31
- do_all = not any((site, organizations, users, datasets, reuses))
32
+ do_all = not any((site, organizations, users, datasets, reuses, geozones))
32
33
 
33
34
  if do_all or site:
34
35
  log.info('Update site metrics')
@@ -114,4 +115,18 @@ def update(site=False, organizations=False, users=False, datasets=False,
114
115
  except Exception as e:
115
116
  log.info(f'Error during update: {e}')
116
117
  continue
118
+
119
+ if do_all or geozones:
120
+ log.info('Update GeoZone metrics')
121
+ all_geozones = GeoZone.objects.timeout(False)
122
+ with click.progressbar(all_geozones, length=GeoZone.objects.count()) as geozones_bar:
123
+ for geozone in geozones_bar:
124
+ try:
125
+ if drop:
126
+ geozone.metrics.clear()
127
+ geozone.count_datasets()
128
+ except Exception as e:
129
+ log.info(f'Error during update: {e}')
130
+ continue
131
+
117
132
  success('All metrics have been updated')
@@ -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
udata/core/spatial/api.py CHANGED
@@ -162,11 +162,6 @@ class SpatialCoverageAPI(API):
162
162
  features = []
163
163
 
164
164
  for zone in GeoZone.objects(level=level.id):
165
- # fetch nested levels IDs
166
- ids = []
167
- ids.append(zone.id)
168
- # Count datasets in zone
169
- nb_datasets = Dataset.objects(spatial__zones__in=ids).count()
170
165
  features.append({
171
166
  'id': zone.id,
172
167
  'type': 'Feature',
@@ -174,7 +169,7 @@ class SpatialCoverageAPI(API):
174
169
  'name': _(zone.name),
175
170
  'code': zone.code,
176
171
  'uri': zone.uri,
177
- 'datasets': nb_datasets
172
+ 'datasets': zone.metrics.get('datasets', 0)
178
173
  }
179
174
  })
180
175
 
@@ -3,6 +3,7 @@ from werkzeug.local import LocalProxy
3
3
  from werkzeug.utils import cached_property
4
4
 
5
5
  from udata.app import cache
6
+ from udata.core.metrics.models import WithMetrics
6
7
  from udata.uris import endpoint_for
7
8
  from udata.i18n import _, get_locale, language
8
9
  from udata.mongo import db
@@ -21,7 +22,6 @@ class GeoLevel(db.Document):
21
22
  max_value=ADMIN_LEVEL_MAX,
22
23
  default=100)
23
24
 
24
-
25
25
  class GeoZoneQuerySet(db.BaseQuerySet):
26
26
 
27
27
  def resolve(self, geoid, id_only=False):
@@ -40,7 +40,7 @@ class GeoZoneQuerySet(db.BaseQuerySet):
40
40
  return result.id if id_only and result else result
41
41
 
42
42
 
43
- class GeoZone(db.Document):
43
+ class GeoZone(WithMetrics, db.Document):
44
44
  SEPARATOR = ':'
45
45
 
46
46
  id = db.StringField(primary_key=True)
@@ -101,6 +101,11 @@ class GeoZone(db.Document):
101
101
  def external_url(self):
102
102
  return endpoint_for('territories.territory', territory=self, _external=True)
103
103
 
104
+ def count_datasets(self):
105
+ from udata.models import Dataset
106
+ self.metrics['datasets'] = Dataset.objects(spatial__zones=self.id).visible().count()
107
+ self.save()
108
+
104
109
  def toGeoJSON(self):
105
110
  return {
106
111
  'id': self.id,
@@ -0,0 +1,7 @@
1
+ from udata.core.spatial.models import GeoZone
2
+ from udata.tasks import job
3
+
4
+ @job('compute-geozones-metrics')
5
+ def compute_geozones_metrics(self):
6
+ for geozone in GeoZone.objects.timeout(False):
7
+ geozone.count_datasets()
@@ -10,6 +10,7 @@ from udata.core.dataset.factories import DatasetFactory
10
10
  from udata.core.spatial.factories import (
11
11
  SpatialCoverageFactory, GeoZoneFactory, GeoLevelFactory
12
12
  )
13
+ from udata.core.spatial.tasks import compute_geozones_metrics
13
14
 
14
15
 
15
16
  class SpatialApiTest(APITestCase):
@@ -229,6 +230,31 @@ class SpatialApiTest(APITestCase):
229
230
  'features': [],
230
231
  })
231
232
 
233
+ def test_coverage_datasets_count(self):
234
+ GeoLevelFactory(id='fr:commune')
235
+ paris = GeoZoneFactory(
236
+ id='fr:commune:75056', level='fr:commune',
237
+ name='Paris', code='75056')
238
+ arles = GeoZoneFactory(
239
+ id='fr:commune:13004', level='fr:commune',
240
+ name='Arles', code='13004')
241
+
242
+ for _ in range(3):
243
+ DatasetFactory(
244
+ spatial=SpatialCoverageFactory(zones=[paris.id]))
245
+ for _ in range(2):
246
+ DatasetFactory(
247
+ spatial=SpatialCoverageFactory(zones=[arles.id]))
248
+
249
+ compute_geozones_metrics()
250
+
251
+ response = self.get(url_for('api.spatial_coverage', level='fr:commune'))
252
+ self.assert200(response)
253
+ self.assertEqual(response.json['features'][0]['id'], 'fr:commune:13004')
254
+ self.assertEqual(response.json['features'][0]['properties']['datasets'], 2)
255
+ self.assertEqual(response.json['features'][1]['id'], 'fr:commune:75056')
256
+ self.assertEqual(response.json['features'][1]['properties']['datasets'], 3)
257
+
232
258
 
233
259
  class SpatialTerritoriesApiTest(APITestCase):
234
260
  modules = []
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
udata/tasks.py CHANGED
@@ -162,6 +162,7 @@ def init_app(app):
162
162
  import udata.core.tags.tasks # noqa
163
163
  import udata.core.activity.tasks # noqa
164
164
  import udata.core.dataset.tasks # noqa
165
+ import udata.core.spatial.tasks # noqa
165
166
  import udata.core.reuse.tasks # noqa
166
167
  import udata.core.user.tasks # noqa
167
168
  import udata.core.organization.tasks # noqa
@@ -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.dev28859
3
+ Version: 8.0.1.dev28928
4
4
  Summary: Open data portal
5
5
  Home-page: https://github.com/opendatateam/udata
6
6
  Author: Opendata Team
@@ -137,7 +137,9 @@ It is collectively taken care of by members of the
137
137
 
138
138
  ## Current (in progress)
139
139
 
140
- - Nothing yet
140
+ - Add dataservices in beta [#2986](https://github.com/opendatateam/udata/pull/2986)
141
+ - Remove deprecated `metrics_for` route [#3022](https://github.com/opendatateam/udata/pull/3022)
142
+ - Fix spatial coverage fetching perfs. Need to schedule `compute-geozones-metrics` [#3018](https://github.com/opendatateam/udata/pull/3018)
141
143
 
142
144
  ## 8.0.0 (2024-04-23)
143
145
 
@@ -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,12 +10,12 @@ 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
16
17
  udata/tags.py,sha256=8MvgyjfUz8O71tCV0V1fQV4_0C2P-SLFrm2IA2QfNDs,595
17
- udata/tasks.py,sha256=WR07NfqRMpEBNOnsXZ9myvDDSVfx4R5VZCi3CgO53A8,4886
18
+ udata/tasks.py,sha256=Dyjoxp6_IuPZSnKi6KmaFrFvnyCM5EF5g_W-jG0qYIo,4930
18
19
  udata/terms.md,sha256=nFx978tUQ3vTEv6POykXaZvcQ5e_gcvmO4ZgcfbSWXo,187
19
20
  udata/tracking.py,sha256=iaGqN9NCqcEAayemJe9a-3N8fO--ixBXCUvQ7jRxbhA,359
20
21
  udata/uris.py,sha256=_RaQSy6ghs2IHJQkDFaYpM2e--VA3p1D6awm5ybueAQ,3489
@@ -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=7AZWPTUCt-ib0FlPm-lEInFdHB1cruPjq-n_fLsRWRw,11278
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
@@ -122,9 +126,8 @@ udata/core/jobs/commands.py,sha256=kYoSluhbIG7MwnCSyO7ptD1V9LGwNjlabqlXSQ4hZq4,4
122
126
  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
- udata/core/metrics/api.py,sha256=vHfH-4n2QR-HE6bXMeAlj1Ma3RR6DRPWDj1LAFukDAM,262
126
- udata/core/metrics/commands.py,sha256=_365QvZ1Kt3qJPGlMg2CkBE4gwe7ZM5ye6_O43FFt28,4493
127
- udata/core/metrics/models.py,sha256=v0xlLxl2Y9QdILW4DTZ1WzUy2a4XTNepdLNKmb8T8LY,287
129
+ udata/core/metrics/commands.py,sha256=74L6PUJyaTnr4w-_redN9ucY0v-3Vi4QumWHKZ__DYE,5143
130
+ udata/core/metrics/models.py,sha256=k4kgKjAViQ1XO1F6n-iRfF4eT9cHoGqjQP4e8vC2vmk,291
128
131
  udata/core/metrics/signals.py,sha256=Xs_k-9lWzsoZU5Joli-41FNgvIVvbAejkjSFB6DHxh0,176
129
132
  udata/core/metrics/tasks.py,sha256=Z7La3-zPB5j6qJoPKr_MgjNZhqscZCmNLPa711ZBkdY,797
130
133
  udata/core/organization/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
@@ -138,7 +141,7 @@ udata/core/organization/csv.py,sha256=x0afMfhWYvx_karwuw5jXqBhMbzkrsiEuAq1wTVurZ
138
141
  udata/core/organization/factories.py,sha256=5BABVcDhEChRhJsDfCDm8WyJG4l9j3H1_OFZa3VtlVs,646
139
142
  udata/core/organization/forms.py,sha256=JXXv4tQGbIbICti7RXLVZdnc6VujATmLhDrHIsFxBME,3550
140
143
  udata/core/organization/metrics.py,sha256=45NDcsFV-oJJQUuq6AyIDXjR-RNubwYWF5-Ke8KrPDY,777
141
- udata/core/organization/models.py,sha256=Y7iFRQY6m87nKZNUMK9qsZJXZ6xzA6t2q_YxAm8K7Dg,8315
144
+ udata/core/organization/models.py,sha256=aLEqb7F1J7I9TKzSrODMB2Ecf7IxMqYmlgrZvLIx1k8,8387
142
145
  udata/core/organization/notifications.py,sha256=j-2LIHZ5y5QuVietWAWOrAqf4v1HMCtSDZ0w7V-z_1c,763
143
146
  udata/core/organization/permissions.py,sha256=cNIPiPgXVW2hvKqbuKHA_62tX2xaT8jiVJ3BEfnsHn0,1299
144
147
  udata/core/organization/rdf.py,sha256=ZSoxyZDj_5g6hv7lbTIy6bIW3xwvQy27rWNgJwtZ6LE,1762
@@ -184,17 +187,18 @@ udata/core/spam/signals.py,sha256=4VVLyC2I39LFAot4P56nHzY0xumjMBDz_N0Ff_kgBd0,15
184
187
  udata/core/spam/tests/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
185
188
  udata/core/spam/tests/test_spam.py,sha256=W1Ck_rsnURhFi0fy5xOO0CPpW9MuUFbr-NmPZdk5R4Q,676
186
189
  udata/core/spatial/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
187
- udata/core/spatial/api.py,sha256=tlApg3JpqbO6CwEwp1LzZ1hyPQreVEgF3H5yrcAPE74,5848
190
+ udata/core/spatial/api.py,sha256=8viSuSSfjZY6GrPj5Ax9WJIYr9KD4YBWAqSd3vhQz50,5666
188
191
  udata/core/spatial/api_fields.py,sha256=ymGK3FyE2KNlASOU5GiPgZGeBjjOqnOyCOcRCbuw3D8,2629
189
192
  udata/core/spatial/commands.py,sha256=qdJ4mUg3NUTrGx6L5MKPh1usvxmeCn8xMCZNGKyjWYE,6914
190
193
  udata/core/spatial/constants.py,sha256=u_4OwAAVYqT0VX6nw_Hc4wIRUPcYww19EFJcnWuMZNo,146
191
194
  udata/core/spatial/factories.py,sha256=GxUU8nRtSoGPw2m3Q-al0PYps2oSnlqC-bM7Eeu14rk,3288
192
195
  udata/core/spatial/forms.py,sha256=tXlYKge5Rc8L8pOjfUo1-9howgVJZDAh7q8xTIlbBww,3218
193
196
  udata/core/spatial/geoids.py,sha256=UqCto4dtQYPXOxyG7sx_0npzM6yvId40ngw3eAlmioQ,1159
194
- udata/core/spatial/models.py,sha256=8yvjj6SjQcBqPVfmBdRzQRHi9RZNTqdENYzkjlAeSN8,5021
197
+ udata/core/spatial/models.py,sha256=xAc44TRnoThYvHvHVHvKZWHHQVfTWbT6HQr_Q79oSnM,5268
198
+ udata/core/spatial/tasks.py,sha256=UPdq3bWR-xW87BWaPVbWVi1IEC3jJyiuFoW2NmvEYQs,228
195
199
  udata/core/spatial/translations.py,sha256=7BjZAMPObm1fwM5U_wnrlpcG-1FtMoS6Z5E3h3Y3sog,533
196
200
  udata/core/spatial/tests/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
197
- udata/core/spatial/tests/test_api.py,sha256=BySPgLpHvWs3vBIX2_7vJkuqfrFBIsTHtdg6PXOsE_U,10171
201
+ udata/core/spatial/tests/test_api.py,sha256=eU-hwsUFtzguN4JfqA-R_qH_7ZNv5odhvRYPU6nTvvE,11317
198
202
  udata/core/spatial/tests/test_fields.py,sha256=23MBhrkGhYkQtWcRbj8TsPsUPT5QWhR-C9sohEKpQrU,9744
199
203
  udata/core/spatial/tests/test_geoid.py,sha256=ovVphCxHb5a-iWl7eoLASRAFUY0CGfNEq-MuqHqTPgE,1533
200
204
  udata/core/spatial/tests/test_models.py,sha256=SZx3NhngXWgVon5BKtP0pPPEdvxGTvHQlYFE-fewzTU,951
@@ -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.dev28859.dist-info/LICENSE,sha256=V8j_M8nAz8PvAOZQocyRDX7keai8UJ9skgmnwqETmdY,34520
680
- udata-8.0.1.dev28859.dist-info/METADATA,sha256=3rqxzk4v1iCCTubvJDJPsW_KbaImLKC-aSY3HhvlV8A,122074
681
- udata-8.0.1.dev28859.dist-info/WHEEL,sha256=DZajD4pwLWue70CAfc7YaxT1wLUciNBvN_TTcvXpltE,110
682
- udata-8.0.1.dev28859.dist-info/entry_points.txt,sha256=3SKiqVy4HUqxf6iWspgMqH8d88Htk6KoLbG1BU-UddQ,451
683
- udata-8.0.1.dev28859.dist-info/top_level.txt,sha256=39OCg-VWFWOq4gCKnjKNu-s3OwFlZIu_dVH8Gl6ndHw,12
684
- udata-8.0.1.dev28859.dist-info/RECORD,,
684
+ udata-8.0.1.dev28928.dist-info/LICENSE,sha256=V8j_M8nAz8PvAOZQocyRDX7keai8UJ9skgmnwqETmdY,34520
685
+ udata-8.0.1.dev28928.dist-info/METADATA,sha256=tB-T4SHz3x5TYr-Ztafc3klwqZmf9NdtIQD6Ak1GYzY,122381
686
+ udata-8.0.1.dev28928.dist-info/WHEEL,sha256=DZajD4pwLWue70CAfc7YaxT1wLUciNBvN_TTcvXpltE,110
687
+ udata-8.0.1.dev28928.dist-info/entry_points.txt,sha256=3SKiqVy4HUqxf6iWspgMqH8d88Htk6KoLbG1BU-UddQ,451
688
+ udata-8.0.1.dev28928.dist-info/top_level.txt,sha256=39OCg-VWFWOq4gCKnjKNu-s3OwFlZIu_dVH8Gl6ndHw,12
689
+ udata-8.0.1.dev28928.dist-info/RECORD,,
udata/core/metrics/api.py DELETED
@@ -1,10 +0,0 @@
1
- from flask import abort
2
-
3
- from udata.api import api, API
4
-
5
-
6
- @api.route('/metrics/<id>', endpoint='metrics')
7
- class MetricsAPI(API):
8
- @api.doc('metrics_for')
9
- def get(self, id):
10
- abort(501, 'This endpoint was deprecated because of metrics refactoring')