udata 9.2.5.dev32160__py2.py3-none-any.whl → 9.2.5.dev32190__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 (48) hide show
  1. udata/api_fields.py +143 -37
  2. udata/core/badges/factories.py +3 -5
  3. udata/core/badges/forms.py +0 -3
  4. udata/core/badges/models.py +19 -24
  5. udata/core/badges/tests/test_commands.py +3 -4
  6. udata/core/badges/tests/test_model.py +37 -28
  7. udata/core/dataservices/models.py +4 -1
  8. udata/core/dataset/api.py +10 -0
  9. udata/core/dataset/events.py +25 -16
  10. udata/core/dataset/models.py +16 -6
  11. udata/core/dataset/search.py +3 -1
  12. udata/core/organization/api.py +11 -0
  13. udata/core/organization/models.py +23 -10
  14. udata/core/organization/search.py +6 -2
  15. udata/core/reuse/api.py +10 -0
  16. udata/core/reuse/models.py +15 -5
  17. udata/core/reuse/search.py +3 -1
  18. udata/search/fields.py +8 -3
  19. udata/static/chunks/{10.a99bb538cfbadb38dbcb.js → 10.dac55d18d0b4ef3cdacf.js} +3 -3
  20. udata/static/chunks/{10.a99bb538cfbadb38dbcb.js.map → 10.dac55d18d0b4ef3cdacf.js.map} +1 -1
  21. udata/static/chunks/{11.bb1c1fb39f740fbbeec0.js → 11.4a20a75f827c5a1125c3.js} +3 -3
  22. udata/static/chunks/{11.bb1c1fb39f740fbbeec0.js.map → 11.4a20a75f827c5a1125c3.js.map} +1 -1
  23. udata/static/chunks/{13.bef5fdb3e147e94fea99.js → 13.645dd0b7c0b9210f1b56.js} +2 -2
  24. udata/static/chunks/{13.bef5fdb3e147e94fea99.js.map → 13.645dd0b7c0b9210f1b56.js.map} +1 -1
  25. udata/static/chunks/{17.b91d28f550dc44bc4979.js → 17.8e19985c4d12a3b7b0c0.js} +2 -2
  26. udata/static/chunks/{17.b91d28f550dc44bc4979.js.map → 17.8e19985c4d12a3b7b0c0.js.map} +1 -1
  27. udata/static/chunks/{19.2c615ffee1e807000770.js → 19.825a43c330157e351fca.js} +3 -3
  28. udata/static/chunks/{19.2c615ffee1e807000770.js.map → 19.825a43c330157e351fca.js.map} +1 -1
  29. udata/static/chunks/{8.291bde987ed97294e4de.js → 8.5ee0cf635c848abbfc05.js} +2 -2
  30. udata/static/chunks/{8.291bde987ed97294e4de.js.map → 8.5ee0cf635c848abbfc05.js.map} +1 -1
  31. udata/static/chunks/{9.985935421e62c97a9f86.js → 9.df3c36f8d0d210621fbb.js} +3 -3
  32. udata/static/chunks/{9.985935421e62c97a9f86.js.map → 9.df3c36f8d0d210621fbb.js.map} +1 -1
  33. udata/static/common.js +1 -1
  34. udata/static/common.js.map +1 -1
  35. udata/tests/api/test_dataservices_api.py +21 -1
  36. udata/tests/api/test_datasets_api.py +15 -0
  37. udata/tests/api/test_organizations_api.py +15 -4
  38. udata/tests/api/test_reuses_api.py +15 -0
  39. udata/tests/apiv2/test_datasets.py +17 -0
  40. udata/tests/dataset/test_dataset_events.py +3 -15
  41. udata/tests/organization/test_organization_model.py +21 -0
  42. udata/tests/site/test_site_metrics.py +1 -3
  43. {udata-9.2.5.dev32160.dist-info → udata-9.2.5.dev32190.dist-info}/METADATA +3 -1
  44. {udata-9.2.5.dev32160.dist-info → udata-9.2.5.dev32190.dist-info}/RECORD +48 -48
  45. {udata-9.2.5.dev32160.dist-info → udata-9.2.5.dev32190.dist-info}/LICENSE +0 -0
  46. {udata-9.2.5.dev32160.dist-info → udata-9.2.5.dev32190.dist-info}/WHEEL +0 -0
  47. {udata-9.2.5.dev32160.dist-info → udata-9.2.5.dev32190.dist-info}/entry_points.txt +0 -0
  48. {udata-9.2.5.dev32160.dist-info → udata-9.2.5.dev32190.dist-info}/top_level.txt +0 -0
