udata 10.4.2.dev35475__py2.py3-none-any.whl → 10.4.3__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 (62) hide show
  1. udata/__init__.py +1 -1
  2. udata/api_fields.py +27 -2
  3. udata/commands/fixtures.py +11 -1
  4. udata/core/dataservices/api.py +12 -10
  5. udata/core/dataservices/apiv2.py +4 -1
  6. udata/core/dataservices/constants.py +19 -0
  7. udata/core/dataservices/models.py +54 -1
  8. udata/core/dataset/api.py +33 -27
  9. udata/core/dataset/api_fields.py +21 -0
  10. udata/core/dataset/apiv2.py +14 -11
  11. udata/core/dataset/models.py +60 -15
  12. udata/core/dataset/rdf.py +1 -1
  13. udata/core/dataset/tasks.py +3 -2
  14. udata/core/organization/api.py +11 -0
  15. udata/core/organization/models.py +29 -2
  16. udata/core/reuse/api.py +4 -5
  17. udata/core/reuse/api_fields.py +8 -0
  18. udata/core/reuse/apiv2.py +2 -0
  19. udata/core/reuse/models.py +18 -1
  20. udata/core/spatial/models.py +9 -0
  21. udata/core/user/models.py +11 -5
  22. udata/harvest/api.py +2 -1
  23. udata/harvest/tests/dcat/bnodes.xml +5 -0
  24. udata/harvest/tests/test_dcat_backend.py +1 -0
  25. udata/migrations/2025-06-18-clean-spatial-coverages.py +25 -0
  26. udata/static/chunks/{11.0f04e49a40a0a381bcce.js → 11.b6f741fcc366abfad9c4.js} +3 -3
  27. udata/static/chunks/{11.0f04e49a40a0a381bcce.js.map → 11.b6f741fcc366abfad9c4.js.map} +1 -1
  28. udata/static/chunks/{13.d9c1735d14038b94c17e.js → 13.2d06442dd9a05d9777b5.js} +2 -2
  29. udata/static/chunks/{13.d9c1735d14038b94c17e.js.map → 13.2d06442dd9a05d9777b5.js.map} +1 -1
  30. udata/static/chunks/{17.81c57c0dedf812e43013.js → 17.e8e4caaad5cb0cc0bacc.js} +2 -2
  31. udata/static/chunks/{17.81c57c0dedf812e43013.js.map → 17.e8e4caaad5cb0cc0bacc.js.map} +1 -1
  32. udata/static/chunks/{19.8da42e8359d72afc2618.js → 19.f03a102365af4315f9db.js} +3 -3
  33. udata/static/chunks/{19.8da42e8359d72afc2618.js.map → 19.f03a102365af4315f9db.js.map} +1 -1
  34. udata/static/chunks/{8.494b003a94383b142c18.js → 8.778091d55cd8ea39af6b.js} +2 -2
  35. udata/static/chunks/{8.494b003a94383b142c18.js.map → 8.778091d55cd8ea39af6b.js.map} +1 -1
  36. udata/static/common.js +1 -1
  37. udata/static/common.js.map +1 -1
  38. udata/tests/api/test_dataservices_api.py +78 -0
  39. udata/tests/api/test_follow_api.py +20 -0
  40. udata/tests/api/test_organizations_api.py +25 -0
  41. udata/tests/test_api_fields.py +35 -0
  42. udata/translations/ar/LC_MESSAGES/udata.mo +0 -0
  43. udata/translations/ar/LC_MESSAGES/udata.po +98 -38
  44. udata/translations/de/LC_MESSAGES/udata.mo +0 -0
  45. udata/translations/de/LC_MESSAGES/udata.po +98 -38
  46. udata/translations/es/LC_MESSAGES/udata.mo +0 -0
  47. udata/translations/es/LC_MESSAGES/udata.po +98 -38
  48. udata/translations/fr/LC_MESSAGES/udata.mo +0 -0
  49. udata/translations/fr/LC_MESSAGES/udata.po +98 -38
  50. udata/translations/it/LC_MESSAGES/udata.mo +0 -0
  51. udata/translations/it/LC_MESSAGES/udata.po +98 -38
  52. udata/translations/pt/LC_MESSAGES/udata.mo +0 -0
  53. udata/translations/pt/LC_MESSAGES/udata.po +98 -38
  54. udata/translations/sr/LC_MESSAGES/udata.mo +0 -0
  55. udata/translations/sr/LC_MESSAGES/udata.po +98 -38
  56. udata/translations/udata.pot +98 -38
  57. {udata-10.4.2.dev35475.dist-info → udata-10.4.3.dist-info}/METADATA +16 -4
  58. {udata-10.4.2.dev35475.dist-info → udata-10.4.3.dist-info}/RECORD +62 -61
  59. {udata-10.4.2.dev35475.dist-info → udata-10.4.3.dist-info}/LICENSE +0 -0
  60. {udata-10.4.2.dev35475.dist-info → udata-10.4.3.dist-info}/WHEEL +0 -0
  61. {udata-10.4.2.dev35475.dist-info → udata-10.4.3.dist-info}/entry_points.txt +0 -0
  62. {udata-10.4.2.dev35475.dist-info → udata-10.4.3.dist-info}/top_level.txt +0 -0
