udata 10.0.1.dev32334__py2.py3-none-any.whl → 10.0.1.dev32350__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 (24) hide show
  1. udata/api_fields.py +131 -70
  2. udata/static/chunks/{10.a99bb538cfbadb38dbcb.js → 10.dac55d18d0b4ef3cdacf.js} +3 -3
  3. udata/static/chunks/{10.a99bb538cfbadb38dbcb.js.map → 10.dac55d18d0b4ef3cdacf.js.map} +1 -1
  4. udata/static/chunks/{11.727465d72948bc466d43.js → 11.4a20a75f827c5a1125c3.js} +3 -3
  5. udata/static/chunks/{11.727465d72948bc466d43.js.map → 11.4a20a75f827c5a1125c3.js.map} +1 -1
  6. udata/static/chunks/{13.3c8337bec315adcd2700.js → 13.645dd0b7c0b9210f1b56.js} +2 -2
  7. udata/static/chunks/{13.3c8337bec315adcd2700.js.map → 13.645dd0b7c0b9210f1b56.js.map} +1 -1
  8. udata/static/chunks/{17.cbeb6d95129cad6481c2.js → 17.8e19985c4d12a3b7b0c0.js} +2 -2
  9. udata/static/chunks/{17.cbeb6d95129cad6481c2.js.map → 17.8e19985c4d12a3b7b0c0.js.map} +1 -1
  10. udata/static/chunks/{19.4f7a5b71ef006ac268c1.js → 19.825a43c330157e351fca.js} +3 -3
  11. udata/static/chunks/{19.4f7a5b71ef006ac268c1.js.map → 19.825a43c330157e351fca.js.map} +1 -1
  12. udata/static/chunks/{8.60610fd40b95ca119141.js → 8.5ee0cf635c848abbfc05.js} +2 -2
  13. udata/static/chunks/{8.60610fd40b95ca119141.js.map → 8.5ee0cf635c848abbfc05.js.map} +1 -1
  14. udata/static/chunks/{9.985935421e62c97a9f86.js → 9.df3c36f8d0d210621fbb.js} +3 -3
  15. udata/static/chunks/{9.985935421e62c97a9f86.js.map → 9.df3c36f8d0d210621fbb.js.map} +1 -1
  16. udata/static/common.js +1 -1
  17. udata/static/common.js.map +1 -1
  18. udata/tests/test_api_fields.py +285 -0
  19. {udata-10.0.1.dev32334.dist-info → udata-10.0.1.dev32350.dist-info}/METADATA +2 -2
  20. {udata-10.0.1.dev32334.dist-info → udata-10.0.1.dev32350.dist-info}/RECORD +24 -23
  21. {udata-10.0.1.dev32334.dist-info → udata-10.0.1.dev32350.dist-info}/LICENSE +0 -0
  22. {udata-10.0.1.dev32334.dist-info → udata-10.0.1.dev32350.dist-info}/WHEEL +0 -0
  23. {udata-10.0.1.dev32334.dist-info → udata-10.0.1.dev32350.dist-info}/entry_points.txt +0 -0
  24. {udata-10.0.1.dev32334.dist-info → udata-10.0.1.dev32350.dist-info}/top_level.txt +0 -0