udata/api_fields.py CHANGED
@@ -1,3 +1,6 @@
1
+ import functools
2
+ from typing import Any, Dict
3
+
1
4
  import flask_restx.fields as restx_fields
2
5
  import mongoengine
3
6
  import mongoengine.fields as mongo_fields
@@ -114,13 +117,15 @@ def convert_db_to_field(key, field, info):
114
117
  # But we want to keep the `constructor_write` to allow changing the list.
115
118
  def constructor_write(**kwargs):
116
119
  return restx_fields.List(field_write, **kwargs)
120
+
117
121
  elif isinstance(
118
122
  field, (mongo_fields.GenericReferenceField, mongoengine.fields.GenericLazyReferenceField)
119
123
  ):
120
124
 
121
125
  def constructor(**kwargs):
122
126
  return restx_fields.Nested(lazy_reference, **kwargs)
123
- elif isinstance(field, (mongo_fields.ReferenceField, mongo_fields.LazyReferenceField)):
127
+
128
+ elif isinstance(field, mongo_fields.ReferenceField | mongo_fields.LazyReferenceField):
124
129
  # For reference we accept while writing a String representing the ID of the referenced model.
125
130
  # For reading, if the user supplied a `nested_fields` (RestX model), we use it to convert
126
131
  # the referenced model, if not we return a String (and RestX will call the `str()` of the model
@@ -142,6 +147,7 @@ def convert_db_to_field(key, field, info):
142
147
 
143
148
  def constructor(**kwargs):
144
149
  return restx_fields.Nested(nested_fields, **kwargs)
150
+
145
151
  elif hasattr(field.document_type_obj, "__read_fields__"):
146
152
 
147
153
  def constructor_read(**kwargs):
@@ -149,6 +155,7 @@ def convert_db_to_field(key, field, info):
149
155
 
150
156
  def constructor_write(**kwargs):
151
157
  return restx_fields.Nested(field.document_type_obj.__write_fields__, **kwargs)
158
+
152
159
  else:
153
160
  raise ValueError(
154
161
  f"EmbeddedDocumentField `{key}` requires a `nested_fields` param to serialize/deserialize or a `@generate_fields()` definition."
@@ -200,8 +207,12 @@ def generate_fields(**kwargs):
200
207
  read_fields = {}
201
208
  write_fields = {}
202
209
  ref_fields = {}
203
- sortables = kwargs.get("additionalSorts", [])
210
+ sortables = kwargs.get("additional_sorts", [])
211
+
204
212
  filterables = []
213
+ additional_filters = get_fields_with_additional_filters(
214
+ kwargs.get("additional_filters", {})
215
+ )
205
216
 
206
217
  read_fields["id"] = restx_fields.String(required=True, readonly=True)
207
218
 
@@ -209,38 +220,48 @@ def generate_fields(**kwargs):
209
220
  sortable_key = info.get("sortable", False)
210
221
  if sortable_key:
211
222
  sortables.append(
212
- {"key": sortable_key if isinstance(sortable_key, str) else key, "value": key}
223
+ {
224
+ "key": sortable_key if isinstance(sortable_key, str) else key,
225
+ "value": key,
226
+ }
213
227
  )
214
228
 
215
229
  filterable = info.get("filterable", None)
230
+
216
231
  if filterable is not None:
217
- if "key" not in filterable:
218
- filterable["key"] = key
219
- if "column" not in filterable:
220
- filterable["column"] = key
221
-
222
- if "constraints" not in filterable:
223
- filterable["constraints"] = []
224
- if isinstance(
225
- field, (mongo_fields.ReferenceField, mongo_fields.LazyReferenceField)
226
- ) or (
227
- isinstance(field, mongo_fields.ListField)
228
- and isinstance(
229
- field.field,
230
- (mongo_fields.ReferenceField, mongo_fields.LazyReferenceField),
231
- )
232
- ):
233
- filterable["constraints"].append("objectid")
232
+ filterables.append(compute_filter(key, field, info, filterable))
233
+
234
+ additional_filter = additional_filters.get(key, None)
235
+ if additional_filter:
236
+ if not isinstance(
237
+ field, mongo_fields.ReferenceField | mongo_fields.LazyReferenceField
238
+ ):
239
+ raise Exception("Cannot use additional_filters on not a ref.")
240
+
241
+ ref_model = field.document_type
242
+
243
+ for child in additional_filter.get("children", []):
244
+ inner_field = getattr(ref_model, child["key"])
234
245
 
235
- if "type" not in filterable:
236
- filterable["type"] = str
237
- if isinstance(field, mongo_fields.BooleanField):
238
- filterable["type"] = boolean
246
+ column = f"{key}__{child['key']}"
247
+ child["key"] = f"{key}_{child['key']}"
248
+ filterable = compute_filter(column, inner_field, info, child)
239
249
 
240
- # We may add more information later here:
241
- # - type of mongo query to execute (right now only simple =)
250
+ # Since MongoDB is not capable of doing joins with a column like `organization__slug` we need to
251
+ # do a custom filter by splitting the query in two.
242
252
 
243
- filterables.append(filterable)
253
+ def query(filterable, query, value):
254
+ # We use the computed `filterable["column"]` here because the `compute_filter` function
255
+ # could have added a default filter at the end (for example `organization__badges` converted
256
+ # in `organization__badges__kind`)
257
+ parts = filterable["column"].split("__", 1)
258
+ models = ref_model.objects.filter(**{parts[1]: value}).only("id")
259
+ return query.filter(**{f"{parts[0]}__in": models})
260
+
261
+ # do a query-based filter instead of a column based one
262
+ filterable["query"] = functools.partial(query, filterable)
263
+
264
+ filterables.append(filterable)
244
265
 
245
266
  read, write = convert_db_to_field(key, field, info)
246
267
 
@@ -330,9 +351,10 @@ def generate_fields(**kwargs):
330
351
 
331
352
  for filterable in filterables:
332
353
  parser.add_argument(
333
- filterable["key"],
354
+ filterable.get("label", filterable["key"]),
334
355
  type=filterable["type"],
335
356
  location="args",
357
+ choices=filterable.get("choices", None),
336
358
  )
337
359
 
338
360
  cls.__index_parser__ = parser
@@ -360,18 +382,23 @@ def generate_fields(**kwargs):
360
382
  base_query = base_query.search_text(phrase_query)
361
383
 
362
384
  for filterable in filterables:
363
- if args.get(filterable["key"]) is not None:
364
- for constraint in filterable["constraints"]:
385
+ filter = args.get(filterable.get("label", filterable["key"]))
386
+ if filter is not None:
387
+ for constraint in filterable.get("constraints", []):
365
388
  if constraint == "objectid" and not ObjectId.is_valid(
366
389
  args[filterable["key"]]
367
390
  ):
368
391
  api.abort(400, f'`{filterable["key"]}` must be an identifier')
369
392
 
370
- base_query = base_query.filter(
371
- **{
372
- filterable["column"]: args[filterable["key"]],
373
- }
374
- )
393
+ query = filterable.get("query", None)
394
+ if query:
395
+ base_query = filterable["query"](base_query, filter)
396
+ else:
397
+ base_query = base_query.filter(
398
+ **{
399
+ filterable["column"]: filter,
400
+ }
401
+ )
375
402
 
376
403
  if paginable:
377
404
  base_query = base_query.paginate(args["page"], args["page_size"])
@@ -379,6 +406,7 @@ def generate_fields(**kwargs):
379
406
  return base_query
380
407
 
381
408
  cls.apply_sort_filters_and_pagination = apply_sort_filters_and_pagination
409
+ cls.__additional_class_info__ = kwargs
382
410
  return cls
383
411
 
384
412
  return wrapper
@@ -417,12 +445,12 @@ def patch(obj, request):
417
445
  value = model_attribute.from_input(value)
418
446
  elif isinstance(model_attribute, mongoengine.fields.ListField) and isinstance(
419
447
  model_attribute.field,
420
- (mongo_fields.ReferenceField, mongo_fields.LazyReferenceField),
448
+ mongo_fields.ReferenceField | mongo_fields.LazyReferenceField,
421
449
  ):
422
450
  # TODO `wrap_primary_key` do Mongo request, do a first pass to fetch all documents before calling it (to avoid multiple queries).
423
451
  value = [wrap_primary_key(key, model_attribute.field, id) for id in value]
424
452
  elif isinstance(
425
- model_attribute, (mongo_fields.ReferenceField, mongo_fields.LazyReferenceField)
453
+ model_attribute, mongo_fields.ReferenceField | mongo_fields.LazyReferenceField
426
454
  ):
427
455
  value = wrap_primary_key(key, model_attribute, value)
428
456
  elif isinstance(
@@ -517,3 +545,81 @@ def wrap_primary_key(
517
545
  raise ValueError(
518
546
  f"Unknown ID field type {id_field.__class__} for {document_type} (ID field name is {id_field_name}, value was {value})"
519
547
  )
548
+
549
+
550
+ def get_fields_with_additional_filters(additional_filters: Dict[str, str]) -> Dict[str, Any]:
551
+ """
552
+ Right now we only support additional filters like "organization.badges".
553
+
554
+ The goal of this function is to key the additional filters by the first part (`organization`) to
555
+ be able to compute them when we loop over all the fields (`title`, `organization`…)
556
+
557
+ The `additional_filters` property is a dict: {"label": "key"}, for example {"organization_badge": "organization.badges"}.
558
+ The `label` will be the name of the parser arg, like `?organization_badge=public-service`, which makes more
559
+ sense than `?organization_badges=public-service`.
560
+ """
561
+ results: dict = {}
562
+ for label, key in additional_filters.items():
563
+ parts = key.split(".")
564
+ if len(parts) == 2:
565
+ parent = parts[0]
566
+ child = parts[1]
567
+
568
+ if parent not in results:
569
+ results[parent] = {"children": []}
570
+
571
+ results[parent]["children"].append(
572
+ {
573
+ "label": label,
574
+ "key": child,
575
+ "type": str,
576
+ }
577
+ )
578
+ else:
579
+ raise Exception(f"Do not support `additional_filters` without two parts: {key}.")
580
+
581
+ return results
582
+
583
+
584
+ def compute_filter(column: str, field, info, filterable):
585
+ # "key" is the param key in the URL
586
+ if "key" not in filterable:
587
+ filterable["key"] = column
588
+
589
+ # If we do a filter on a embed document, get the class info
590
+ # of this document to see if there is a default filter value
591
+ embed_info = None
592
+ if isinstance(field, mongo_fields.EmbeddedDocumentField):
593
+ embed_info = field.get("__additional_class_info__", None)
594
+ elif isinstance(field, mongo_fields.EmbeddedDocumentListField):
595
+ embed_info = getattr(field.field.document_type, "__additional_class_info__", None)
596
+
597
+ if embed_info and embed_info.get("default_filterable_field", None):
598
+ # There is a default filterable field so append it to the column and replace the
599
+ # field to use the inner one (for example using the `kind` `StringField` instead of
600
+ # the embed `Badge` field.)
601
+ filterable["column"] = f"{column}__{embed_info['default_filterable_field']}"
602
+ field = getattr(field.field.document_type, embed_info["default_filterable_field"])
603
+ else:
604
+ filterable["column"] = column
605
+
606
+ if "constraints" not in filterable:
607
+ filterable["constraints"] = []
608
+
609
+ if isinstance(field, mongo_fields.ReferenceField | mongo_fields.LazyReferenceField) or (
610
+ isinstance(field, mongo_fields.ListField)
611
+ and isinstance(field.field, mongo_fields.ReferenceField | mongo_fields.LazyReferenceField)
612
+ ):
613
+ filterable["constraints"].append("objectid")
614
+
615
+ if "type" not in filterable:
616
+ if isinstance(field, mongo_fields.BooleanField):
617
+ filterable["type"] = boolean
618
+ else:
619
+ filterable["type"] = str
620
+
621
+ filterable["choices"] = info.get("choices", None)
622
+ if hasattr(field, "choices") and field.choices:
623
+ filterable["choices"] = field.choices
624
+
625
+ return filterable
@@ -2,14 +2,12 @@ from factory.fuzzy import FuzzyChoice
2
2
 
3
3
  from udata.factories import ModelFactory
4
4
 
5
- from .models import Badge
6
5
 
7
-
8
- def badge_factory(model):
6
+ def badge_factory(model_):
9
7
  class BadgeFactory(ModelFactory):
10
8
  class Meta:
11
- model = Badge
9
+ model = model_._fields["badges"].field.document_type
12
10
 
13
- kind = FuzzyChoice(model.__badges__.keys())
11
+ kind = FuzzyChoice(model_.__badges__)
14
12
 
15
13
  return BadgeFactory
@@ -1,6 +1,5 @@
1
1
  from udata.forms import ModelForm, fields, validators
2
2
  from udata.i18n import lazy_gettext as _
3
- from udata.models import Badge
4
3
 
5
4
  __all__ = ("badge_form",)
6
5
 
@@ -9,8 +8,6 @@ def badge_form(model):
9
8
  """A form factory for a given model badges"""
10
9
 
11
10
  class BadgeForm(ModelForm):
12
- model_class = Badge
13
-
14
11
  kind = fields.RadioField(
15
12
  _("Kind"),
16
13
  [validators.DataRequired()],
@@ -3,7 +3,7 @@ from datetime import datetime
3
3
 
4
4
  from mongoengine.signals import post_save
5
5
 
6
- from udata.api_fields import field
6
+ from udata.api_fields import field, generate_fields
7
7
  from udata.auth import current_user
8
8
  from udata.core.badges.fields import badge_fields
9
9
  from udata.mongo import db
@@ -12,10 +12,19 @@ from .signals import on_badge_added, on_badge_removed
12
12
 
13
13
  log = logging.getLogger(__name__)
14
14
 
15
- __all__ = ("Badge", "BadgeMixin")
16
15
 
16
+ __all__ = ["Badge", "BadgeMixin", "BadgesList"]
17
17
 
18
+ DEFAULT_BADGES_LIST_PARAMS = {
19
+ "readonly": True,
20
+ "inner_field_info": {"nested_fields": badge_fields},
21
+ }
22
+
23
+
24
+ @generate_fields(default_filterable_field="kind")
18
25
  class Badge(db.EmbeddedDocument):
26
+ meta = {"allow_inheritance": True}
27
+ # The following field should be overloaded in descendants.
19
28
  kind = db.StringField(required=True)
20
29
  created = db.DateTimeField(default=datetime.utcnow, required=True)
21
30
  created_by = db.ReferenceField("User")
@@ -23,30 +32,16 @@ class Badge(db.EmbeddedDocument):
23
32
  def __str__(self):
24
33
  return self.kind
25
34
 
26
- def validate(self, clean=True):
27
- badges = getattr(self._instance, "__badges__", {})
28
- if self.kind not in badges.keys():
29
- raise db.ValidationError("Unknown badge type %s" % self.kind)
30
- return super(Badge, self).validate(clean=clean)
31
-
32
35
 
33
36
  class BadgesList(db.EmbeddedDocumentListField):
34
- def __init__(self, *args, **kwargs):
35
- return super(BadgesList, self).__init__(Badge, *args, **kwargs)
36
-
37
- def validate(self, value):
38
- kinds = [b.kind for b in value]
39
- if len(kinds) > len(set(kinds)):
40
- raise db.ValidationError("Duplicate badges for a given kind is not allowed")
41
- return super(BadgesList, self).validate(value)
37
+ def __init__(self, badge_model, *args, **kwargs):
38
+ return super(BadgesList, self).__init__(badge_model, *args, **kwargs)
42
39
 
43
40
 
44
- class BadgeMixin(object):
45
- badges = field(
46
- BadgesList(),
47
- readonly=True,
48
- inner_field_info={"nested_fields": badge_fields},
49
- )
41
+ class BadgeMixin:
42
+ default_badges_list_params = DEFAULT_BADGES_LIST_PARAMS
43
+ # The following field should be overloaded in descendants.
44
+ badges = field(BadgesList(Badge), **DEFAULT_BADGES_LIST_PARAMS)
50
45
 
51
46
  def get_badge(self, kind):
52
47
  """Get a badge given its kind if present"""
@@ -61,7 +56,7 @@ class BadgeMixin(object):
61
56
  if kind not in getattr(self, "__badges__", {}):
62
57
  msg = "Unknown badge type for {model}: {kind}"
63
58
  raise db.ValidationError(msg.format(model=self.__class__.__name__, kind=kind))
64
- badge = Badge(kind=kind)
59
+ badge = self._fields["badges"].field.document_type(kind=kind)
65
60
  if current_user.is_authenticated:
66
61
  badge.created_by = current_user.id
67
62
 
@@ -88,5 +83,5 @@ class BadgeMixin(object):
88
83
 
89
84
  def badge_label(self, badge):
90
85
  """Display the badge label for a given kind"""
91
- kind = badge.kind if isinstance(badge, Badge) else badge
86
+ kind = badge.kind if isinstance(badge, self.badge) else badge
92
87
  return self.__badges__[kind]
@@ -4,7 +4,6 @@ import pytest
4
4
 
5
5
  from udata.core.organization.constants import CERTIFIED, PUBLIC_SERVICE
6
6
  from udata.core.organization.factories import OrganizationFactory
7
- from udata.models import Badge
8
7
 
9
8
 
10
9
  @pytest.mark.usefixtures("clean_db")
@@ -21,9 +20,9 @@ class BadgeCommandTest:
21
20
  assert org.badges[0].kind == PUBLIC_SERVICE
22
21
 
23
22
  def test_toggle_badge_off(self, cli):
24
- ps_badge = Badge(kind=PUBLIC_SERVICE)
25
- certified_badge = Badge(kind=CERTIFIED)
26
- org = OrganizationFactory(badges=[ps_badge, certified_badge])
23
+ org = OrganizationFactory()
24
+ org.add_badge(PUBLIC_SERVICE)
25
+ org.add_badge(CERTIFIED)
27
26
 
28
27
  cli("badges", "toggle", str(org.id), PUBLIC_SERVICE)
29
28
 
@@ -1,19 +1,31 @@
1
+ from udata.api_fields import field
1
2
  from udata.auth import login_user
2
3
  from udata.core.user.factories import UserFactory
3
4
  from udata.mongo import db
4
5
  from udata.tests import DBTestMixin, TestCase
5
6
 
6
- from ..models import Badge, BadgeMixin
7
+ from ..models import Badge, BadgeMixin, BadgesList
7
8
 
8
9
  TEST = "test"
9
10
  OTHER = "other"
10
11
 
12
+ BADGES = {
13
+ TEST: "Test",
14
+ OTHER: "Other",
15
+ }
11
16
 
12
- class Fake(db.Document, BadgeMixin):
13
- __badges__ = {
14
- TEST: "Test",
15
- OTHER: "Other",
16
- }
17
+
18
+ class FakeBadge(Badge):
19
+ kind = db.StringField(required=True, choices=list(BADGES.keys()))
20
+
21
+
22
+ class FakeBadgeMixin(BadgeMixin):
23
+ badges = field(BadgesList(FakeBadge), **BadgeMixin.default_badges_list_params)
24
+ __badges__ = BADGES
25
+
26
+
27
+ class Fake(db.Document, FakeBadgeMixin):
28
+ pass
17
29
 
18
30
 
19
31
  class BadgeMixinTest(DBTestMixin, TestCase):
@@ -22,15 +34,24 @@ class BadgeMixinTest(DBTestMixin, TestCase):
22
34
  fake = Fake.objects.create()
23
35
  self.assertIsInstance(fake.badges, (list, tuple))
24
36
 
37
+ def test_choices(self):
38
+ """It should have a choice list on the badge field."""
39
+ self.assertEqual(
40
+ Fake._fields["badges"].field.document_type.kind.choices, list(Fake.__badges__.keys())
41
+ )
42
+
25
43
  def test_get_badge_found(self):
26
44
  """It allow to get a badge by kind if present"""
27
- fake = Fake.objects.create(badges=[Badge(kind=TEST), Badge(kind=OTHER)])
45
+ fake = Fake.objects.create()
46
+ fake.add_badge(TEST)
47
+ fake.add_badge(OTHER)
28
48
  badge = fake.get_badge(TEST)
29
49
  self.assertEqual(badge.kind, TEST)
30
50
 
31
51
  def test_get_badge_not_found(self):
32
52
  """It should return None if badge is absent"""
33
- fake = Fake.objects.create(badges=[Badge(kind=OTHER)])
53
+ fake = Fake.objects.create()
54
+ fake.add_badge(OTHER)
34
55
  badge = fake.get_badge(TEST)
35
56
  self.assertIsNone(badge)
36
57
 
@@ -49,7 +70,8 @@ class BadgeMixinTest(DBTestMixin, TestCase):
49
70
 
50
71
  def test_add_2nd_badge(self):
51
72
  """It should add badges to the top of the list"""
52
- fake = Fake.objects.create(badges=[Badge(kind=OTHER)])
73
+ fake = Fake.objects.create()
74
+ fake.add_badge(OTHER)
53
75
 
54
76
  result = fake.add_badge(TEST)
55
77
 
@@ -86,8 +108,8 @@ class BadgeMixinTest(DBTestMixin, TestCase):
86
108
 
87
109
  def test_remove_badge(self):
88
110
  """It should remove a badge given its kind"""
89
- badge = Badge(kind=TEST)
90
- fake = Fake.objects.create(badges=[badge])
111
+ fake = Fake.objects.create()
112
+ fake.add_badge(TEST)
91
113
 
92
114
  fake.remove_badge(TEST)
93
115
 
@@ -121,28 +143,15 @@ class BadgeMixinTest(DBTestMixin, TestCase):
121
143
 
122
144
  def test_toggle_remove_badge(self):
123
145
  """Toggle should remove a badge given its kind if present"""
124
- badge = Badge(kind=TEST)
125
- fake = Fake.objects.create(badges=[badge])
146
+ fake = Fake.objects.create()
147
+ fake.add_badge(TEST)
126
148
 
127
149
  fake.toggle_badge(TEST)
128
150
 
129
151
  self.assertEqual(len(fake.badges), 0)
130
152
 
131
- def test_create_with_badges(self):
132
- """It should allow object creation with badges"""
133
- fake = Fake.objects.create(badges=[Badge(kind=TEST), Badge(kind=OTHER)])
134
-
135
- self.assertEqual(len(fake.badges), 2)
136
- for badge, kind in zip(fake.badges, (TEST, OTHER)):
137
- self.assertEqual(badge.kind, kind)
138
- self.assertIsNotNone(badge.created)
139
-
140
- def test_create_disallow_duplicate_badges(self):
141
- """It should not allow object creation with duplicate badges"""
142
- with self.assertRaises(db.ValidationError):
143
- Fake.objects.create(badges=[Badge(kind=TEST), Badge(kind=TEST)])
144
-
145
153
  def test_create_disallow_unknown_badges(self):
146
154
  """It should not allow object creation with unknown badges"""
147
155
  with self.assertRaises(db.ValidationError):
148
- Fake.objects.create(badges=[Badge(kind="unknown")])
156
+ fake = Fake.objects.create()
157
+ fake.add_badge("unknown")
@@ -95,7 +95,10 @@ class HarvestMetadata(db.EmbeddedDocument):
95
95
  archived_at = field(db.DateTimeField())
96
96
 
97
97
 
98
- @generate_fields(searchable=True)
98
+ @generate_fields(
99
+ searchable=True,
100
+ additional_filters={"organization_badge": "organization.badges"},
101
+ )
99
102
  class Dataservice(WithMetrics, Owned, db.Document):
100
103
  meta = {
101
104
  "indexes": [
udata/core/dataset/api.py CHANGED
@@ -36,6 +36,7 @@ from udata.core.badges.fields import badge_fields
36
36
  from udata.core.dataservices.models import Dataservice
37
37
  from udata.core.dataset.models import CHECKSUM_TYPES
38
38
  from udata.core.followers.api import FollowAPI
39
+ from udata.core.organization.models import Organization
39
40
  from udata.core.storages.api import handle_upload, upload_parser
40
41
  from udata.core.topic.models import Topic
41
42
  from udata.linkchecker.checker import check_resource
@@ -96,6 +97,12 @@ class DatasetApiParser(ModelApiParser):
96
97
  self.parser.add_argument("granularity", type=str, location="args")
97
98
  self.parser.add_argument("temporal_coverage", type=str, location="args")
98
99
  self.parser.add_argument("organization", type=str, location="args")
100
+ self.parser.add_argument(
101
+ "organization_badge",
102
+ type=str,
103
+ choices=list(Organization.__badges__),
104
+ location="args",
105
+ )
99
106
  self.parser.add_argument("owner", type=str, location="args")
100
107
  self.parser.add_argument("format", type=str, location="args")
101
108
  self.parser.add_argument("schema", type=str, location="args")
@@ -131,6 +138,9 @@ class DatasetApiParser(ModelApiParser):
131
138
  if not ObjectId.is_valid(args["organization"]):
132
139
  api.abort(400, "Organization arg must be an identifier")
133
140
  datasets = datasets.filter(organization=args["organization"])
141
+ if args.get("organization_badge"):
142
+ orgs = Organization.objects.with_badge(args["organization_badge"]).only("id")
143
+ datasets = datasets.filter(organization__in=orgs)
134
144
  if args.get("owner"):
135
145
  if not ObjectId.is_valid(args["owner"]):
136
146
  api.abort(400, "Owner arg must be an identifier")
@@ -1,4 +1,6 @@
1
1
  import datetime
2
+ from collections.abc import Callable
3
+ from typing import Any
2
4
 
3
5
  import requests
4
6
  from flask import current_app
@@ -38,23 +40,28 @@ def serialize_resource_for_event(resource):
38
40
  return resource_dict
39
41
 
40
42
 
43
+ def payload_for_resource(document: Any, resource_id: str | None) -> dict | None:
44
+ if resource_id is None: # On delete, there is no resource_id, and no need for a payload.
45
+ return None
46
+ resource: dict = serialize_resource_for_event(get_by(document.resources, "id", resource_id))
47
+ return {
48
+ "resource_id": str(resource_id),
49
+ "dataset_id": str(document.id),
50
+ "document": resource,
51
+ }
52
+
53
+
41
54
  @task(route="high.resource")
42
- def publish(url, document, resource_id, action):
55
+ def publish(url: str, document: Any, resource_id: str, action: str) -> None:
56
+ method: Callable
43
57
  match action:
44
58
  case EventMessageType.CREATED:
45
59
  method = requests.post
46
- resource = serialize_resource_for_event(get_by(document.resources, "id", resource_id))
47
60
  case EventMessageType.MODIFIED:
48
61
  method = requests.put
49
- resource = serialize_resource_for_event(get_by(document.resources, "id", resource_id))
50
62
  case EventMessageType.DELETED:
51
63
  method = requests.delete
52
- resource = None
53
- payload = {
54
- "resource_id": str(resource_id),
55
- "dataset_id": str(document.id),
56
- "document": resource,
57
- }
64
+ payload: dict | None = payload_for_resource(document, resource_id)
58
65
  headers = {}
59
66
  if current_app.config["RESOURCES_ANALYSER_API_KEY"]:
60
67
  headers = {"Authorization": f"Bearer {current_app.config['RESOURCES_ANALYSER_API_KEY']}"}
@@ -63,7 +70,7 @@ def publish(url, document, resource_id, action):
63
70
 
64
71
 
65
72
  @Dataset.on_resource_added.connect
66
- def publish_added_resource_message(sender, document, **kwargs):
73
+ def publish_added_resource_message(sender, document, **kwargs) -> None:
67
74
  if current_app.config.get("PUBLISH_ON_RESOURCE_EVENTS") and current_app.config.get(
68
75
  "RESOURCES_ANALYSER_URI"
69
76
  ):
@@ -76,26 +83,28 @@ def publish_added_resource_message(sender, document, **kwargs):
76
83
 
77
84
 
78
85
  @Dataset.on_resource_updated.connect
79
- def publish_updated_resource_message(sender, document, **kwargs):
86
+ def publish_updated_resource_message(sender, document, **kwargs) -> None:
80
87
  if current_app.config.get("PUBLISH_ON_RESOURCE_EVENTS") and current_app.config.get(
81
88
  "RESOURCES_ANALYSER_URI"
82
89
  ):
90
+ resource_id: str = kwargs["resource_id"]
83
91
  publish.delay(
84
- f"{current_app.config.get('RESOURCES_ANALYSER_URI')}/api/resources/",
92
+ f"{current_app.config.get('RESOURCES_ANALYSER_URI')}/api/resources/{resource_id}",
85
93
  document,
86
- kwargs["resource_id"],
94
+ resource_id,
87
95
  EventMessageType.MODIFIED,
88
96
  )
89
97
 
90
98
 
91
99
  @Dataset.on_resource_removed.connect
92
- def publish_removed_resource_message(sender, document, **kwargs):
100
+ def publish_removed_resource_message(sender, document, **kwargs) -> None:
93
101
  if current_app.config.get("PUBLISH_ON_RESOURCE_EVENTS") and current_app.config.get(
94
102
  "RESOURCES_ANALYSER_URI"
95
103
  ):
104
+ resource_id: str = kwargs["resource_id"]
96
105
  publish.delay(
97
- f"{current_app.config.get('RESOURCES_ANALYSER_URI')}/api/resources/",
106
+ f"{current_app.config.get('RESOURCES_ANALYSER_URI')}/api/resources/{resource_id}",
98
107
  document,
99
- kwargs["resource_id"],
108
+ None,
100
109
  EventMessageType.DELETED,
101
110
  )