udata/__init__.py CHANGED
@@ -4,5 +4,5 @@
4
4
  udata
5
5
  """
6
6
 
7
- __version__ = "10.4.2.dev"
7
+ __version__ = "10.4.3"
8
8
  __description__ = "Open data portal"
udata/api_fields.py CHANGED
@@ -341,6 +341,9 @@ def generate_fields(**kwargs) -> Callable:
341
341
  continue # Do not override if the attribute is also callable like for Extras
342
342
 
343
343
  method = getattr(cls, method_name)
344
+ if isinstance(method, property):
345
+ method = method.fget
346
+
344
347
  if not callable(method):
345
348
  continue
346
349
 
@@ -356,7 +359,16 @@ def generate_fields(**kwargs) -> Callable:
356
359
  """
357
360
  return lambda o: method(o)
358
361
 
359
- read_fields[method_name] = restx_fields.String(
362
+ nested_fields: dict | None = additional_field_info.get("nested_fields")
363
+ if nested_fields is None:
364
+ # If there is no `nested_fields` convert the object to the string representation.
365
+ field_constructor = restx_fields.String
366
+ else:
367
+
368
+ def field_constructor(**kwargs):
369
+ return restx_fields.Nested(nested_fields, **kwargs)
370
+
371
+ read_fields[method_name] = field_constructor(
360
372
  attribute=make_lambda(method), **{"readonly": True, **additional_field_info}
361
373
  )
362
374
  if additional_field_info.get("show_as_ref", False):
@@ -505,7 +517,6 @@ def patch(obj, request) -> type:
505
517
  field = obj.__write_fields__.get(key)
506
518
  if field is not None and not field.readonly:
507
519
  model_attribute = getattr(obj.__class__, key)
508
-
509
520
  if hasattr(model_attribute, "from_input"):
510
521
  value = model_attribute.from_input(value)
511
522
  elif isinstance(model_attribute, mongoengine.fields.ListField) and isinstance(
@@ -531,6 +542,20 @@ def patch(obj, request) -> type:
531
542
  value["id"],
532
543
  document_type=db.resolve_model(value["class"]),
533
544
  )
545
+ elif value and isinstance(
546
+ model_attribute,
547
+ mongoengine.fields.EmbeddedDocumentField,
548
+ ):
549
+ embedded_field = model_attribute.document_type()
550
+ value = embedded_field._from_son(value)
551
+ elif value and isinstance(
552
+ model_attribute,
553
+ mongoengine.fields.EmbeddedDocumentListField,
554
+ ):
555
+ embedded_field = model_attribute.field.document_type()
556
+ # MongoEngine BaseDocument has a `from_json` method for string and a private `_from_son`
557
+ # but there is no public `from_son` to use
558
+ value = [embedded_field._from_son(embedded_value) for embedded_value in value]
534
559
 
535
560
  info = getattr(model_attribute, "__additional_field_info__", {})
536
561
 
@@ -54,16 +54,25 @@ UNWANTED_KEYS: dict[str, list[str]] = {
54
54
  "badges",
55
55
  "spatial",
56
56
  "quality",
57
+ "permissions",
57
58
  ],
58
59
  "resource": ["latest", "preview_url", "last_modified"],
59
60
  "organization": ["class", "page", "uri", "logo_thumbnail"],
60
- "reuse": ["datasets", "image_thumbnail", "page", "uri", "owner"],
61
+ "reuse": [
62
+ "datasets",
63
+ "image_thumbnail",
64
+ "page",
65
+ "uri",
66
+ "owner",
67
+ "permissions",
68
+ ],
61
69
  "community": [
62
70
  "dataset",
63
71
  "owner",
64
72
  "latest",
65
73
  "last_modified",
66
74
  "preview_url",
75
+ "permissions",
67
76
  ],
68
77
  "discussion": ["subject", "url", "class", "permissions"],
69
78
  "discussion_message": ["permissions"],
@@ -75,6 +84,7 @@ UNWANTED_KEYS: dict[str, list[str]] = {
75
84
  "owner",
76
85
  "self_api_url",
77
86
  "self_web_url",
87
+ "permissions",
78
88
  ],
79
89
  }
