udata 7.0.8.dev28841__py2.py3-none-any.whl → 8.0.1__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/__init__.py +1 -1
- udata/api/__init__.py +6 -4
- udata/api/oauth2.py +2 -1
- udata/api_fields.py +254 -0
- udata/core/badges/models.py +2 -1
- udata/core/dataservices/__init__.py +0 -0
- udata/core/dataservices/api.py +84 -0
- udata/core/dataservices/models.py +130 -0
- udata/core/dataset/apiv2.py +2 -0
- udata/core/dataset/models.py +1 -0
- udata/core/dataset/rdf.py +21 -1
- udata/core/metrics/commands.py +18 -3
- udata/core/metrics/models.py +2 -3
- udata/core/organization/api_fields.py +28 -3
- udata/core/organization/models.py +3 -1
- udata/core/owned.py +39 -2
- udata/core/spatial/api.py +5 -10
- udata/core/spatial/models.py +7 -2
- udata/core/spatial/tasks.py +7 -0
- udata/core/spatial/tests/test_api.py +26 -0
- udata/core/user/api.py +11 -7
- udata/core/user/models.py +13 -2
- udata/harvest/backends/dcat.py +14 -8
- udata/harvest/tests/dcat/catalog.xml +1 -0
- udata/harvest/tests/test_dcat_backend.py +3 -0
- udata/routing.py +6 -0
- udata/settings.py +4 -1
- udata/static/admin.css +2 -2
- udata/static/admin.css.map +1 -1
- udata/static/chunks/{0.6f1698738c9b0618b673.js → 0.93c3ae13b5b94753ee80.js} +3 -3
- udata/static/chunks/0.93c3ae13b5b94753ee80.js.map +1 -0
- udata/static/chunks/{14.f4037a917d5364cb564b.js → 14.e64890872b31c55fcdf7.js} +2 -2
- udata/static/chunks/14.e64890872b31c55fcdf7.js.map +1 -0
- udata/static/chunks/{2.7c89fae92899be371ed3.js → 2.614b3e73b072982fd9b1.js} +2 -2
- udata/static/chunks/2.614b3e73b072982fd9b1.js.map +1 -0
- udata/static/chunks/{5.3dc97ea195d251881552.js → 5.48417db6b33328fa9d6a.js} +2 -2
- udata/static/chunks/5.48417db6b33328fa9d6a.js.map +1 -0
- udata/static/common.js +1 -1
- udata/static/common.js.map +1 -1
- udata/tasks.py +1 -0
- udata/tests/api/__init__.py +3 -0
- udata/tests/api/test_dataservices_api.py +236 -0
- udata/tests/api/test_organizations_api.py +78 -5
- udata/tests/api/test_user_api.py +47 -13
- udata/tests/plugin.py +5 -0
- {udata-7.0.8.dev28841.dist-info → udata-8.0.1.dist-info}/METADATA +17 -3
- {udata-7.0.8.dev28841.dist-info → udata-8.0.1.dist-info}/RECORD +51 -46
- udata/core/metrics/api.py +0 -10
- udata/static/chunks/0.6f1698738c9b0618b673.js.map +0 -1
- udata/static/chunks/14.f4037a917d5364cb564b.js.map +0 -1
- udata/static/chunks/2.7c89fae92899be371ed3.js.map +0 -1
- udata/static/chunks/5.3dc97ea195d251881552.js.map +0 -1
- {udata-7.0.8.dev28841.dist-info → udata-8.0.1.dist-info}/LICENSE +0 -0
- {udata-7.0.8.dev28841.dist-info → udata-8.0.1.dist-info}/WHEEL +0 -0
- {udata-7.0.8.dev28841.dist-info → udata-8.0.1.dist-info}/entry_points.txt +0 -0
- {udata-7.0.8.dev28841.dist-info → udata-8.0.1.dist-info}/top_level.txt +0 -0
udata/__init__.py
CHANGED
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
|
|
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
|
-
|
|
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
|
|
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(
|
|
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
|
+
|
udata/core/badges/models.py
CHANGED
|
@@ -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)
|
udata/core/dataset/apiv2.py
CHANGED
|
@@ -8,6 +8,7 @@ from udata import search
|
|
|
8
8
|
from udata.api import apiv2, API, fields
|
|
9
9
|
from udata.utils import multi_to_dict, get_by
|
|
10
10
|
|
|
11
|
+
from udata.core.organization.api_fields import member_user_with_email_fields
|
|
11
12
|
from .api_fields import (
|
|
12
13
|
badge_fields,
|
|
13
14
|
org_ref_fields,
|
|
@@ -165,6 +166,7 @@ specific_resource_fields = apiv2.model('SpecificResource', {
|
|
|
165
166
|
apiv2.inherit('Badge', badge_fields)
|
|
166
167
|
apiv2.inherit('OrganizationReference', org_ref_fields)
|
|
167
168
|
apiv2.inherit('UserReference', user_ref_fields)
|
|
169
|
+
apiv2.inherit('MemberUserWithEmail', member_user_with_email_fields)
|
|
168
170
|
apiv2.inherit('Resource', resource_fields)
|
|
169
171
|
apiv2.inherit('SpatialCoverage', spatial_coverage_fields)
|
|
170
172
|
apiv2.inherit('TemporalCoverage', temporal_coverage_fields)
|
udata/core/dataset/models.py
CHANGED
udata/core/dataset/rdf.py
CHANGED
|
@@ -76,6 +76,16 @@ EU_RDF_REQUENCIES = {
|
|
|
76
76
|
EUFREQ.NEVER: 'punctual',
|
|
77
77
|
}
|
|
78
78
|
|
|
79
|
+
# Map High Value Datasets URIs to keyword categories
|
|
80
|
+
EU_HVD_CATEGORIES = {
|
|
81
|
+
"http://data.europa.eu/bna/c_164e0bf5": "Météorologiques",
|
|
82
|
+
"http://data.europa.eu/bna/c_a9135398": "Entreprises et propriété d'entreprises",
|
|
83
|
+
"http://data.europa.eu/bna/c_ac64a52d": "Géospatiales",
|
|
84
|
+
"http://data.europa.eu/bna/c_b79e35eb": "Mobilité",
|
|
85
|
+
"http://data.europa.eu/bna/c_dd313021": "Observation de la terre et environnement",
|
|
86
|
+
"http://data.europa.eu/bna/c_e1da4e07": "Statistiques"
|
|
87
|
+
}
|
|
88
|
+
|
|
79
89
|
|
|
80
90
|
class HTMLDetector(HTMLParser):
|
|
81
91
|
def __init__(self, *args, **kwargs):
|
|
@@ -469,9 +479,19 @@ def remote_url_from_rdf(rdf):
|
|
|
469
479
|
|
|
470
480
|
|
|
471
481
|
def theme_labels_from_rdf(rdf):
|
|
482
|
+
'''
|
|
483
|
+
Get theme labels to use as keywords.
|
|
484
|
+
Map HVD keywords from known URIs resources if HVD support is activated.
|
|
485
|
+
'''
|
|
472
486
|
for theme in rdf.objects(DCAT.theme):
|
|
473
487
|
if isinstance(theme, RdfResource):
|
|
474
|
-
|
|
488
|
+
uri = theme.identifier.toPython()
|
|
489
|
+
if current_app.config['HVD_SUPPORT'] and uri in EU_HVD_CATEGORIES:
|
|
490
|
+
label = EU_HVD_CATEGORIES[uri]
|
|
491
|
+
# Additionnally yield hvd keyword
|
|
492
|
+
yield 'hvd'
|
|
493
|
+
else:
|
|
494
|
+
label = rdf_value(theme, SKOS.prefLabel)
|
|
475
495
|
else:
|
|
476
496
|
label = theme.toPython()
|
|
477
497
|
if label:
|
udata/core/metrics/commands.py
CHANGED
|
@@ -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')
|
udata/core/metrics/models.py
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
|
-
from
|
|
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
|
|