geovisio 2.8.1__py3-none-any.whl → 2.9.0__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.
geovisio/web/items.py CHANGED
@@ -6,13 +6,16 @@ from typing import Dict, List, Optional, Any
6
6
  from urllib.parse import unquote
7
7
  from psycopg.types.json import Jsonb
8
8
  from pydantic import BaseModel, ConfigDict, ValidationError, field_validator, model_validator
9
+ from shapely import intersects
9
10
  from werkzeug.datastructures import MultiDict
10
11
  from uuid import UUID
11
12
  from geovisio import errors, utils
12
13
  from geovisio.utils import auth, db
14
+ from geovisio.utils.cql2 import parse_search_filter
13
15
  from geovisio.utils.params import validation_error
14
16
  from geovisio.utils.pictures import cleanupExif
15
- from geovisio.utils.semantics import SemanticTagUpdate, Entity, EntityType, update_tags
17
+ from geovisio.utils.semantics import Entity, EntityType, update_tags
18
+ from geovisio.utils.tags import SemanticTagUpdate
16
19
  from geovisio.web.params import (
17
20
  as_latitude,
18
21
  as_longitude,
@@ -138,6 +141,7 @@ def dbPictureToStacItem(seqId, dbPic):
138
141
  "pers:roll": dbPic["metadata"].get("roll"),
139
142
  "geovisio:status": dbPic.get("status"),
140
143
  "geovisio:producer": dbPic["account_name"],
144
+ "geovisio:rank_in_collection": dbPic["rank"],
141
145
  "original_file:size": dbPic["metadata"].get("originalFileSize"),
142
146
  "original_file:name": dbPic["metadata"].get("originalFileName"),
143
147
  "panoramax:horizontal_pixel_density": dbPic.get("h_pixel_density"),
@@ -145,7 +149,8 @@ def dbPictureToStacItem(seqId, dbPic):
145
149
  "geovisio:thumbnail": _getThumbJpgPictureURL(dbPic["id"], dbPic.get("status")),
146
150
  "exif": removeNoneInDict(cleanupExif(dbPic["exif"])),
147
151
  "quality:horizontal_accuracy": float("{:.1f}".format(dbPic["gps_accuracy_m"])) if dbPic.get("gps_accuracy_m") else None,
148
- "semantics": dbPic["semantics"] if "semantics" in dbPic else None,
152
+ "semantics": [s for s in dbPic.get("semantics") or [] if s],
153
+ "annotations": [a for a in dbPic.get("annotations") or [] if a],
149
154
  }
150
155
  ),