80
90
 
@@ -9,7 +9,7 @@ from flask_login import current_user
9
9
 
10
10
  from udata.api import API, api, fields
11
11
  from udata.api_fields import patch
12
- from udata.core.dataservices.permissions import OwnablePermission
12
+ from udata.core.dataservices.constants import DATASERVICE_ACCESS_TYPE_RESTRICTED
13
13
  from udata.core.dataset.models import Dataset
14
14
  from udata.core.followers.api import FollowAPI
15
15
  from udata.core.site.models import current_site
@@ -18,7 +18,6 @@ from udata.i18n import gettext as _
18
18
  from udata.rdf import RDF_EXTENSIONS, graph_response, negociate_content
19
19
 
20
20
  from .models import Dataservice
21
- from .permissions import DataserviceEditPermission
22
21
  from .rdf import dataservice_to_rdf
23
22
 
24
23
  ns = api.namespace("dataservices", "Dataservices related operations (beta)")
@@ -49,7 +48,8 @@ class DataservicesAPI(API):
49
48
  dataservice = patch(Dataservice(), request)
50
49
  if not dataservice.owner and not dataservice.organization:
51
50
  dataservice.owner = current_user._get_current_object()
52
-
51
+ if dataservice.access_type != DATASERVICE_ACCESS_TYPE_RESTRICTED:
52
+ dataservice.access_audiences = []
53
53
  dataservice.save()
54
54
  return dataservice, 201
55
55
 
@@ -97,7 +97,7 @@ class DataserviceAPI(API):
97
97
  @api.doc("get_dataservice")
98
98
  @api.marshal_with(Dataservice.__read_fields__)
99
99
  def get(self, dataservice):
100
- if not OwnablePermission(dataservice).can():
100
+ if not dataservice.permissions["edit"].can():
101
101
  if dataservice.private:
102
102
  api.abort(404)
103
103
  elif dataservice.deleted_at:
@@ -115,10 +115,12 @@ class DataserviceAPI(API):
115
115
  ):
116
116
  api.abort(410, "dataservice has been deleted")
117
117
 
118
- OwnablePermission(dataservice).test()
118
+ dataservice.permissions["edit"].test()
119
119
 
120
120
  patch(dataservice, request)
121
121
  dataservice.metadata_modified_at = datetime.utcnow()
122
+ if dataservice.access_type != DATASERVICE_ACCESS_TYPE_RESTRICTED:
123
+ dataservice.access_audiences = []
122
124
 
123
125
  dataservice.save()
124
126
  return dataservice
@@ -130,7 +132,7 @@ class DataserviceAPI(API):
130
132
  if dataservice.deleted_at:
131
133
  api.abort(410, "dataservice has been deleted")
132
134
 
133
- OwnablePermission(dataservice).test()
135
+ dataservice.permissions["delete"].test()
134
136
  dataservice.deleted_at = datetime.utcnow()
135
137
  dataservice.metadata_modified_at = datetime.utcnow()
136
138
  dataservice.save()
@@ -163,7 +165,7 @@ class DataserviceDatasetsAPI(API):
163
165
  if dataservice.deleted_at:
164
166
  api.abort(410, "Dataservice has been deleted")
165
167
 
166
- OwnablePermission(dataservice).test()
168
+ dataservice.permissions["edit"].test()
167
169
 
168
170
  data = request.json
169
171
 
@@ -199,7 +201,7 @@ class DataserviceDatasetAPI(API):
199
201
  if dataservice.deleted_at:
200
202
  api.abort(410, "Dataservice has been deleted")
201
203
 
202
- OwnablePermission(dataservice).test()
204
+ dataservice.permissions["edit"].test()
203
205
 
204
206
  if dataset not in dataservice.datasets:
205
207
  api.abort(404, "Dataset not found in dataservice")
@@ -228,8 +230,8 @@ class DataserviceRdfAPI(API):
228
230
  @api.response(410, "Dataservice has been deleted")
229
231
  class DataserviceRdfFormatAPI(API):
230
232
  @api.doc("rdf_dataservice_format")
231
- def get(self, dataservice, format):
232
- if not DataserviceEditPermission(dataservice).can():
233
+ def get(self, dataservice: Dataservice, format):
234
+ if not dataservice.permissions["edit"].can():
233
235
  if dataservice.private:
234
236
  api.abort(404)
235
237
  elif dataservice.deleted_at:
@@ -2,14 +2,17 @@ from flask import request
2
2
 
3
3
  from udata import search
4
4
  from udata.api import API, apiv2
5
- from udata.core.dataservices.models import Dataservice, HarvestMetadata
5
+ from udata.core.dataservices.models import AccessAudience, Dataservice, HarvestMetadata
6
6
  from udata.utils import multi_to_dict
7
7
 
8
+ from .models import dataservice_permissions_fields
8
9
  from .search import DataserviceSearch
9
10
 
11
+ apiv2.inherit("DataservicePermissions", dataservice_permissions_fields)
10
12
  apiv2.inherit("DataservicePage", Dataservice.__page_fields__)
11
13
  apiv2.inherit("Dataservice (read)", Dataservice.__read_fields__)
12
14
  apiv2.inherit("HarvestMetadata (read)", HarvestMetadata.__read_fields__)
15
+ apiv2.inherit("AccessAudience (read)", AccessAudience.__read_fields__)
13
16
 
14
17
  ns = apiv2.namespace("dataservices", "Dataservice related operations")
15
18
 
@@ -9,3 +9,22 @@ DATASERVICE_ACCESS_TYPES = [
9
9
  DATASERVICE_ACCESS_TYPE_OPEN_WITH_ACCOUNT,
10
10
  DATASERVICE_ACCESS_TYPE_RESTRICTED,
11
11
  ]
12
+
13
+ DATASERVICE_ACCESS_AUDIENCE_ADMINISTRATION = "local_authority_and_administration"
14
+ DATASERVICE_ACCESS_AUDIENCE_COMPANY = "company_and_association"
15
+ DATASERVICE_ACCESS_AUDIENCE_PRIVATE = "private"
16
+
17
+ DATASERVICE_ACCESS_AUDIENCE_TYPES = [
18
+ DATASERVICE_ACCESS_AUDIENCE_ADMINISTRATION,
19
+ DATASERVICE_ACCESS_AUDIENCE_COMPANY,
20
+ DATASERVICE_ACCESS_AUDIENCE_PRIVATE,
21
+ ]
22
+
23
+ DATASERVICE_ACCESS_AUDIENCE_YES = "yes"
24
+ DATASERVICE_ACCESS_AUDIENCE_NO = "no"
25
+ DATASERVICE_ACCESS_AUDIENCE_UNDER_CONDITIONS = "under_condition"
26
+ DATASERVICE_ACCESS_AUDIENCE_CONDITIONS = [
27
+ DATASERVICE_ACCESS_AUDIENCE_YES,
28
+ DATASERVICE_ACCESS_AUDIENCE_NO,
29
+ DATASERVICE_ACCESS_AUDIENCE_UNDER_CONDITIONS,
30
+ ]
@@ -7,14 +7,22 @@ from mongoengine.signals import post_save
7
7
 
8
8
  import udata.core.contact_point.api_fields as contact_api_fields
9
9
  import udata.core.dataset.api_fields as datasets_api_fields
10
+ from udata.api import api, fields
10
11
  from udata.api_fields import field, function_field, generate_fields
11
12
  from udata.core.activity.models import Auditable
12
- from udata.core.dataservices.constants import DATASERVICE_ACCESS_TYPES, DATASERVICE_FORMATS
13
+ from udata.core.dataservices.constants import (
14
+ DATASERVICE_ACCESS_AUDIENCE_CONDITIONS,
15
+ DATASERVICE_ACCESS_AUDIENCE_TYPES,
16
+ DATASERVICE_ACCESS_TYPES,
17
+ DATASERVICE_FORMATS,
18
+ )
13
19
  from udata.core.dataset.models import Dataset
20
+ from udata.core.metrics.helpers import get_stock_metrics
14
21
  from udata.core.metrics.models import WithMetrics
15
22
  from udata.core.owned import Owned, OwnedQuerySet
16
23
  from udata.i18n import lazy_gettext as _
17
24
  from udata.models import Discussion, Follow, db
25
+ from udata.mongo.errors import FieldValidationError
18
26
  from udata.uris import endpoint_for
19
27
 
20
28
  # "frequency"
@@ -27,6 +35,15 @@ from udata.uris import endpoint_for
27
35
  # "temporal_coverage"
28
36
 
29
37
 
38
+ dataservice_permissions_fields = api.model(
39
+ "DataservicePermissions",
40
+ {
41
+ "delete": fields.Permission(),
42
+ "edit": fields.Permission(),
43
+ },
44
+ )
45
+
46
+
30
47
  class DataserviceQuerySet(OwnedQuerySet):
31
48
  def visible(self):
32
49
  return self(archived_at=None, deleted_at=None, private=False)
@@ -98,6 +115,21 @@ class HarvestMetadata(db.EmbeddedDocument):
98
115
  archived_reason = field(db.StringField())
99
116
 
100
117
 
118
+ @generate_fields()
119
+ class AccessAudience(db.EmbeddedDocument):
120
+ role = field(db.StringField(choices=DATASERVICE_ACCESS_AUDIENCE_TYPES), filterable={})
121
+ condition = field(db.StringField(choices=DATASERVICE_ACCESS_AUDIENCE_CONDITIONS), filterable={})
122
+
123
+
124
+ def check_only_one_condition_per_role(access_audiences, **_kwargs):
125
+ roles = set(e["role"] for e in access_audiences)
126
+ if len(roles) != len(access_audiences):
127
+ raise FieldValidationError(
128
+ _("You can only set one condition for a given access audience role"),
129
+ field="access_audiences",
130
+ )
131
+
132
+
101
133
  @generate_fields(
102
134
  searchable=True,
103
135
  additional_filters={"organization_badge": "organization.badges"},
@@ -158,6 +190,11 @@ class Dataservice(Auditable, WithMetrics, Owned, db.Document):
158
190
  availability_url = field(db.URLField())
159
191
 
160
192
  access_type = field(db.StringField(choices=DATASERVICE_ACCESS_TYPES), filterable={})
193
+ access_audiences = field(
194
+ db.EmbeddedDocumentListField(AccessAudience),
195
+ checks=[check_only_one_condition_per_role],
196
+ )
197
+
161
198
  authorization_request_url = field(db.URLField())
162
199
 
163
200
  format = field(db.StringField(choices=DATASERVICE_FORMATS))
@@ -252,6 +289,7 @@ class Dataservice(Auditable, WithMetrics, Owned, db.Document):
252
289
  __metrics_keys__ = [
253
290
  "discussions",
254
291
  "followers",
292
+ "followers_by_months",
255
293
  "views",
256
294
  ]
257
295
 
@@ -259,12 +297,27 @@ class Dataservice(Auditable, WithMetrics, Owned, db.Document):
259
297
  def is_hidden(self):
260
298
  return self.private or self.deleted_at or self.archived_at
261
299
 
300
+ @property
301
+ @function_field(
302
+ nested_fields=dataservice_permissions_fields,
303
+ )
304
+ def permissions(self):
305
+ from .permissions import DataserviceEditPermission
306
+
307
+ return {
308
+ "delete": DataserviceEditPermission(self),
309
+ "edit": DataserviceEditPermission(self),
310
+ }
311
+
262
312
  def count_discussions(self):
263
313
  self.metrics["discussions"] = Discussion.objects(subject=self, closed=None).count()
264
314
  self.save(signal_kwargs={"ignores": ["post_save"]})
265
315
 
266
316
  def count_followers(self):
267
317
  self.metrics["followers"] = Follow.objects(until=None).followers(self).count()
318
+ self.metrics["followers_by_months"] = get_stock_metrics(
319
+ Follow.objects(following=self), date_label="since"
320
+ )
268
321
  self.save(signal_kwargs={"ignores": ["post_save"]})
269
322
 
270
323
 
udata/core/dataset/api.py CHANGED
@@ -39,6 +39,7 @@ from udata.core.badges.fields import badge_fields
39
39
  from udata.core.dataservices.models import Dataservice
40
40
  from udata.core.dataset.models import CHECKSUM_TYPES
41
41
  from udata.core.followers.api import FollowAPI
42
+ from udata.core.followers.models import Follow
42
43
  from udata.core.organization.models import Organization
43
44
  from udata.core.reuse.models import Reuse
44
45
  from udata.core.site.models import current_site
@@ -84,7 +85,6 @@ from .models import (
84
85
  ResourceSchema,
85
86
  get_resource,
86
87
  )
87
- from .permissions import DatasetEditPermission, ResourceEditPermission
88
88
  from .rdf import dataset_to_rdf
89
89
 
90
90
  DEFAULT_SORTING = "-created_at_internal"
@@ -122,6 +122,12 @@ class DatasetApiParser(ModelApiParser):
122
122
  location="args",
123
123
  )
124
124
  self.parser.add_argument("owner", type=str, location="args")
125
+ self.parser.add_argument(
126
+ "followed_by",
127
+ type=str,
128
+ location="args",
129
+ help="(beta, subject to change/be removed)",
130
+ )
125
131
  self.parser.add_argument("format", type=str, location="args")
126
132
  self.parser.add_argument("schema", type=str, location="args")
127
133
  self.parser.add_argument("schema_version", type=str, location="args")
@@ -183,6 +189,16 @@ class DatasetApiParser(ModelApiParser):
183
189
  if not ObjectId.is_valid(args["owner"]):
184
190
  api.abort(400, "Owner arg must be an identifier")
185
191
  datasets = datasets.filter(owner=args["owner"])
192
+ if args.get("followed_by"):
193
+ if not ObjectId.is_valid(args["followed_by"]):
194
+ api.abort(400, "`followed_by` arg must be an identifier")
195
+ ids = [
196
+ f.following.id
197
+ for f in Follow.objects(follower=args["followed_by"]).filter(
198
+ __raw__={"following._cls": Dataset._class_name}
199
+ )
200
+ ]
201
+ datasets = datasets.filter(id__in=ids)
186
202
  if args.get("format"):
187
203
  datasets = datasets.filter(resources__format=args["format"])
188
204
  if args.get("schema"):
@@ -342,9 +358,9 @@ class DatasetsAtomFeedAPI(API):
342
358
  class DatasetAPI(API):
343
359
  @api.doc("get_dataset")
344
360
  @api.marshal_with(dataset_fields)
345
- def get(self, dataset):
361
+ def get(self, dataset: Dataset):
346
362
  """Get a dataset given its identifier"""
347
- if not DatasetEditPermission(dataset).can():
363
+ if not dataset.permissions["edit"].can():
348
364
  if dataset.private:
349
365
  api.abort(404)
350
366
  elif dataset.deleted:
@@ -356,12 +372,12 @@ class DatasetAPI(API):
356
372
  @api.expect(dataset_fields)
357
373
  @api.marshal_with(dataset_fields)
358
374
  @api.response(400, errors.VALIDATION_ERROR)
359
- def put(self, dataset):
375
+ def put(self, dataset: Dataset):
360
376
  """Update a dataset given its identifier"""
361
377
  request_deleted = request.json.get("deleted", True)
362
378
  if dataset.deleted and request_deleted is not None:
363
379
  api.abort(410, "Dataset has been deleted")
364
- DatasetEditPermission(dataset).test()
380
+ dataset.permissions["edit"].test()
365
381
  dataset.last_modified_internal = datetime.utcnow()
366
382
  form = api.validate(DatasetForm, dataset)
367
383
 
@@ -374,7 +390,7 @@ class DatasetAPI(API):
374
390
  """Delete a dataset given its identifier"""
375
391
  if dataset.deleted:
376
392
  api.abort(410, "Dataset has been deleted")
377
- DatasetEditPermission(dataset).test()
393
+ dataset.permissions["delete"].test()
378
394
  dataset.deleted = datetime.utcnow()
379
395
  dataset.last_modified_internal = datetime.utcnow()
380
396
  dataset.save()
@@ -420,7 +436,7 @@ class DatasetRdfAPI(API):
420
436
  class DatasetRdfFormatAPI(API):
421
437
  @api.doc("rdf_dataset_format")
422
438
  def get(self, dataset, format):
423
- if not DatasetEditPermission(dataset).can():
439
+ if not dataset.permissions["edit"].can():
424
440
  if dataset.private:
425
441
  api.abort(404)
426
442
  elif dataset.deleted:
@@ -479,7 +495,7 @@ class ResourcesAPI(API):
479
495
  @api.marshal_with(resource_fields, code=201)
480
496
  def post(self, dataset):
481
497
  """Create a new resource for a given dataset"""
482
- ResourceEditPermission(dataset).test()
498
+ dataset.permissions["edit_resources"].test()
483
499
  form = api.validate(ResourceFormWithoutId)
484
500
  resource = Resource()
485
501
 
@@ -487,8 +503,6 @@ class ResourcesAPI(API):
487
503
  api.abort(400, "This endpoint only supports remote resources")
488
504
  form.populate_obj(resource)
489
505
  dataset.add_resource(resource)
490
- dataset.last_modified_internal = datetime.utcnow()
491
- dataset.save()
492
506
  return resource, 201
493
507
 
494
508
  @api.secure
@@ -497,7 +511,7 @@ class ResourcesAPI(API):
497
511
  @api.marshal_list_with(resource_fields)
498
512
  def put(self, dataset):
499
513
  """Reorder resources"""
500
- ResourceEditPermission(dataset).test()
514
+ dataset.permissions["edit_resources"].test()
501
515
  resources = request.json
502
516
  if len(dataset.resources) != len(resources):
503
517
  api.abort(
@@ -551,12 +565,10 @@ class UploadNewDatasetResource(UploadMixin, API):
551
565
  @api.marshal_with(upload_fields, code=201)
552
566
  def post(self, dataset):
553
567
  """Upload a file for a new dataset resource"""
554
- ResourceEditPermission(dataset).test()
568
+ dataset.permissions["edit_resources"].test()
555
569
  infos = self.handle_upload(dataset)
556
570
  resource = Resource(**infos)
557
571
  dataset.add_resource(resource)
558
- dataset.last_modified_internal = datetime.utcnow()
559
- dataset.save()
560
572
  return resource, 201
561
573
 
562
574
 
@@ -602,15 +614,13 @@ class UploadDatasetResource(ResourceMixin, UploadMixin, API):
602
614
  @api.marshal_with(upload_fields)
603
615
  def post(self, dataset, rid):
604
616
  """Upload a file related to a given resource on a given dataset"""
605
- ResourceEditPermission(dataset).test()
617
+ dataset.permissions["edit_resources"].test()
606
618
  resource = self.get_resource_or_404(dataset, rid)
607
619
  fs_filename_to_remove = resource.fs_filename
608
620
  infos = self.handle_upload(dataset)
609
621
  for k, v in infos.items():
610
622
  resource[k] = v
611
623
  dataset.update_resource(resource)
612
- dataset.last_modified_internal = datetime.utcnow()
613
- dataset.save()
614
624
  if fs_filename_to_remove is not None:
615
625
  storages.resources.delete(fs_filename_to_remove)
616
626
  return resource
@@ -631,7 +641,7 @@ class ReuploadCommunityResource(ResourceMixin, UploadMixin, API):
631
641
  @api.marshal_with(upload_community_fields)
632
642
  def post(self, community):
633
643
  """Update the file related to a given community resource"""
634
- ResourceEditPermission(community).test()
644
+ community.permissions["edit"].test()
635
645
  fs_filename_to_remove = community.fs_filename
636
646
  infos = self.handle_upload(community.dataset)
637
647
  community.update(**infos)
@@ -648,7 +658,7 @@ class ResourceAPI(ResourceMixin, API):
648
658
  @api.marshal_with(resource_fields)
649
659
  def get(self, dataset, rid):
650
660
  """Get a resource given its identifier"""
651
- if not DatasetEditPermission(dataset).can():
661
+ if not dataset.permissions["edit"].can():
652
662
  if dataset.private:
653
663
  api.abort(404)
654
664
  elif dataset.deleted:
@@ -662,7 +672,7 @@ class ResourceAPI(ResourceMixin, API):
662
672
  @api.marshal_with(resource_fields)
663
673
  def put(self, dataset, rid):
664
674
  """Update a given resource on a given dataset"""
665
- ResourceEditPermission(dataset).test()
675
+ dataset.permissions["edit_resources"].test()
666
676
  resource = self.get_resource_or_404(dataset, rid)
667
677
  form = api.validate(ResourceFormWithoutId, resource)
668
678
 
@@ -683,19 +693,15 @@ class ResourceAPI(ResourceMixin, API):
683
693
  form.populate_obj(resource)
684
694
  resource.last_modified_internal = datetime.utcnow()
685
695
  dataset.update_resource(resource)
686
- dataset.last_modified_internal = datetime.utcnow()
687
- dataset.save()
688
696
  return resource
689
697
 
690
698
  @api.secure
691
699
  @api.doc("delete_resource")
692
700
  def delete(self, dataset, rid):
693
701
  """Delete a given resource on a given dataset"""
694
- ResourceEditPermission(dataset).test()
702
+ dataset.permissions["edit_resources"].test()
695
703
  resource = self.get_resource_or_404(dataset, rid)
696
704
  dataset.remove_resource(resource)
697
- dataset.last_modified_internal = datetime.utcnow()
698
- dataset.save()
699
705
  return "", 204
700
706
 
701
707
 
@@ -751,7 +757,7 @@ class CommunityResourceAPI(API):
751
757
  @api.marshal_with(community_resource_fields)
752
758
  def put(self, community):
753
759
  """Update a given community resource"""
754
- ResourceEditPermission(community).test()
760
+ community.permissions["edit"].test()
755
761
  form = api.validate(CommunityResourceForm, community)
756
762
  if community.filetype == "file":
757
763
  form._fields.get("url").data = community.url
@@ -766,7 +772,7 @@ class CommunityResourceAPI(API):
766
772
  @api.doc("delete_community_resource")
767
773
  def delete(self, community):
768
774
  """Delete a given community resource"""
769
- ResourceEditPermission(community).test()
775
+ community.permissions["delete"].test()
770
776
  # Deletes community resource's file from file storage
771
777
  if community.fs_filename is not None:
772
778
  storages.resources.delete(community.fs_filename)
@@ -220,6 +220,15 @@ dataset_ref_fields = api.inherit(
220
220
  },
221
221
  )
222
222
 
223
+
224
+ community_resource_permissions_fields = api.model(
225
+ "DatasetPermissions",
226
+ {
227
+ "delete": fields.Permission(),
228
+ "edit": fields.Permission(),
229
+ },
230
+ )
231
+
223
232
  community_resource_fields = api.inherit(
224
233
  "CommunityResource",
225
234
  resource_fields,
@@ -233,6 +242,7 @@ community_resource_fields = api.inherit(
233
242
  "owner": fields.Nested(
234
243
  user_ref_fields, allow_null=True, description="The user information"
235
244
  ),
245
+ "permissions": fields.Nested(community_resource_permissions_fields),
236
246
  },
237
247
  )
238
248
 
@@ -285,6 +295,7 @@ DEFAULT_MASK = ",".join(
285
295
  "internal",
286
296
  "contact_points",
287
297
  "featured",
298
+ "permissions",
288
299
  )
289
300
  )
290
301
 
@@ -300,6 +311,15 @@ dataset_internal_fields = api.model(
300
311
  },
301
312
  )
302
313
 
314
+ dataset_permissions_fields = api.model(
315
+ "DatasetPermissions",
316
+ {
317
+ "delete": fields.Permission(),
318
+ "edit": fields.Permission(),
319
+ "edit_resources": fields.Permission(),
320
+ },
321
+ )
322
+
303
323
  dataset_fields = api.model(
304
324
  "Dataset",
305
325
  {
@@ -401,6 +421,7 @@ dataset_fields = api.model(
401
421
  "contact_points": fields.List(
402
422
  fields.Nested(contact_point_fields, description="The dataset contact points"),
403
423
  ),
424
+ "permissions": fields.Nested(dataset_permissions_fields),
404
425
  },
405
426
  mask=DEFAULT_MASK,
406
427
  )