udata/api_fields.py CHANGED
@@ -1,16 +1,56 @@
1
+ """Enhance a MongoEngine document class to give it super powers by decorating it with @generate_fields.
2
+
3
+ The main goal of `generate_fields` is to remove duplication: we used to have fields declaration in
4
+ - models.py
5
+ - forms.py
6
+ - api_fields.py
7
+
8
+ Now they're defined in models.py, and adding the `generate_fields` decorator makes them available in the format we need them for the forms or the API.
9
+
10
+ - default_filterable_field: which field in this document should be the default filter, eg when filtering by Badge, you're actually filtering on `Badge.kind`
11
+ - searchable: boolean, if True, the document can be full-text searched using MongoEngine text search
12
+ - additional_sorts: add more sorts than the already available ones based on fields (see below). Eg, sort by metrics.
13
+ - additional_filters: filter on a field of a field (aka "join"), eg filter on `Reuse__organization__badge=PUBLIC_SERVICE`.
14
+
15
+
16
+ On top of those functionalities added to the document by the `@generate_fields` decorator parameters,
17
+ the document fields are parsed and enhanced if they are wrapped in the `field` helper.
18
+
19
+ - sortable: boolean, if True, it'll be available in the list of sort options
20
+ - show_as_ref: add to the list of `ref_fields` (see below)
21
+ - readonly: don't add this field to the `write_fields`
22
+ - markdown: use Mardown to format this field instead of plain old text
23
+ - filterable: this field can be filtered on. It's either an empty dictionnary, either {`key`: `field_name`} if the `field_name` to use is different from the original field, eg `dataset` instead of `datasets`.
24
+ - description: use as the info on the field in the swagger forms.
25
+ - check: provide a function to validate the content of the field.
26
+ - thumbnail_info: add additional info for a thumbnail, eg `{ "size": BIGGEST_IMAGE_SIZE }`.
27
+
28
+ You may also use the `@function_field` decorator to treat a document method as a field.
29
+
30
+
31
+ The following fields are added on the document class once decorated:
32
+
33
+ - ref_fields: list of fields to return when embedding/referencing a document, eg when querying Reuse.organization, only return a subset of the org fields
34
+ - read_fields: all of the fields to return when querying a document
35
+ - write_fields: list of fields to provide when creating a document, eg when creating a Reuse, we only provide organization IDs, not all the org fields
36
+
37
+ """
38
+
1
39
  import functools
2
- from typing import Any, Dict
40
+ from typing import Any, Callable, Iterable
3
41
 
4
42
  import flask_restx.fields as restx_fields
5
43
  import mongoengine
6
44
  import mongoengine.fields as mongo_fields
7
45
  from bson import ObjectId
8
46
  from flask_restx.inputs import boolean
47
+ from flask_restx.reqparse import RequestParser
9
48
  from flask_storage.mongo import ImageField as FlaskStorageImageField
10
49
 
11
50
  import udata.api.fields as custom_restx_fields
12
51
  from udata.api import api, base_reference
13
52
  from udata.mongo.errors import FieldValidationError
53
+ from udata.mongo.queryset import DBPaginator, UDataQuerySet
14
54
 