151
156
  "links": cleanNoneInList(
@@ -437,19 +442,12 @@ def getCollectionItems(collectionId):
437
442
  CASE WHEN LAG(p.status) OVER othpics = 'ready' THEN ST_AsGeoJSON(LAG(p.geom) OVER othpics)::json END AS prevpicgeojson,
438
443
  CASE WHEN LEAD(p.status) OVER othpics = 'ready' THEN LEAD(p.id) OVER othpics END AS nextpic,
439
444
  CASE WHEN LEAD(p.status) OVER othpics = 'ready' THEN ST_AsGeoJSON(LEAD(p.geom) OVER othpics)::json END AS nextpicgeojson,
440
- t.semantics
445
+ get_picture_semantics(p.id) as semantics,
446
+ get_picture_annotations(p.id) as annotations
441
447
  FROM sequences_pictures sp
442
448
  JOIN pictures p ON sp.pic_id = p.id
443
449
  JOIN accounts a ON a.id = p.account_id
444
450
  JOIN sequences s ON s.id = sp.seq_id
445
- LEFT JOIN (
446
- SELECT picture_id, json_agg(json_strip_nulls(json_build_object(
447
- 'key', key,
448
- 'value', value
449
- ))) AS semantics
450
- FROM pictures_semantics
451
- GROUP BY picture_id
452
- ) t ON t.picture_id = p.id
453
451
  WHERE
454
452
  {filter}
455
453
  WINDOW othpics AS (PARTITION BY sp.seq_id ORDER BY sp.rank)
@@ -460,15 +458,14 @@ def getCollectionItems(collectionId):
460
458
 
461
459
  records = cursor.execute(query, params)
462
460
 
463
- bounds: Optional[Bounds] = None
464
461
  items = []
462
+ first_rank, last_rank = None, None
465
463
  for dbPic in records:
466
- if not bounds:
467
- bounds = Bounds(min=dbPic["rank"], max=dbPic["rank"])
468
- else:
469
- bounds.update(dbPic["rank"])
470
-
464
+ if first_rank is None:
465
+ first_rank = dbPic["rank"]
466
+ last_rank = dbPic["rank"]
471
467
  items.append(dbPictureToStacItem(collectionId, dbPic))
468
+ bounds = Bounds(first=first_rank, last=last_rank) if records else None
472
469
 
473
470
  links = [
474
471
  get_root_link(),
@@ -491,8 +488,8 @@ def getCollectionItems(collectionId):
491
488
  ]
492
489
 
493
490
  if paginated and items and bounds:
494
- if bounds.min:
495
- has_item_before = bounds.min > seqMeta["min_rank"]
491
+ if bounds.first:
492
+ has_item_before = bounds.first > seqMeta["min_rank"]
496
493
  if has_item_before:
497
494
  links.append(
498
495
  {
@@ -504,7 +501,7 @@ def getCollectionItems(collectionId):
504
501
  # Previous page link
505
502
  # - If limit is set, rank is current - limit -1
506
503
  # - If no limit is set, rank is 0 (none)
507
- prevRank = bounds.min - limit - 1 if limit is not None else 0
504
+ prevRank = bounds.first - limit - 1 if limit is not None else 0
508
505
  if prevRank < 1:
509
506
  prevRank = None
510
507
  links.append(
@@ -521,7 +518,7 @@ def getCollectionItems(collectionId):
521
518
  }
522
519
  )
523
520
 
524
- has_item_after = bounds.max < seqMeta["max_rank"]
521
+ has_item_after = bounds.last < seqMeta["max_rank"]
525
522
  if has_item_after:
526
523
  links.append(
527
524
  {
@@ -532,7 +529,7 @@ def getCollectionItems(collectionId):
532
529
  _external=True,
533
530
  collectionId=collectionId,
534
531
  limit=limit,
535
- startAfterRank=bounds.max,
532
+ startAfterRank=bounds.last,
536
533
  ),
537
534
  }
538
535
  )
@@ -543,10 +540,10 @@ def getCollectionItems(collectionId):
543
540
 
544
541
  lastPageRank = startAfterRank
545
542
  if limit is not None:
546
- if seqMeta["max_rank"] > bounds.max:
543
+ if seqMeta["max_rank"] > bounds.last:
547
544
  lastPageRank = seqMeta["max_rank"] - limit
548
- if lastPageRank < bounds.max:
549
- lastPageRank = bounds.max
545
+ if lastPageRank < bounds.last:
546
+ lastPageRank = bounds.last
550
547
 
551
548
  links.append(
552
549
  {
@@ -608,19 +605,13 @@ def _getPictureItemById(collectionId, itemId):
608
605
  p.account_id AS account_id,
609
606
  spl.prevpic, spl.prevpicgeojson, spl.nextpic, spl.nextpicgeojson, p.exif,
610
607
  relp.related_pics, p.gps_accuracy_m, p.h_pixel_density,
611
- t.semantics
608
+
609
+ get_picture_semantics(p.id) as semantics,
610
+ get_picture_annotations(p.id) as annotations
612
611
  FROM pictures p
613
612
  JOIN sequences_pictures sp ON sp.pic_id = p.id
614
613
  JOIN accounts ON p.account_id = accounts.id
615
614
  JOIN sequences s ON sp.seq_id = s.id
616
- LEFT JOIN (
617
- SELECT picture_id, json_agg(json_strip_nulls(json_build_object(
618
- 'key', key,
619
- 'value', value
620
- ))) AS semantics
621
- FROM pictures_semantics
622
- GROUP BY picture_id
623
- ) t ON t.picture_id = p.id
624
615
  LEFT JOIN (
625
616
  SELECT
626
617
  p.id,
@@ -735,6 +726,18 @@ def getCollectionItem(collectionId, itemId):
735
726
  return stacItem, picStatusToHttpCode[stacItem["properties"]["geovisio:status"]], {"Content-Type": "application/geo+json"}
736
727
 
737
728
 
729
+ class SearchParams(BaseModel):
730
+ bbox: Optional[str] = None
731
+ limit: int = 10
732
+ datetime: Optional[str] = None
733
+ place_position: Optional[str] = None
734
+ place_distance: Optional[str] = None
735
+ place_fov_tolerance: Optional[int] = None
736
+ intersects: Optional[str] = None
737
+ ids: Optional[str] = None
738
+ collections: Optional[str] = None
739
+
740
+
738
741
  @bp.route("/search", methods=["GET", "POST"])
739
742
  def searchItems():
740
743
  """Search through all available items
@@ -755,6 +758,7 @@ def searchItems():
755
758
  - $ref: '#/components/parameters/GeoVisio_place_position'
756
759
  - $ref: '#/components/parameters/GeoVisio_place_distance'
757
760
  - $ref: '#/components/parameters/GeoVisio_place_fov_tolerance'
761
+ - $ref: '#/components/parameters/searchCQL2_filter'
758
762
  post:
759
763
  requestBody:
760
764
  required: true
@@ -788,14 +792,14 @@ def searchItems():
788
792
  args = request.args
789
793
 
790
794
  # Limit
791
- if args.get("limit") is not None:
792
- limit = args.get("limit", type=int)
793
- if limit is None or limit < 1 or limit > 10000:
794
- raise errors.InvalidAPIUsage(_("Parameter limit must be either empty or a number between 1 and 10000"), status_code=400)
795
- else:
796
- sqlParams["limit"] = limit
797
- else:
798
- sqlParams["limit"] = 10
795
+ limit = args.get("limit") or 10
796
+ try:
797
+ limit = int(limit)
798
+ if limit < 1 or limit > 10000:
799
+ raise ValueError()
800
+ except ValueError:
801
+ raise errors.InvalidAPIUsage(_("Parameter limit must be either empty or a number between 1 and 10000"), status_code=400)
802
+ sqlParams["limit"] = limit
799
803
 
800
804
  # Bounding box
801
805
  bboxarg = parse_bbox(args.getlist("bbox"))
@@ -900,7 +904,7 @@ def searchItems():
900
904
  raise errors.InvalidAPIUsage(_("Parameter collections should be a JSON array of strings"), status_code=400)
901
905
 
902
906
  # To speed up search, if it's a search by id and on only one id, we use the same code as /collections/:cid/items/:id
903
- if args.get("ids") is not None and args:
907
+ if args.get("ids") is not None:
904
908
  ids = parse_list(args.get("ids"), paramName="ids")
905
909
  if ids and len(ids) == 1:
906
910
  picture_id = ids[0]
@@ -917,7 +921,11 @@ def searchItems():
917
921
  200,
918
922
  {"Content-Type": "application/geo+json"},
919
923
  )
920
-
924
+ filter_param = args.get("filter")
925
+ if filter_param is not None:
926
+ cql_filter = parse_search_filter(filter_param)
927
+ if cql_filter is not None:
928
+ sqlWhere.append(cql_filter)
921
929
  #
922
930
  # Database query
923
931
  #
@@ -932,19 +940,12 @@ SELECT * FROM (
932
940
  accounts.name AS account_name,
933
941
  p.account_id AS account_id,
934
942
  p.exif, p.gps_accuracy_m, p.h_pixel_density,
935
- t.semantics
943
+ get_picture_semantics(p.id) as semantics,
944
+ get_picture_annotations(p.id) as annotations
936
945
  FROM pictures p
937
946
  LEFT JOIN sequences_pictures sp ON p.id = sp.pic_id
938
947
  LEFT JOIN sequences s ON s.id = sp.seq_id
939
948
  LEFT JOIN accounts ON p.account_id = accounts.id
940
- LEFT JOIN (
941
- SELECT picture_id, json_agg(json_strip_nulls(json_build_object(
942
- 'key', key,
943
- 'value', value
944
- ))) AS semantics
945
- FROM pictures_semantics
946
- GROUP BY picture_id
947
- ) t ON t.picture_id = p.id
948
949
  WHERE {sqlWhere}
949
950
  {orderBy}
950
951
  LIMIT %(limit)s
@@ -1247,7 +1248,7 @@ def patchCollectionItem(collectionId, itemId, account):
1247
1248
  ---
1248
1249
  tags:
1249
1250
  - Editing
1250
- - Tags
1251
+ - Semantics
1251
1252
  parameters:
1252
1253
  - name: collectionId
1253
1254
  in: path
geovisio/web/map.py CHANGED
@@ -352,23 +352,35 @@ def _get_query(z: int, x: int, y: int, onlyForUser: Optional[UUID], additional_f
352
352
  sql.SQL("nb_360_pictures"),
353
353
  sql.SQL("nb_pictures - nb_360_pictures AS nb_flat_pictures"),
354
354
  sql.SQL(
355
- """((CASE WHEN nb_pictures = 0 THEN 0 WHEN nb_pictures <= (select PERCENTILE_CONT(0.5) WITHIN GROUP(ORDER BY nb_pictures) from pictures_grid)
356
- THEN nb_pictures::float / (select PERCENTILE_CONT(0.5) WITHIN GROUP(ORDER BY nb_pictures) from pictures_grid) * 0.5
357
- ELSE 0.5 + nb_pictures::float / (SELECT MAX(nb_pictures) FROM pictures_grid) * 0.5
355
+ """((CASE WHEN nb_pictures = 0
356
+ THEN 0
357
+ WHEN nb_pictures <= (SELECT PERCENTILE_CONT(0.5) WITHIN GROUP(ORDER BY nb_pictures) FILTER (WHERE nb_pictures > 0) FROM pictures_grid)
358
+ THEN
359
+ nb_pictures::float / (SELECT PERCENTILE_CONT(0.5) WITHIN GROUP(ORDER BY nb_pictures) FILTER (WHERE nb_pictures > 0) FROM pictures_grid) * 0.5
360
+ ELSE
361
+ 0.5 + nb_pictures::float / (SELECT MAX(nb_pictures) FROM pictures_grid) * 0.5
358
362
  END) * 10)::int / 10::float AS coef"""
359
363
  ),
360
364
  sql.SQL(
361
- """((CASE WHEN nb_360_pictures = 0 THEN 0
362
- WHEN nb_360_pictures <= (select PERCENTILE_CONT(0.5) WITHIN GROUP(ORDER BY nb_360_pictures) from pictures_grid)
363
- THEN nb_360_pictures::float / (select PERCENTILE_CONT(0.5) WITHIN GROUP(ORDER BY nb_360_pictures) from pictures_grid) * 0.5
364
- ELSE 0.5 + nb_360_pictures::float / (SELECT MAX(nb_360_pictures) FROM pictures_grid) * 0.5
365
+ """((CASE WHEN nb_360_pictures = 0
366
+ THEN 0
367
+ WHEN (SELECT PERCENTILE_CONT(0.5) WITHIN GROUP(ORDER BY nb_360_pictures) FILTER (WHERE nb_360_pictures > 0) FROM pictures_grid) = 0
368
+ THEN 0
369
+ WHEN nb_360_pictures <= (SELECT PERCENTILE_CONT(0.5) WITHIN GROUP(ORDER BY nb_360_pictures) FILTER (WHERE nb_360_pictures > 0) FROM pictures_grid)
370
+ THEN nb_360_pictures::float / (SELECT PERCENTILE_CONT(0.5) WITHIN GROUP(ORDER BY nb_360_pictures) FILTER (WHERE nb_360_pictures > 0) FROM pictures_grid) * 0.5
371
+ ELSE
372
+ 0.5 + nb_360_pictures::float / (SELECT MAX(nb_360_pictures) FROM pictures_grid) * 0.5
365
373
  END) * 10)::int / 10::float AS coef_360_pictures"""
366
374
  ),
367
375
  sql.SQL(
368
- """((CASE WHEN (nb_pictures - nb_360_pictures) = 0 THEN 0
369
- WHEN (nb_pictures - nb_360_pictures) <= (select PERCENTILE_CONT(0.5) WITHIN GROUP(ORDER BY (nb_pictures - nb_360_pictures)) from pictures_grid)
370
- THEN (nb_pictures - nb_360_pictures)::float / (select PERCENTILE_CONT(0.5) WITHIN GROUP(ORDER BY (nb_pictures - nb_360_pictures)) from pictures_grid) * 0.5
371
- ELSE 0.5 + (nb_pictures - nb_360_pictures)::float / (SELECT MAX((nb_pictures - nb_360_pictures)) FROM pictures_grid) * 0.5
376
+ """((CASE WHEN (nb_pictures - nb_360_pictures) = 0
377
+ THEN 0
378
+ WHEN (SELECT PERCENTILE_CONT(0.5) WITHIN GROUP(ORDER BY (nb_pictures - nb_360_pictures)) FILTER (WHERE (nb_pictures - nb_360_pictures) > 0) FROM pictures_grid) = 0
379
+ THEN 0
380
+ WHEN (nb_pictures - nb_360_pictures) <= (SELECT PERCENTILE_CONT(0.5) WITHIN GROUP(ORDER BY (nb_pictures - nb_360_pictures)) FILTER (WHERE (nb_pictures - nb_360_pictures) > 0) FROM pictures_grid)
381
+ THEN (nb_pictures - nb_360_pictures)::float / (SELECT PERCENTILE_CONT(0.5) WITHIN GROUP(ORDER BY (nb_pictures - nb_360_pictures)) FILTER (WHERE (nb_pictures - nb_360_pictures) > 0) FROM pictures_grid) * 0.5
382
+ ELSE
383
+ 0.5 + (nb_pictures - nb_360_pictures)::float / (SELECT MAX((nb_pictures - nb_360_pictures)) FROM pictures_grid) * 0.5
372
384
  END) * 10)::int / 10::float AS coef_flat_pictures"""
373
385
  ),
374
386
  ]
@@ -616,7 +628,7 @@ def getUserTile(userId: UUID, z: int, x: int, y: int, format: str):
616
628
  format: binary
617
629
  """
618
630
 
619
- filter = params.parse_filter(request.args.get("filter"))
631
+ filter = params.parse_collection_filter(request.args.get("filter"))
620
632
  return _getTile(z, x, y, format, onlyForUser=userId, filter=filter)
621
633
 
622
634
 
@@ -688,5 +700,5 @@ def getMyTile(account: Account, z: int, x: int, y: int, format: str):
688
700
  type: string
689
701
  format: binary
690
702
  """
691
- filter = params.parse_filter(request.args.get("filter"))
703
+ filter = params.parse_collection_filter(request.args.get("filter"))
692
704
  return _getTile(z, x, y, format, onlyForUser=UUID(account.id), filter=filter)
geovisio/web/params.py CHANGED
@@ -8,8 +8,6 @@ import datetime
8
8
  import re
9
9
  from werkzeug.datastructures import MultiDict
10
10
  from typing import Optional, Tuple, Any, List
11
- from pygeofilter.backends.sql import to_sql_where
12
- from pygeofilter.parsers.ecql import parse as ecql_parser
13
11
  from pygeofilter import ast
14
12
  from pygeofilter.backends.evaluator import Evaluator, handle
15
13
  from psycopg import sql
@@ -17,6 +15,8 @@ from geovisio.utils.sequences import STAC_FIELD_MAPPINGS, STAC_FIELD_TO_SQL_FILT
17
15
  from geovisio.utils.fields import SortBy, SQLDirection, SortByField
18
16
  from flask_babel import gettext as _
19
17
 
18
+ from geovisio.utils.cql2 import parse_cql2_filter
19
+
20
20
 
21
21
  RGX_SORTBY = re.compile("[+-]?[A-Za-z_].*(,[+-]?[A-Za-z_].*)*")
22
22
  SEQUENCES_DEFAULT_FETCH = 100
@@ -325,37 +325,27 @@ def parse_list(value: Optional[Any], tryFallbacks: bool = True, paramName: Optio
325
325
  return None
326
326
 
327
327
 
328
- def parse_filter(value: Optional[str]) -> Optional[sql.SQL]:
328
+ def parse_collection_filter(value: Optional[str]) -> Optional[sql.SQL]:
329
329
  """Reads STAC filter parameter and sends SQL condition back.
330
330
 
331
- >>> parse_filter('')
331
+ >>> parse_collection_filter('')
332
332
 
333
- >>> parse_filter("updated >= '2023-12-31'")
333
+ >>> parse_collection_filter("updated >= '2023-12-31'")
334
334
  SQL("(s.updated_at >= '2023-12-31')")
335
- >>> parse_filter("updated >= '2023-12-31' AND created < '2023-10-31'")
335
+ >>> parse_collection_filter("updated >= '2023-12-31' AND created < '2023-10-31'")
336
336
  SQL("((s.updated_at >= '2023-12-31') AND (s.inserted_at < '2023-10-31'))")
337
- >>> parse_filter("status IN ('deleted','ready')") # when we ask for deleted, we should also have hidden collections
337
+ >>> parse_collection_filter("status IN ('deleted','ready')") # when we ask for deleted, we should also have hidden collections
338
338
  SQL("s.status IN ('deleted', 'ready', 'hidden')")
339
- >>> parse_filter("status = 'deleted' OR status = 'ready'")
339
+ >>> parse_collection_filter("status = 'deleted' OR status = 'ready'")
340
340
  SQL("(((s.status = 'deleted') OR (s.status = 'hidden')) OR (s.status = 'ready'))")
341
- >>> parse_filter('invalid = 10') # doctest: +IGNORE_EXCEPTION_DETAIL
341
+ >>> parse_collection_filter('invalid = 10') # doctest: +IGNORE_EXCEPTION_DETAIL
342
342
  Traceback (most recent call last):
343
343
  geovisio.errors.InvalidAPIUsage: Unsupported filter parameter
344
- >>> parse_filter('updated == 10') # doctest: +IGNORE_EXCEPTION_DETAIL
344
+ >>> parse_collection_filter('updated == 10') # doctest: +IGNORE_EXCEPTION_DETAIL
345
345
  Traceback (most recent call last):
346
346
  geovisio.errors.InvalidAPIUsage: Unsupported filter parameter
347
347
  """
348
- if value is not None and len(value) > 0:
349
- try:
350
- filterAst = ecql_parser(value)
351
- altered_ast = _alterFilterAst(filterAst) # type: ignore
352
-
353
- f = to_sql_where(altered_ast, STAC_FIELD_TO_SQL_FILTER).replace('"', "")
354
- return sql.SQL(f) # type: ignore
355
- except:
356
- raise errors.InvalidAPIUsage(_("Unsupported filter parameter"), status_code=400)
357
- else:
358
- return None
348
+ return parse_cql2_filter(value, STAC_FIELD_TO_SQL_FILTER, ast_updater=_alterFilterAst)
359
349
 
360
350
 
361
351
  def parse_picture_heading(heading: Optional[str]) -> Optional[int]:
geovisio/web/stac.py CHANGED
@@ -20,10 +20,11 @@ from geovisio.utils.sequences import (
20
20
  get_collections,
21
21
  CollectionsRequest,
22
22
  STAC_FIELD_MAPPINGS,
23
+ get_dataset_bounds,
23
24
  get_pagination_links,
24
25
  )
25
26
  from geovisio.web.params import (
26
- parse_filter,
27
+ parse_collection_filter,
27
28
  parse_collections_limit,
28
29
  )
29
30
 
@@ -334,12 +335,17 @@ def getUserCatalog(userId, userIdMatchesAccount=False):
334
335
  """
335
336
 
336
337
  collection_request = CollectionsRequest(
337
- sort_by=SortBy(fields=[SortByField(field=STAC_FIELD_MAPPINGS["created"], direction=SQLDirection.ASC)]),
338
+ sort_by=SortBy(
339
+ fields=[
340
+ SortByField(field=STAC_FIELD_MAPPINGS["created"], direction=SQLDirection.ASC),
341
+ SortByField(field=STAC_FIELD_MAPPINGS["id"], direction=SQLDirection.ASC),
342
+ ]
343
+ ),
338
344
  user_id=userId,
339
345
  userOwnsAllCollections=userIdMatchesAccount,
340
346
  )
341
347
  collection_request.limit = parse_collections_limit(request.args.get("limit"))
342
- collection_request.pagination_filter = parse_filter(request.args.get("page"))
348
+ collection_request.pagination_filter = parse_collection_filter(request.args.get("page"))
343
349
 
344
350
  userName = None
345
351
  meta_collection = None
@@ -350,12 +356,14 @@ def getUserCatalog(userId, userIdMatchesAccount=False):
350
356
  raise errors.InvalidAPIUsage(_("Impossible to find user %(u)s", u=userId))
351
357
  userName = userName["name"]
352
358
 
353
- meta_collection = cursor.execute(
354
- SQL("SELECT MIN(inserted_at) AS min_order, MAX(inserted_at) AS max_order FROM sequences s WHERE account_id = %(account)s"),
355
- params={"account": userId},
356
- ).fetchone()
359
+ datasetBounds = get_dataset_bounds(
360
+ cursor.connection,
361
+ collection_request.sort_by,
362
+ additional_filters=SQL("s.account_id = %(account)s"),
363
+ additional_filters_params={"account": userId},
364
+ )
357
365
 
358
- if not meta_collection or meta_collection["min_order"] is None:
366
+ if datasetBounds is None:
359
367
  # No data found, trying to give the most meaningfull error message
360
368
  raise errors.InvalidAPIUsage(_("No data loaded for user %(u)s", u=userId), 404)
361
369
 
@@ -388,10 +396,9 @@ def getUserCatalog(userId, userIdMatchesAccount=False):
388
396
  pagination_links = get_pagination_links(
389
397
  route="stac.getUserCatalog",
390
398
  routeArgs={"userId": str(userId), "limit": collection_request.limit},
391
- field=collection_request.sort_by.fields[0].field.stac,
392
- direction=collection_request.sort_by.fields[0].direction,
393
- datasetBounds=Bounds(min=meta_collection["min_order"], max=meta_collection["max_order"]),
394
- dataBounds=db_collections.query_first_order_bounds,
399
+ sortBy=collection_request.sort_by,
400
+ datasetBounds=datasetBounds,
401
+ dataBounds=db_collections.query_bounds,
395
402
  additional_filters=None,
396
403
  )
397
404
 
@@ -98,7 +98,7 @@ def create_upload_set(params: UploadSetCreationParameter, accountId: UUID) -> Up
98
98
  )
99
99
 
100
100
  if db_upload_set is None:
101
- raise Exception("Impossible to insert sequence in database")
101
+ raise Exception("Impossible to insert upload_set in database")
102
102
 
103
103
  return db_upload_set
104
104
 
@@ -106,13 +106,6 @@ def create_upload_set(params: UploadSetCreationParameter, accountId: UUID) -> Up
106
106
  def update_upload_set(upload_set_id: UUID, params: UploadSetUpdateParameter) -> UploadSet:
107
107
  db_params = model_query.get_db_params_and_values(params)
108
108
 
109
- with db.conn(current_app) as conn, conn.transaction():
110
- import psycopg
111
-
112
- cur = psycopg.ClientCursor(conn)
113
- q = SQL("UPDATE upload_sets SET {fields} WHERE id = %(upload_set_id)s").format(fields=db_params.fields_for_set())
114
- print(cur.mogrify(q, db_params.params_as_dict | {"upload_set_id": upload_set_id}))
115
-
116
109
  with db.execute(
117
110
  current_app,
118
111
  SQL("UPDATE upload_sets SET {fields} WHERE id = %(upload_set_id)s").format(fields=db_params.fields_for_set()),
@@ -201,7 +194,7 @@ def patchUploadSet(upload_set_id, account=None):
201
194
  content:
202
195
  application/json:
203
196
  schema:
204
- $ref: '#/components/schemas/GeoVisioUploadSet'
197
+ $ref: '#/components/schemas/UploadSetUpdateParameter'
205
198
  security:
206
199
  - bearerToken: []
207
200
  - cookieAuth: []
@@ -2,7 +2,7 @@ from fs.path import dirname
2
2
  from PIL import Image, ImageOps
3
3
  from flask import current_app
4
4
  from geovisio import utils
5
- from geovisio.utils import db, sequences, upload_set
5
+ from geovisio.utils import db, semantics, sequences, upload_set
6
6
  import psycopg
7
7
  from psycopg.rows import dict_row
8
8
  from psycopg.sql import SQL
@@ -22,8 +22,6 @@ import geovisio.utils.filesystems
22
22
 
23
23
  log = logging.getLogger("geovisio.runner_pictures")
24
24
 
25
- PROCESS_MAX_RETRY = 5 # Number of times a job will be retryed if there is a `RecoverableProcessException` during process (like if the blurring api is not reachable).
26
-
27
25
 
28
26
  class PictureBackgroundProcessor(object):
29
27
  def __init__(self, app):
@@ -50,6 +48,10 @@ class PictureBackgroundProcessor(object):
50
48
  worker = PictureProcessor(app=current_app)
51
49
  return self.executor.submit(worker.process_jobs)
52
50
 
51
+ def stop(self):
52
+ if self.enabled:
53
+ self.executor.shutdown(cancel_futures=True, wait=True)
54
+
53
55
 
54
56
  class ProcessTask(str, Enum):
55
57
  prepare = "prepare"
@@ -103,6 +105,55 @@ class DbJob:
103
105
  return f"{self.task} for {impacted_object}"
104
106
 
105
107
 
108
+ def store_detection_semantics(pic: DbPicture, metadata: Dict[str, Any], store_id: bool):
109
+ """store the detection returned by the blurring API in the database.
110
+
111
+ The semantics part is stored as annotations, linked to the default account.
112
+
113
+ The blurring id, which could be used to unblur the picture later, is stored in a separate column?
114
+
115
+ Note that all old semantics tags are removed, and to know this, we check the `service_name` field returned by the blurring API, and the special qualifier tag
116
+ `detection_model` that is formated like a user-agent.
117
+ So we delete all old tags (and related qualifiers) o
118
+ """
119
+ from geovisio.utils import annotations
120
+
121
+ tags = metadata.pop("annotations", [])
122
+
123
+ with db.conn(current_app) as conn, conn.cursor() as cursor:
124
+ blurring_id = metadata.get("blurring_id")
125
+ if blurring_id and store_id:
126
+ # we store the blurring id to be able to unblur the picture later
127
+ cursor.execute(
128
+ "UPDATE pictures SET blurring_id = %(blurring_id)s WHERE id = %(id)s",
129
+ {"blurring_id": blurring_id, "id": pic.id},
130
+ )
131
+
132
+ if not tags:
133
+ return
134
+
135
+ default_account_id = cursor.execute("SELECT id from accounts where is_default = true").fetchone()
136
+ if not default_account_id:
137
+ log.error("Impossible to find a default account, cannot add semantics from blurring api")
138
+ default_account_id = default_account_id[0]
139
+
140
+ # we want to remove all the tags added by the same bluring api previously
141
+ # it's especially usefull when a picture is blurred multiple times
142
+ # and if the detection model has been updated between the blurrings
143
+ semantics.delete_annotation_tags_from_service(conn, pic.id, service_name="SGBlur", account=default_account_id)
144
+ try:
145
+ annotations_to_create = [
146
+ annotations.AnnotationCreationParameter(**t, account_id=default_account_id, picture_id=pic.id) for t in tags
147
+ ]
148
+ except Exception as e:
149
+ # if the detections are not in the correct format, we skip them
150
+ msg = errors.getMessageFromException(e)
151
+ log.error(f"impossible to save blurring detections, skipping it for picture {pic.id}: {msg}")
152
+ return
153
+ for a in annotations_to_create:
154
+ annotations.creation_annotation(a)
155
+
156
+
106
157
  def processPictureFiles(pic: DbPicture, config):
107
158
  """Generates the files associated with a sequence picture.
108
159
 
@@ -140,15 +191,23 @@ def processPictureFiles(pic: DbPicture, config):
140
191
  if not skipBlur:
141
192
  with sentry_sdk.start_span(description="Blurring picture"):
142
193
  try:
143
- picture = utils.pictures.createBlurredHDPicture(
194
+ res = utils.pictures.createBlurredHDPicture(
144
195
  fses.permanent,
145
196
  config.get("API_BLUR_URL"),
146
197
  pictureBytes,
147
198
  picHdPath,
199
+ keep_unblured_parts=config["PICTURE_PROCESS_KEEP_UNBLURRED_PARTS"],
148
200
  )
149
201
  except Exception as e:
150
- log.exception(f"impossible to blur picture {pic.id}")
151
- raise RecoverableProcessException("Blur API failure: " + errors.getMessageFromException(e)) from e
202
+ msg = errors.getMessageFromException(e)
203
+ log.error(f"impossible to blur picture {pic.id}: {msg}")
204
+ raise RecoverableProcessException("Blur API failure: " + msg) from e
205
+ if res is None:
206
+ picture = None
207
+ else:
208
+ picture = res.image
209
+ if res.metadata:
210
+ store_detection_semantics(pic, res.metadata, store_id=config["PICTURE_PROCESS_KEEP_UNBLURRED_PARTS"])
152
211
 
153
212
  # Delete original unblurred file
154
213
  geovisio.utils.filesystems.removeFsEvenNotFound(fses.tmp, picHdPath)
@@ -383,12 +442,13 @@ def _get_next_job(app):
383
442
  _finalize_job(locking_transaction, job)
384
443
  log.debug(f"Job {job.label()} processed")
385
444
  except RecoverableProcessException as e:
386
- _mark_process_as_error(locking_transaction, job, e, recoverable=True)
445
+ _mark_process_as_error(locking_transaction, job, e, config=app.config, recoverable=True)
387
446
  except RetryLaterProcessException as e:
388
447
  _mark_process_as_error(
389
448
  locking_transaction,
390
449
  job,
391
450
  e,
451
+ config=app.config,
392
452
  recoverable=True,
393
453
  mark_as_error=False,
394
454
  )
@@ -396,11 +456,11 @@ def _get_next_job(app):
396
456
  log.error(f"Interruption received, stoping job {job.label()}")
397
457
  # starts a new connection, since the current one can be corrupted by the exception
398
458
  with app.pool.connection() as t:
399
- _mark_process_as_error(t, job, interruption, recoverable=True)
459
+ _mark_process_as_error(t, job, interruption, config=app.config, recoverable=True)
400
460
  error = interruption
401
461
  except Exception as e:
402
462
  log.exception(f"Impossible to finish job {job.label()}")
403
- _mark_process_as_error(locking_transaction, job, e, recoverable=False)
463
+ _mark_process_as_error(locking_transaction, job, e, config=app.config, recoverable=False)
404
464
 
405
465
  # try to finalize the sequence anyway
406
466
  _finalize_sequence(job)
@@ -506,6 +566,7 @@ def _mark_process_as_error(
506
566
  conn,
507
567
  job: DbJob,
508
568
  e: Exception,
569
+ config: Dict,
509
570
  recoverable: bool = False,
510
571
  mark_as_error: bool = True,
511
572
  ):
@@ -524,7 +585,7 @@ def _mark_process_as_error(
524
585
  RETURNING nb_errors""",
525
586
  {"err": str(e), "id": job.job_queue_id},
526
587
  ).fetchone()
527
- if nb_error and nb_error[0] > PROCESS_MAX_RETRY:
588
+ if nb_error and nb_error[0] > config["PICTURE_PROCESS_NB_RETRIES"]:
528
589
  logging.info(f"Job {job.label()} has failed {nb_error} times, we stop trying to process it.")
529
590
  recoverable = False
530
591
  else: