udata 7.0.8.dev28841__py2.py3-none-any.whl → 9.0.1.dev29390__py2.py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of udata might be problematic. Click here for more details.

Files changed (73) hide show
  1. udata/__init__.py +1 -1
  2. udata/api/__init__.py +6 -4
  3. udata/api/oauth2.py +2 -1
  4. udata/api_fields.py +254 -0
  5. udata/commands/purge.py +8 -2
  6. udata/core/badges/models.py +2 -1
  7. udata/core/dataservices/__init__.py +0 -0
  8. udata/core/dataservices/api.py +92 -0
  9. udata/core/dataservices/models.py +142 -0
  10. udata/core/dataservices/permissions.py +7 -0
  11. udata/core/dataservices/tasks.py +25 -0
  12. udata/core/dataset/apiv2.py +2 -0
  13. udata/core/dataset/csv.py +8 -1
  14. udata/core/dataset/models.py +1 -0
  15. udata/core/dataset/rdf.py +77 -15
  16. udata/core/metrics/commands.py +18 -3
  17. udata/core/metrics/models.py +2 -3
  18. udata/core/organization/api_fields.py +28 -3
  19. udata/core/organization/csv.py +5 -3
  20. udata/core/organization/models.py +3 -1
  21. udata/core/owned.py +39 -2
  22. udata/core/reuse/csv.py +3 -0
  23. udata/core/site/api.py +4 -1
  24. udata/core/spatial/api.py +5 -10
  25. udata/core/spatial/models.py +7 -2
  26. udata/core/spatial/tasks.py +7 -0
  27. udata/core/spatial/tests/test_api.py +26 -0
  28. udata/core/user/api.py +11 -7
  29. udata/core/user/models.py +13 -2
  30. udata/harvest/backends/base.py +93 -103
  31. udata/harvest/backends/dcat.py +65 -90
  32. udata/harvest/tasks.py +3 -13
  33. udata/harvest/tests/dcat/bnodes.xml +10 -1
  34. udata/harvest/tests/dcat/catalog.xml +1 -0
  35. udata/harvest/tests/factories.py +13 -6
  36. udata/harvest/tests/test_actions.py +2 -2
  37. udata/harvest/tests/test_base_backend.py +9 -5
  38. udata/harvest/tests/test_dcat_backend.py +17 -1
  39. udata/rdf.py +4 -0
  40. udata/routing.py +6 -0
  41. udata/settings.py +4 -1
  42. udata/static/admin.css +2 -2
  43. udata/static/admin.css.map +1 -1
  44. udata/static/chunks/{0.6f1698738c9b0618b673.js → 0.93c3ae13b5b94753ee80.js} +3 -3
  45. udata/static/chunks/0.93c3ae13b5b94753ee80.js.map +1 -0
  46. udata/static/chunks/{14.f4037a917d5364cb564b.js → 14.e64890872b31c55fcdf7.js} +2 -2
  47. udata/static/chunks/14.e64890872b31c55fcdf7.js.map +1 -0
  48. udata/static/chunks/{2.7c89fae92899be371ed3.js → 2.614b3e73b072982fd9b1.js} +2 -2
  49. udata/static/chunks/2.614b3e73b072982fd9b1.js.map +1 -0
  50. udata/static/chunks/{5.3dc97ea195d251881552.js → 5.48417db6b33328fa9d6a.js} +2 -2
  51. udata/static/chunks/5.48417db6b33328fa9d6a.js.map +1 -0
  52. udata/static/common.js +1 -1
  53. udata/static/common.js.map +1 -1
  54. udata/tasks.py +1 -0
  55. udata/tests/api/__init__.py +3 -0
  56. udata/tests/api/test_dataservices_api.py +236 -0
  57. udata/tests/api/test_organizations_api.py +78 -5
  58. udata/tests/api/test_user_api.py +47 -13
  59. udata/tests/dataservice/test_dataservice_tasks.py +46 -0
  60. udata/tests/dataset/test_dataset_rdf.py +17 -2
  61. udata/tests/plugin.py +5 -0
  62. udata/tests/site/test_site_rdf.py +16 -0
  63. {udata-7.0.8.dev28841.dist-info → udata-9.0.1.dev29390.dist-info}/METADATA +27 -1
  64. {udata-7.0.8.dev28841.dist-info → udata-9.0.1.dev29390.dist-info}/RECORD +68 -60
  65. udata/core/metrics/api.py +0 -10
  66. udata/static/chunks/0.6f1698738c9b0618b673.js.map +0 -1
  67. udata/static/chunks/14.f4037a917d5364cb564b.js.map +0 -1
  68. udata/static/chunks/2.7c89fae92899be371ed3.js.map +0 -1
  69. udata/static/chunks/5.3dc97ea195d251881552.js.map +0 -1
  70. {udata-7.0.8.dev28841.dist-info → udata-9.0.1.dev29390.dist-info}/LICENSE +0 -0
  71. {udata-7.0.8.dev28841.dist-info → udata-9.0.1.dev29390.dist-info}/WHEEL +0 -0
  72. {udata-7.0.8.dev28841.dist-info → udata-9.0.1.dev29390.dist-info}/entry_points.txt +0 -0
  73. {udata-7.0.8.dev28841.dist-info → udata-9.0.1.dev29390.dist-info}/top_level.txt +0 -0
udata/__init__.py CHANGED
@@ -4,5 +4,5 @@
4
4
  udata
5
5
  '''
6
6
 
7
- __version__ = '7.0.8.dev'
7
+ __version__ = '9.0.1.dev'
8
8
  __description__ = 'Open data portal'
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
+
udata/commands/purge.py CHANGED
@@ -5,6 +5,7 @@ import click
5
5
  from udata.commands import cli, success
6
6
 
7
7
  from udata.core.dataset.tasks import purge_datasets
8
+ from udata.core.dataservices.tasks import purge_dataservices
8
9
  from udata.core.organization.tasks import purge_organizations
9
10
  from udata.core.reuse.tasks import purge_reuses
10
11
 
@@ -15,13 +16,14 @@ log = logging.getLogger(__name__)
15
16
  @click.option('-d', '--datasets', is_flag=True)
16
17
  @click.option('-r', '--reuses', is_flag=True)
17
18
  @click.option('-o', '--organizations', is_flag=True)
18
- def purge(datasets, reuses, organizations):
19
+ @click.option('--dataservices', is_flag=True)
20
+ def purge(datasets, reuses, organizations, dataservices):
19
21
  '''
20
22
  Permanently remove data flagged as deleted.
21
23
 
22
24
  If no model flag is given, all models are purged.
23
25
  '''
24
- purge_all = not any((datasets, reuses, organizations))
26
+ purge_all = not any((datasets, reuses, organizations, dataservices))
25
27
 
26
28
  if purge_all or datasets:
27
29
  log.info('Purging datasets')
@@ -35,4 +37,8 @@ def purge(datasets, reuses, organizations):
35
37
  log.info('Purging organizations')
36
38
  purge_organizations()
37
39
 
40
+ if purge_all or dataservices:
41
+ log.info('Purging dataservices')
42
+ purge_dataservices()
43
+
38
44
  success('Done')
@@ -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,92 @@
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 udata.core.followers.api import FollowAPI
10
+ from .models import Dataservice
11
+
12
+ ns = api.namespace('dataservices', 'Dataservices related operations (beta)')
13
+
14
+
15
+ @ns.route('/', endpoint='dataservices')
16
+ class DataservicesAPI(API):
17
+ '''Dataservices collection endpoint'''
18
+ @api.doc('list_dataservices')
19
+ @api.expect(Dataservice.__index_parser__)
20
+ @api.marshal_with(Dataservice.__page_fields__)
21
+ def get(self):
22
+ '''List or search all dataservices'''
23
+ query = Dataservice.objects.visible()
24
+
25
+ return Dataservice.apply_sort_filters_and_pagination(query)
26
+
27
+ @api.secure
28
+ @api.doc('create_dataservice', responses={400: 'Validation error'})
29
+ @api.expect(Dataservice.__write_fields__)
30
+ @api.marshal_with(Dataservice.__read_fields__, code=201)
31
+ def post(self):
32
+ dataservice = patch(Dataservice(), request)
33
+ if not dataservice.owner and not dataservice.organization:
34
+ dataservice.owner = current_user._get_current_object()
35
+
36
+ try:
37
+ dataservice.save()
38
+ except mongoengine.errors.ValidationError as e:
39
+ api.abort(400, e.message)
40
+
41
+ return dataservice, 201
42
+
43
+
44
+ @ns.route('/<dataservice:dataservice>/', endpoint='dataservice')
45
+ class DataserviceAPI(API):
46
+ @api.doc('get_dataservice')
47
+ @api.marshal_with(Dataservice.__read_fields__)
48
+ def get(self, dataservice):
49
+ if dataservice.deleted_at and not OwnablePermission(dataservice).can():
50
+ api.abort(410, 'Dataservice has been deleted')
51
+ return dataservice
52
+
53
+ @api.secure
54
+ @api.doc('update_dataservice', responses={400: 'Validation error'})
55
+ @api.expect(Dataservice.__write_fields__)
56
+ @api.marshal_with(Dataservice.__read_fields__)
57
+ def patch(self, dataservice):
58
+ if dataservice.deleted_at:
59
+ api.abort(410, 'dataservice has been deleted')
60
+
61
+ OwnablePermission(dataservice).test()
62
+
63
+ patch(dataservice, request)
64
+ dataservice.modified_at = datetime.utcnow()
65
+
66
+ try:
67
+ dataservice.save()
68
+ return dataservice
69
+ except mongoengine.errors.ValidationError as e:
70
+ api.abort(400, e.message)
71
+
72
+ @api.secure
73
+ @api.doc('delete_dataservice')
74
+ @api.response(204, 'dataservice deleted')
75
+ def delete(self, dataservice):
76
+ if dataservice.deleted_at:
77
+ api.abort(410, 'dataservice has been deleted')
78
+
79
+ OwnablePermission(dataservice).test()
80
+ dataservice.deleted_at = datetime.utcnow()
81
+ dataservice.modified_at = datetime.utcnow()
82
+ dataservice.save()
83
+
84
+ return '', 204
85
+
86
+
87
+ @ns.route('/<id>/followers/', endpoint='dataservice_followers')
88
+ @ns.doc(get={'id': 'list_dataservice_followers'},
89
+ post={'id': 'follow_dataservice'},
90
+ delete={'id': 'unfollow_dataservice'})
91
+ class DataserviceFollowersAPI(FollowAPI):
92
+ model = Dataservice
@@ -0,0 +1,142 @@
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
+ import udata.core.contact_point.api_fields as contact_api_fields
7
+ import udata.core.dataset.api_fields as datasets_api_fields
8
+ from udata.i18n import lazy_gettext as _
9
+
10
+ from udata.models import db, Discussion, Follow
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)
131
+
132
+ @property
133
+ def is_hidden(self):
134
+ return self.private or self.deleted_at or self.archived_at
135
+
136
+ def count_discussions(self):
137
+ self.metrics['discussions'] = Discussion.objects(subject=self, closed=None).count()
138
+ self.save()
139
+
140
+ def count_followers(self):
141
+ self.metrics['followers'] = Follow.objects(until=None).followers(self).count()
142
+ self.save()
@@ -0,0 +1,7 @@
1
+ from udata.core.dataset.permissions import (
2
+ OwnablePermission
3
+ )
4
+
5
+ class DataserviceEditPermission(OwnablePermission):
6
+ '''Permissions to edit a Dataservice'''
7
+ pass
@@ -0,0 +1,25 @@
1
+ from celery.utils.log import get_task_logger
2
+
3
+ from udata.core.dataservices.models import Dataservice
4
+ # from udata.harvest.models import HarvestJob
5
+ from udata.models import (Follow, Discussion, Activity, Transfer)
6
+ from udata.tasks import job
7
+
8
+ log = get_task_logger(__name__)
9
+
10
+
11
+ @job('purge-dataservices')
12
+ def purge_dataservices(self):
13
+ for dataservice in Dataservice.objects(deleted_at__ne=None):
14
+ log.info(f'Purging dataservice {dataservice}')
15
+ # Remove followers
16
+ Follow.objects(following=dataservice).delete()
17
+ # Remove discussions
18
+ Discussion.objects(subject=dataservice).delete()
19
+ # Remove HarvestItem references
20
+ # TODO: uncomment when adding dataservice harvest
21
+ # HarvestJob.objects(items__dataservice=dataservice).update(set__items__S__dataservice=None)
22
+ # Remove associated Transfers
23
+ Transfer.objects(subject=dataservice).delete()
24
+ # Remove dataservice
25
+ dataservice.delete()
@@ -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)