15
55
  lazy_reference = api.model(
16
56
  "LazyReference",
@@ -21,26 +61,27 @@ lazy_reference = api.model(
21
61
  )
22
62
 
23
63
 
24
- def convert_db_to_field(key, field, info):
25
- """
26
- This function maps a Mongo field to a Flask RestX field.
64
+ def convert_db_to_field(key, field, info) -> tuple[Callable | None, Callable | None]:
65
+ """Map a Mongo field to a Flask RestX field.
66
+
27
67
  Most of the types are a simple 1-to-1 mapping except lists and references that requires
28
68
  more work.
29
69
  We currently only map the params that we use from Mongo to RestX (for example min_length / max_length…).
30
70
 
31
71
  In the first part of the function we save the RestX constructor as a lambda because we need to call it with the
32
72
  params. Since merging the params involve a litte bit of work (merging default params with read/write params and then with
33
- user-supplied overrides, setting the readonly flag…), it's easier to have do this one time at the end of the function.
73
+ user-supplied overrides, setting the readonly flag…), it's easier to have to do this only once at the end of the function.
74
+
34
75
  """
35
- params = {}
76
+ params: dict = {}
36
77
  params["required"] = field.required
37
78
 
38
- read_params = {}
39
- write_params = {}
79
+ read_params: dict = {}
80
+ write_params: dict = {}
40
81
 
41
- constructor = None
42
- constructor_read = None
43
- constructor_write = None
82
+ constructor: Callable
83
+ constructor_read: Callable | None = None
84
+ constructor_write: Callable | None = None
44
85
 
45
86
  if info.get("convert_to"):
46
87
  # TODO: this is currently never used. We may remove it if the auto-conversion
@@ -66,7 +107,7 @@ def convert_db_to_field(key, field, info):
66
107
  elif isinstance(field, mongo_fields.DictField):
67
108
  constructor = restx_fields.Raw
68
109
  elif isinstance(field, mongo_fields.ImageField) or isinstance(field, FlaskStorageImageField):
69
- size = info.get("size", None)
110
+ size: int | None = info.get("size", None)
70
111
  if size:
71
112
  params["description"] = f"URL of the cropped and squared image ({size}x{size})"
72
113
  else:
@@ -103,7 +144,7 @@ def convert_db_to_field(key, field, info):
103
144
  # 1. `inner_field_info` inside `__additional_field_info__` on the parent
104
145
  # 2. `__additional_field_info__` of the inner field
105
146
  # 3. `__additional_field_info__` of the parent
106
- inner_info = getattr(field.field, "__additional_field_info__", {})
147
+ inner_info: dict = getattr(field.field, "__additional_field_info__", {})
107
148
  field_read, field_write = convert_db_to_field(
108
149
  f"{key}.inner", field.field, {**info, **inner_info, **info.get("inner_field_info", {})}
109
150
  )
@@ -130,7 +171,7 @@ def convert_db_to_field(key, field, info):
130
171
  # For reading, if the user supplied a `nested_fields` (RestX model), we use it to convert
131
172
  # the referenced model, if not we return a String (and RestX will call the `str()` of the model
132
173
  # when returning from an endpoint)
133
- nested_fields = info.get("nested_fields")
174
+ nested_fields: dict | None = info.get("nested_fields")
134
175
  if nested_fields is None:
135
176
  # If there is no `nested_fields` convert the object to the string representation.
136
177
  constructor_read = restx_fields.String
@@ -167,9 +208,11 @@ def convert_db_to_field(key, field, info):
167
208
  read_params = {**params, **read_params, **info}
168
209
  write_params = {**params, **write_params, **info}
169
210
 
170
- read = constructor_read(**read_params) if constructor_read else constructor(**read_params)
211
+ read: Callable = (
212
+ constructor_read(**read_params) if constructor_read else constructor(**read_params)
213
+ )
171
214
  if write_params.get("readonly", False) or (constructor_write is None and constructor is None):
172
- write = None
215
+ write: Callable | None = None
173
216
  else:
174
217
  write = (
175
218
  constructor_write(**write_params) if constructor_write else constructor(**write_params)
@@ -177,10 +220,11 @@ def convert_db_to_field(key, field, info):
177
220
  return read, write
178
221
 
179
222
 
180
- def get_fields(cls):
181
- """
182
- Returns all the exposed fields of the class (fields decorated with `field()`)
183
- It also expends image fields to add thumbnail fields.
223
+ def get_fields(cls) -> Iterable[tuple[str, Callable, dict]]:
224
+ """Return all the document fields that are wrapped with the `field()` helper.
225
+
226
+ Also expand image fields to add thumbnail fields.
227
+
184
228
  """
185
229
  for key, field in cls._fields.items():
186
230
  info: dict | None = getattr(field, "__additional_field_info__", None)
@@ -197,27 +241,35 @@ def get_fields(cls):
197
241
  )
198
242
 
199
243
 
200
- def generate_fields(**kwargs):
201
- """
244
+ def generate_fields(**kwargs) -> Callable:
245
+ """Mongoengine document decorator.
246
+
202
247
  This decorator will create two auto-generated attributes on the class `__read_fields__` and `__write_fields__`
203
248
  that can be used in API endpoint inside `expect()` and `marshall_with()`.
249
+
250
+ It will also
251
+ - generate an API parameter parser
252
+ - sort and filter a list of documents with the provided params using the `apply_sort_filters_and_pagination` helper
253
+
204
254
  """
205
255
 
206
- def wrapper(cls):
207
- read_fields = {}
208
- write_fields = {}
209
- ref_fields = {}
210
- sortables = kwargs.get("additional_sorts", [])
256
+ def wrapper(cls) -> Callable:
257
+ from udata.models import db
211
258
 
212
- filterables = []
213
- additional_filters = get_fields_with_additional_filters(
259
+ read_fields: dict = {}
260
+ write_fields: dict = {}
261
+ ref_fields: dict = {}
262
+ sortables: list = kwargs.get("additional_sorts", [])
263
+
264
+ filterables: list[dict] = []
265
+ additional_filters: dict[str, dict] = get_fields_with_additional_filters(
214
266
  kwargs.get("additional_filters", {})
215
267
  )
216
268
 
217
269
  read_fields["id"] = restx_fields.String(required=True, readonly=True)
218
270
 
219
271
  for key, field, info in get_fields(cls):
220
- sortable_key = info.get("sortable", False)
272
+ sortable_key: bool = info.get("sortable", False)
221
273
  if sortable_key:
222
274
  sortables.append(
223
275
  {
@@ -226,41 +278,41 @@ def generate_fields(**kwargs):
226
278
  }
227
279
  )
228
280
 
229
- filterable = info.get("filterable", None)
230
-
281
+ filterable: dict[str, Any] | None = info.get("filterable", None)
231
282
  if filterable is not None:
232
283
  filterables.append(compute_filter(key, field, info, filterable))
233
284
 
234
- additional_filter = additional_filters.get(key, None)
285
+ additional_filter: dict | None = additional_filters.get(key, None)
235
286
  if additional_filter:
236
287
  if not isinstance(
237
288
  field, mongo_fields.ReferenceField | mongo_fields.LazyReferenceField
238
289
  ):
239
290
  raise Exception("Cannot use additional_filters on not a ref.")
240
291
 
241
- ref_model = field.document_type
292
+ ref_model: db.Document = field.document_type
242
293
 
243
294
  for child in additional_filter.get("children", []):
244
- inner_field = getattr(ref_model, child["key"])
295
+ inner_field: str = getattr(ref_model, child["key"])
245
296
 
246
- column = f"{key}__{child['key']}"
297
+ column: str = f"{key}__{child['key']}"
247
298
  child["key"] = f"{key}_{child['key']}"
248
299
  filterable = compute_filter(column, inner_field, info, child)
249
300
 
250
301
  # Since MongoDB is not capable of doing joins with a column like `organization__slug` we need to
251
302
  # do a custom filter by splitting the query in two.
252
303
 
253
- def query(filterable, query, value):
304
+ def query(filterable, query, value) -> UDataQuerySet:
254
305
  # We use the computed `filterable["column"]` here because the `compute_filter` function
255
306
  # could have added a default filter at the end (for example `organization__badges` converted
256
307
  # in `organization__badges__kind`)
257
308
  parts = filterable["column"].split("__", 1)
258
- models = ref_model.objects.filter(**{parts[1]: value}).only("id")
309
+ models: UDataQuerySet = ref_model.objects.filter(**{parts[1]: value}).only(
310
+ "id"
311
+ )
259
312
  return query.filter(**{f"{parts[0]}__in": models})
260
313
 
261
314
  # do a query-based filter instead of a column based one
262
315
  filterable["query"] = functools.partial(query, filterable)
263
-
264
316
  filterables.append(filterable)
265
317
 
266
318
  read, write = convert_db_to_field(key, field, info)
@@ -289,8 +341,8 @@ def generate_fields(**kwargs):
289
341
  if not callable(method):
290
342
  continue
291
343
 
292
- info = getattr(method, "__additional_field_info__", None)
293
- if info is None:
344
+ additional_field_info = getattr(method, "__additional_field_info__", None)
345
+ if additional_field_info is None:
294
346
  continue
295
347
 
296
348
  def make_lambda(method):
@@ -302,16 +354,16 @@ def generate_fields(**kwargs):
302
354
  return lambda o: method(o)
303
355
 
304
356
  read_fields[method_name] = restx_fields.String(
305
- attribute=make_lambda(method), **{"readonly": True, **info}
357
+ attribute=make_lambda(method), **{"readonly": True, **additional_field_info}
306
358
  )
307
- if info.get("show_as_ref", False):
359
+ if additional_field_info.get("show_as_ref", False):
308
360
  ref_fields[key] = read_fields[method_name]
309
361
 
310
362
  cls.__read_fields__ = api.model(f"{cls.__name__} (read)", read_fields, **kwargs)
311
363
  cls.__write_fields__ = api.model(f"{cls.__name__} (write)", write_fields, **kwargs)
312
364
  cls.__ref_fields__ = api.inherit(f"{cls.__name__}Reference", base_reference, ref_fields)
313
365
 
314
- mask = kwargs.pop("mask", None)
366
+ mask: str | None = kwargs.pop("mask", None)
315
367
  if mask is not None:
316
368
  mask = "data{{{0}}},*".format(mask)
317
369
  cls.__page_fields__ = api.model(
@@ -322,8 +374,8 @@ def generate_fields(**kwargs):
322
374
  )
323
375
 
324
376
  # Parser for index sort/filters
325
- paginable = kwargs.get("paginable", True)
326
- parser = api.parser()
377
+ paginable: bool = kwargs.get("paginable", True)
378
+ parser: RequestParser = api.parser()
327
379
 
328
380
  if paginable:
329
381
  parser.add_argument(
@@ -334,7 +386,7 @@ def generate_fields(**kwargs):
334
386
  )
335
387
 
336
388
  if sortables:
337
- choices = [sortable["key"] for sortable in sortables] + [
389
+ choices: list[str] = [sortable["key"] for sortable in sortables] + [
338
390
  "-" + sortable["key"] for sortable in sortables
339
391
  ]
340
392
  parser.add_argument(
@@ -345,12 +397,13 @@ def generate_fields(**kwargs):
345
397
  help="The field (and direction) on which sorting apply",
346
398
  )
347
399
 
348
- searchable = kwargs.pop("searchable", False)
400
+ searchable: bool = kwargs.pop("searchable", False)
349
401
  if searchable:
350
402
  parser.add_argument("q", type=str, location="args")
351
403
 
352
404
  for filterable in filterables:
353
405
  parser.add_argument(
406
+ # Use the custom label from `additional_filters` if there's one.
354
407
  filterable.get("label", filterable["key"]),
355
408
  type=filterable["type"],
356
409
  location="args",
@@ -359,18 +412,17 @@ def generate_fields(**kwargs):
359
412
 
360
413
  cls.__index_parser__ = parser
361
414
 
362
- def apply_sort_filters_and_pagination(base_query):
415
+ def apply_sort_filters_and_pagination(base_query) -> DBPaginator:
363
416
  args = cls.__index_parser__.parse_args()
364
417
 
365
418
  if sortables and args["sort"]:
366
- negate = args["sort"].startswith("-")
367
- sort_key = args["sort"][1:] if negate else args["sort"]
419
+ negate: bool = args["sort"].startswith("-")
420
+ sort_key: str = args["sort"][1:] if negate else args["sort"]
368
421
 
369
- sort_by = next(
422
+ sort_by: str | None = next(
370
423
  (sortable["value"] for sortable in sortables if sortable["key"] == sort_key),
371
424
  None,
372
425
  )
373
-
374
426
  if sort_by:
375
427
  if negate:
376
428
  sort_by = "-" + sort_by
@@ -378,10 +430,13 @@ def generate_fields(**kwargs):
378
430
  base_query = base_query.order_by(sort_by)
379
431
 
380
432
  if searchable and args.get("q"):
381
- phrase_query = " ".join([f'"{elem}"' for elem in args["q"].split(" ")])
433
+ phrase_query: str = " ".join([f'"{elem}"' for elem in args["q"].split(" ")])
382
434
  base_query = base_query.search_text(phrase_query)
383
435
 
384
436
  for filterable in filterables:
437
+ # If it's from an `additional_filter`, use the custom label instead of the key,
438
+ # eg use `organization_badge` instead of `organization.badges` which is
439
+ # computed to `organization_badges`.
385
440
  filter = args.get(filterable.get("label", filterable["key"]))
386
441
  if filter is not None:
387
442
  for constraint in filterable.get("constraints", []):
@@ -412,7 +467,7 @@ def generate_fields(**kwargs):
412
467
  return wrapper
413
468
 
414
469
 
415
- def function_field(**info):
470
+ def function_field(**info) -> Callable:
416
471
  def inner(func):
417
472
  func.__additional_field_info__ = info
418
473
  return func
@@ -421,18 +476,20 @@ def function_field(**info):
421
476
 
422
477
 
423
478
  def field(inner, **kwargs):
424
- """
425
- Simple decorator to mark a field as visible for the API fields.
426
- We can pass additional arguments that will be forward to the RestX field constructor.
479
+ """Simple wrapper to make a document field visible for the API.
480
+
481
+ We can pass additional arguments that will be forwarded to the RestX field constructor.
482
+
427
483
  """
428
484
  inner.__additional_field_info__ = kwargs
429
485
  return inner
430
486
 
431
487
 
432
- def patch(obj, request):
433
- """
434
- Patch the object with the data from the request.
488
+ def patch(obj, request) -> type:
489
+ """Patch the object with the data from the request.
490
+
435
491
  Only fields decorated with the `field()` decorator will be read (and not readonly).
492
+
436
493
  """
437
494
  from udata.mongo.engine import db
438
495
 
@@ -480,7 +537,7 @@ def patch(obj, request):
480
537
  return obj
481
538
 
482
539
 
483
- def patch_and_save(obj, request):
540
+ def patch_and_save(obj, request) -> type:
484
541
  obj = patch(obj, request)
485
542
 
486
543
  try:
@@ -497,11 +554,12 @@ def wrap_primary_key(
497
554
  value: str | None,
498
555
  document_type=None,
499
556
  ):
500
- """
501
- We need to wrap the `String` inside an `ObjectId` most of the time. If the foreign ID is a `String` we need to get
502
- a `DBRef` from the database.
557
+ """Wrap the `String` inside an `ObjectId`.
558
+
559
+ If the foreign ID is a `String`, get a `DBRef` from the database.
503
560
 
504
561
  TODO: we only check the document reference if the ID is a `String` field (not in the case of a classic `ObjectId`).
562
+
505
563
  """
506
564
  if value is None:
507
565
  return value
@@ -547,13 +605,15 @@ def wrap_primary_key(
547
605
  )
548
606
 
549
607
 
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".
608
+ def get_fields_with_additional_filters(additional_filters: dict[str, str]) -> dict[str, Any]:
609
+ """Filter on additional related fields.
553
610
 
554
- The goal of this function is to key the additional filters by the first part (`organization`) to
611
+ Right now we only support additional filters with a depth of two, eg "organization.badges".
612
+
613
+ The goal of this function is to key by the additional filters by the first part (`organization`) to
555
614
  be able to compute them when we loop over all the fields (`title`, `organization`…)
556
615
 
616
+
557
617
  The `additional_filters` property is a dict: {"label": "key"}, for example {"organization_badge": "organization.badges"}.
558
618
  The `label` will be the name of the parser arg, like `?organization_badge=public-service`, which makes more
559
619
  sense than `?organization_badges=public-service`.
@@ -570,6 +630,7 @@ def get_fields_with_additional_filters(additional_filters: Dict[str, str]) -> Di
570
630
 
571
631
  results[parent]["children"].append(
572
632
  {
633
+ # The name for the parser argument, so `organization_badge` instead of `organization_badges`.
573
634
  "label": label,
574
635
  "key": child,
575
636
  "type": str,
@@ -581,7 +642,7 @@ def get_fields_with_additional_filters(additional_filters: Dict[str, str]) -> Di
581
642
  return results
582
643
 
583
644
 
584
- def compute_filter(column: str, field, info, filterable):
645
+ def compute_filter(column: str, field, info, filterable) -> dict:
585
646
  # "key" is the param key in the URL
586
647
  if "key" not in filterable:
587
648
  filterable["key"] = column