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.
@@ -1,17 +1,213 @@
1
- from geovisio.utils import auth
2
- from psycopg.rows import dict_row, class_row
3
- from psycopg.sql import SQL
4
- from geovisio.utils.semantics import Entity, EntityType, SemanticTagUpdate, update_tags
1
+ from typing import List, Optional
2
+ from geovisio.utils import auth, db
3
+ from geovisio.utils.annotations import AnnotationCreationParameter, creation_annotation, get_annotation, update_annotation
4
+ from geovisio.utils.tags import SemanticTagUpdate
5
5
  from geovisio.web.utils import accountIdOrDefault
6
- from psycopg.types.json import Jsonb
7
- from geovisio.utils import db
8
6
  from geovisio.utils.params import validation_error
9
7
  from geovisio import errors
10
- from pydantic import BaseModel, ConfigDict, ValidationError
8
+ from pydantic import BaseModel, ValidationError
11
9
  from uuid import UUID
12
- from typing import List, Optional
13
- from flask import Blueprint, request, current_app
10
+ from flask import Blueprint, current_app, request, url_for
14
11
  from flask_babel import gettext as _
15
12
 
16
13
 
17
14
  bp = Blueprint("annotations", __name__, url_prefix="/api")
15
+
16
+
17
+ @bp.route("/collections/<uuid:collectionId>/items/<uuid:itemId>/annotations", methods=["POST"])
18
+ @auth.login_required()
19
+ def postAnnotation(collectionId, itemId, account):
20
+ """Create an annotation on a picture.
21
+
22
+ The geometry can be provided as a bounding box (a list of 4 integers, minx, miny, maxx, maxy) or as a geojson geometry.
23
+ All coordinates must be in pixel, starting from the top left of the picture.
24
+
25
+ If an annotation already exists on the picture with the same shape, it will be used.
26
+ ---
27
+ tags:
28
+ - Editing
29
+ - Semantics
30
+ parameters:
31
+ - name: collectionId
32
+ in: path
33
+ description: ID of collection to retrieve
34
+ required: true
35
+ schema:
36
+ type: string
37
+ - name: itemId
38
+ in: path
39
+ description: ID of item to retrieve
40
+ required: true
41
+ schema:
42
+ type: string
43
+ requestBody:
44
+ content:
45
+ application/json:
46
+ schema:
47
+ $ref: '#/components/schemas/GeoVisioPostAnnotation'
48
+ security:
49
+ - bearerToken: []
50
+ - cookieAuth: []
51
+ responses:
52
+ 200:
53
+ description: the annotation metadata
54
+ content:
55
+ application/json:
56
+ schema:
57
+ $ref: '#/components/schemas/GeoVisioAnnotation'
58
+ """
59
+
60
+ account_id = UUID(accountIdOrDefault(account))
61
+
62
+ pic = db.fetchone(
63
+ current_app,
64
+ "SELECT 1 FROM sequences_pictures WHERE seq_id = %(seq)s AND pic_id = %(pic)s",
65
+ {"seq": collectionId, "pic": itemId},
66
+ )
67
+ if not pic:
68
+ raise errors.InvalidAPIUsage(_("Picture %(p)s wasn't found in database", p=itemId), status_code=404)
69
+
70
+ if request.is_json and request.json is not None:
71
+ try:
72
+ params = AnnotationCreationParameter(**request.json, account_id=account_id, picture_id=itemId)
73
+ except ValidationError as ve:
74
+ raise errors.InvalidAPIUsage(_("Impossible to create an annotation"), payload=validation_error(ve))
75
+ else:
76
+ raise errors.InvalidAPIUsage(_("Parameter for creating an annotation should be a valid JSON"), status_code=415)
77
+
78
+ annotation = creation_annotation(params)
79
+
80
+ return (
81
+ annotation.model_dump_json(exclude_none=True),
82
+ 200,
83
+ {
84
+ "Content-Type": "application/json",
85
+ "Access-Control-Expose-Headers": "Location", # Needed for allowing web browsers access Location header
86
+ "Location": url_for(
87
+ "annotations.getAnnotation", _external=True, annotationId=annotation.id, collectionId=collectionId, itemId=itemId
88
+ ),
89
+ },
90
+ )
91
+
92
+
93
+ @bp.route("/collections/<uuid:collectionId>/items/<uuid:itemId>/annotations/<uuid:annotationId>", methods=["GET"])
94
+ def getAnnotation(collectionId, itemId, annotationId):
95
+ """Get an annotation
96
+
97
+ ---
98
+ tags:
99
+ - Semantics
100
+ parameters:
101
+ - name: collectionId
102
+ in: path
103
+ description: ID of collection
104
+ required: true
105
+ schema:
106
+ type: string
107
+ - name: itemId
108
+ in: path
109
+ description: ID of item
110
+ required: true
111
+ schema:
112
+ type: string
113
+ - name: annotationId
114
+ in: path
115
+ description: ID of annotation
116
+ required: true
117
+ schema:
118
+ type: string
119
+ security:
120
+ - bearerToken: []
121
+ - cookieAuth: []
122
+ responses:
123
+ 200:
124
+ description: the annotation metadata
125
+ content:
126
+ application/json:
127
+ schema:
128
+ $ref: '#/components/schemas/GeoVisioAnnotation'
129
+ """
130
+ with db.conn(current_app) as conn:
131
+
132
+ annotation = get_annotation(conn, annotationId)
133
+ if not annotation or annotation.picture_id != itemId:
134
+ raise errors.InvalidAPIUsage(_("Annotation %(p)s not found", p=itemId), status_code=404)
135
+
136
+ return annotation.model_dump_json(exclude_none=True), 200, {"Content-Type": "application/json"}
137
+
138
+
139
+ class AnnotationPatchParameter(BaseModel):
140
+ """Parameters used to update an annotation"""
141
+
142
+ semantics: Optional[List[SemanticTagUpdate]] = None
143
+ """Tags to update on the annotation. By default each tag will be added to the annotation's tags, but you can change this behavior by setting the `action` parameter to `delete`.
144
+
145
+ If you want to replace a tag, you need to first delete it, then add it again.
146
+
147
+ Like:
148
+ [
149
+ {"key": "some_key", "value": "some_value", "action": "delete"},
150
+ {"key": "some_key", "value": "some_new_value"}
151
+ ]
152
+ """
153
+
154
+
155
+ @bp.route("/collections/<uuid:collectionId>/items/<uuid:itemId>/annotations/<uuid:annotationId>", methods=["PATCH"])
156
+ @auth.login_required()
157
+ def patchAnnotation(collectionId, itemId, annotationId, account):
158
+ """Patch an annotation
159
+
160
+ Note that if the annotation has no associated tags anymore, it will be deleted.
161
+ ---
162
+ tags:
163
+ - Semantics
164
+ parameters:
165
+ - name: collectionId
166
+ in: path
167
+ description: ID of collection
168
+ required: true
169
+ schema:
170
+ type: string
171
+ - name: itemId
172
+ in: path
173
+ description: ID of item
174
+ required: true
175
+ schema:
176
+ type: string
177
+ - name: annotationId
178
+ in: path
179
+ description: ID of annotation
180
+ required: true
181
+ schema:
182
+ type: string
183
+ security:
184
+ - bearerToken: []
185
+ - cookieAuth: []
186
+ responses:
187
+ 200:
188
+ description: the annotation metadata
189
+ content:
190
+ application/json:
191
+ schema:
192
+ $ref: '#/components/schemas/GeoVisioAnnotation'
193
+ 204:
194
+ description: The annotation was empty, it has been correctly deleted
195
+ """
196
+ if request.is_json and request.json is not None:
197
+ try:
198
+ params = AnnotationPatchParameter(**request.json)
199
+ except ValidationError as ve:
200
+ raise errors.InvalidAPIUsage(_("Impossible to patch annotation, invalid parameters"), payload=validation_error(ve))
201
+ else:
202
+ raise errors.InvalidAPIUsage(_("Parameter for updating an annotation should be a valid JSON"), status_code=415)
203
+
204
+ with db.conn(current_app) as conn:
205
+
206
+ annotation = get_annotation(conn, annotationId)
207
+ if not annotation or annotation.picture_id != itemId:
208
+ raise errors.InvalidAPIUsage(_("Annotation %(p)s not found", p=itemId), status_code=404)
209
+
210
+ a = update_annotation(annotation, params.semantics, account.id)
211
+ if a is None:
212
+ return "", 204
213
+ return a.model_dump_json(exclude_none=True), 200, {"Content-Type": "application/json"}
@@ -1,14 +1,16 @@
1
+ from copy import deepcopy
1
2
  from enum import Enum
2
3
  from attr import dataclass
3
4
  from geovisio import errors, utils, db
4
5
  from geovisio.utils import auth, sequences
5
6
  from geovisio.utils.params import validation_error
6
- from geovisio.utils.semantics import SemanticTagUpdate, Entity, EntityType, update_tags
7
+ from geovisio.utils.semantics import Entity, EntityType, update_tags
8
+ from geovisio.utils.tags import SemanticTagUpdate
7
9
  from geovisio.web.params import (
8
10
  parse_datetime,
9
11
  parse_datetime_interval,
10
12
  parse_bbox,
11
- parse_filter,
13
+ parse_collection_filter,
12
14
  parse_sortby,
13
15
  parse_collections_limit,
14
16
  )
@@ -16,6 +18,7 @@ from geovisio.utils.sequences import (
16
18
  STAC_FIELD_MAPPINGS,
17
19
  CollectionsRequest,
18
20
  get_collections,
21
+ get_dataset_bounds,
19
22
  )
20
23
  from geovisio.utils.fields import SortBy, SortByField, SQLDirection, Bounds, BBox
21
24
  from geovisio.web.rss import dbSequencesToGeoRSS
@@ -248,12 +251,18 @@ def getAllCollections():
248
251
  if not sortBy:
249
252
  direction = SQLDirection.DESC if format == "rss" else SQLDirection.ASC
250
253
  sortBy = SortBy(fields=[SortByField(field=STAC_FIELD_MAPPINGS["created"], direction=direction)])
254
+ # we always add the creation date fields in the sort list (after the selected ones), this will we'll get the `created` bounds of the dataset
255
+ # we'll also get
256
+ if not any(s.field == STAC_FIELD_MAPPINGS["created"] for s in sortBy.fields):
257
+ sortBy.fields.append(SortByField(field=STAC_FIELD_MAPPINGS["created"], direction=SQLDirection.ASC))
258
+ if not any(s.field == STAC_FIELD_MAPPINGS["id"] for s in sortBy.fields):
259
+ sortBy.fields.append(SortByField(field=STAC_FIELD_MAPPINGS["id"], direction=SQLDirection.ASC))
251
260
 
252
261
  collection_request = CollectionsRequest(sort_by=sortBy)
253
262
 
254
263
  # Filter parameter
255
- collection_request.user_filter = parse_filter(request.args.get("filter"))
256
- collection_request.pagination_filter = parse_filter(request.args.get("page"))
264
+ collection_request.user_filter = parse_collection_filter(request.args.get("filter"))
265
+ collection_request.pagination_filter = parse_collection_filter(request.args.get("page"))
257
266
 
258
267
  # Limit parameter
259
268
  collection_request.limit = parse_collections_limit(request.args.get("limit"))
@@ -298,14 +307,14 @@ def getAllCollections():
298
307
  },
299
308
  ]
300
309
 
301
- with db.cursor(current_app, row_factory=dict_row) as cursor:
302
- stats = cursor.execute("SELECT min(inserted_at) as min, max(inserted_at) as max FROM sequences").fetchone()
303
- if stats is None:
310
+ with db.conn(current_app) as conn:
311
+ datasetBounds = get_dataset_bounds(conn, collection_request.sort_by, additional_filters=collection_request.user_filter)
312
+ if datasetBounds is None:
304
313
  return ({"collections": [], "links": links}, 200, {"Content-Type": "application/json"})
305
- datasetBounds = Bounds(min=stats["min"], max=stats["max"])
306
- if collection_request.created_after and collection_request.created_after > datasetBounds.max:
314
+ creation_date_index = collection_request.sort_by.get_field_index("created")
315
+ if collection_request.created_after and collection_request.created_after > datasetBounds.last[creation_date_index]:
307
316
  raise errors.InvalidAPIUsage(_("There is no collection created after %(d)s", d=collection_request.created_after))
308
- if collection_request.created_before and collection_request.created_before < datasetBounds.min:
317
+ if collection_request.created_before and collection_request.created_before < datasetBounds.first[creation_date_index]:
309
318
  raise errors.InvalidAPIUsage(_("There is no collection created before %(d)s", d=collection_request.created_before))
310
319
 
311
320
  db_collections = get_collections(collection_request)
@@ -322,10 +331,9 @@ def getAllCollections():
322
331
  pagination_links = sequences.get_pagination_links(
323
332
  route="stac_collections.getAllCollections",
324
333
  routeArgs={"limit": collection_request.limit},
325
- field=sortBy.fields[0].field.stac,
326
- direction=sortBy.fields[0].direction,
334
+ sortBy=sortBy,
327
335
  datasetBounds=datasetBounds,
328
- dataBounds=db_collections.query_first_order_bounds,
336
+ dataBounds=db_collections.query_bounds,
329
337
  additional_filters=additional_filters,
330
338
  )
331
339
 
@@ -402,7 +410,7 @@ def getCollection(collectionId):
402
410
  SELECT sequence_id, json_agg(json_strip_nulls(json_build_object(
403
411
  'key', key,
404
412
  'value', value
405
- ))) AS semantics
413
+ )) ORDER BY key, value) AS semantics
406
414
  FROM sequences_semantics
407
415
  GROUP BY sequence_id
408
416
  ) t ON t.sequence_id = s.id
@@ -607,7 +615,7 @@ If unset, sort order is unchanged."""
607
615
  if relHeading < -180 or relHeading > 180:
608
616
  raise ValueError()
609
617
  return relHeading
610
- except ValueError:
618
+ except (ValueError, TypeError):
611
619
  raise errors.InvalidAPIUsage(
612
620
  _("Relative heading is not valid, should be an integer in degrees from -180 to 180"), status_code=400
613
621
  )
@@ -629,7 +637,7 @@ def patchCollection(collectionId, account):
629
637
  ---
630
638
  tags:
631
639
  - Editing
632
- - Tags
640
+ - Semantics
633
641
  parameters:
634
642
  - name: collectionId
635
643
  in: path
@@ -749,13 +757,7 @@ def patchCollection(collectionId, account):
749
757
  sqlUpdates.append(SQL("last_account_to_edit = %(account)s"))
750
758
 
751
759
  cursor.execute(
752
- SQL(
753
- """
754
- UPDATE sequences
755
- SET {updates}
756
- WHERE id = %(id)s
757
- """
758
- ).format(updates=SQL(", ").join(sqlUpdates)),
760
+ SQL("UPDATE sequences SET {updates} WHERE id = %(id)s").format(updates=SQL(", ").join(sqlUpdates)),
759
761
  sqlParams,
760
762
  )
761
763
 
@@ -924,7 +926,12 @@ def send_collections_as_csv(collection_request: CollectionsRequest):
924
926
  raise errors.InvalidAPIUsage(_("CSV export does not support pagination"), status_code=400)
925
927
  if collection_request.filters():
926
928
  raise errors.InvalidAPIUsage(_("CSV export does not support filters"), status_code=400)
927
- if collection_request.sort_by != SortBy(fields=[SortByField(field=STAC_FIELD_MAPPINGS["created"], direction=SQLDirection.DESC)]):
929
+ if collection_request.sort_by != SortBy(
930
+ fields=[
931
+ SortByField(field=STAC_FIELD_MAPPINGS["created"], direction=SQLDirection.DESC),
932
+ SortByField(field=STAC_FIELD_MAPPINGS["id"], direction=SQLDirection.ASC),
933
+ ]
934
+ ):
928
935
  raise errors.InvalidAPIUsage(_("CSV export does not support sorting by anything but creation date"), status_code=400)
929
936
 
930
937
  def generate_csv():
@@ -957,7 +964,7 @@ SELECT
957
964
  s.computed_gps_accuracy AS computed_gps_accuracy
958
965
  FROM sequences s
959
966
  WHERE {filter}
960
- ORDER BY s.inserted_at DESC
967
+ ORDER BY s.inserted_at DESC, id ASC
961
968
  ) TO STDOUT CSV HEADER"""
962
969
  ).format(filter=SQL(" AND ").join(filters)),
963
970
  params,
@@ -966,7 +973,7 @@ ORDER BY s.inserted_at DESC
966
973
  for a in copy:
967
974
  yield bytes(a)
968
975
 
969
- return stream_with_context(generate_csv()), {"Content-Disposition": "attachment"}
976
+ return stream_with_context(generate_csv()), {"Content-Type": "text/csv", "Content-Disposition": "attachment"}
970
977
 
971
978
 
972
979
  @bp.route("/users/<uuid:userId>/collection")
@@ -1033,13 +1040,17 @@ def getUserCollection(userId, userIdMatchesAccount=False):
1033
1040
  if not sortBy:
1034
1041
  sortBy = SortBy(fields=[SortByField(field=STAC_FIELD_MAPPINGS["created"], direction=SQLDirection.DESC)])
1035
1042
 
1043
+ if not any(s.field == STAC_FIELD_MAPPINGS["created"] for s in sortBy.fields):
1044
+ sortBy.fields.append(SortByField(field=STAC_FIELD_MAPPINGS["created"], direction=SQLDirection.ASC))
1045
+ if not any(s.field == STAC_FIELD_MAPPINGS["id"] for s in sortBy.fields):
1046
+ sortBy.fields.append(SortByField(field=STAC_FIELD_MAPPINGS["id"], direction=SQLDirection.ASC))
1036
1047
  collection_request = CollectionsRequest(sort_by=sortBy, userOwnsAllCollections=userIdMatchesAccount)
1037
1048
 
1038
1049
  # Filter parameter
1039
- collection_request.user_filter = parse_filter(request.args.get("filter"))
1050
+ collection_request.user_filter = parse_collection_filter(request.args.get("filter"))
1040
1051
 
1041
1052
  # Filters added by the pagination
1042
- collection_request.pagination_filter = parse_filter(request.args.get("page"))
1053
+ collection_request.pagination_filter = parse_collection_filter(request.args.get("page"))
1043
1054
 
1044
1055
  # Limit parameter
1045
1056
  # if not specified, the default with CSV it 1000. if there are more, the paginated API should be used
@@ -1095,8 +1106,6 @@ def getUserCollection(userId, userIdMatchesAccount=False):
1095
1106
  MAX(LEAST(90, ST_YMax(s.bbox))) AS maxy,
1096
1107
  MIN(s.inserted_at) AS created,
1097
1108
  MAX(s.updated_at) AS updated,
1098
- MIN({order_column}) AS min_order,
1099
- MAX({order_column}) AS max_order,
1100
1109
  ROUND(SUM(ST_Length(s.geom::geography))) / 1000 AS length_km
1101
1110
  FROM sequences s
1102
1111
  WHERE {filter}
@@ -1115,6 +1124,13 @@ def getUserCollection(userId, userIdMatchesAccount=False):
1115
1124
  else:
1116
1125
  raise errors.InvalidAPIUsage(_("No matching sequences found"), 404)
1117
1126
 
1127
+ datasetBounds = get_dataset_bounds(
1128
+ cursor.connection,
1129
+ collection_request.sort_by,
1130
+ additional_filters=SQL(" AND ").join(meta_filter),
1131
+ additional_filters_params={"account": userId},
1132
+ )
1133
+
1118
1134
  collections = get_collections(collection_request)
1119
1135
 
1120
1136
  sequences_links = [
@@ -1164,10 +1180,9 @@ def getUserCollection(userId, userIdMatchesAccount=False):
1164
1180
  pagination_links = sequences.get_pagination_links(
1165
1181
  route="stac_collections.getUserCollection",
1166
1182
  routeArgs={"userId": str(userId), "limit": collection_request.limit},
1167
- field=sortBy.fields[0].field.stac,
1168
- direction=sortBy.fields[0].direction,
1169
- datasetBounds=Bounds(min=meta_collection["min_order"], max=meta_collection["max_order"]),
1170
- dataBounds=collections.query_first_order_bounds,
1183
+ sortBy=sortBy,
1184
+ datasetBounds=datasetBounds,
1185
+ dataBounds=collections.query_bounds,
1171
1186
  additional_filters=additional_filters,
1172
1187
  )
1173
1188
 
@@ -29,7 +29,7 @@ def configuration():
29
29
  "name": _get_translated(apiSum.name, userLang),
30
30
  "description": _get_translated(apiSum.description, userLang),
31
31
  "geo_coverage": _get_translated(apiSum.geo_coverage, userLang),
32
- "logo": apiSum.logo,
32
+ "logo": str(apiSum.logo),
33
33
  "color": str(apiSum.color),
34
34
  "email": apiSum.email,
35
35
  "auth": _auth_configuration(),
@@ -53,6 +53,7 @@ def _auth_configuration():
53
53
  return {
54
54
  "enabled": True,
55
55
  "user_profile": {"url": auth.oauth_provider.user_profile_page_url()},
56
+ "registration_is_open": flask.current_app.config["API_REGISTRATION_IS_OPEN"],
56
57
  "enforce_tos_acceptance": flask.current_app.config["API_ENFORCE_TOS_ACCEPTANCE"],
57
58
  }
58
59
 
geovisio/web/docs.py CHANGED
@@ -1,5 +1,10 @@
1
- from geovisio.web import annotations, collections, items, prepare, users, utils, upload_set, reports, excluded_areas, pages
2
- from geovisio.utils import upload_set as upload_set_utils, reports as reports_utils, excluded_areas as excluded_areas_utils
1
+ from geovisio.web import collections, items, prepare, users, utils, upload_set, reports, excluded_areas, pages
2
+ from geovisio.utils import (
3
+ upload_set as upload_set_utils,
4
+ reports as reports_utils,
5
+ excluded_areas as excluded_areas_utils,
6
+ annotations as annotations_utils,
7
+ )
3
8
  from importlib import metadata
4
9
  import re
5
10
 
@@ -258,6 +263,9 @@ Note that you may not rely only on these ID that could change through time.
258
263
  "GeoVisioUploadSetFiles": upload_set_utils.UploadSetFiles.model_json_schema(
259
264
  ref_template="#/components/schemas/GeoVisioUploadSetFiles/$defs/{model}", mode="serialization"
260
265
  ),
266
+ "UploadSetUpdateParameter": upload_set.UploadSetUpdateParameter.model_json_schema(
267
+ ref_template="#/components/schemas/UploadSetUpdateParameter/$defs/{model}", mode="serialization"
268
+ ),
261
269
  "GeoVisioCollectionOfCollection": {
262
270
  "allOf": [
263
271
  {"$ref": "#/components/schemas/STACCollection"},
@@ -462,6 +470,11 @@ The CSV headers will be:
462
470
  "geovisio:producer": {"type": "string"},
463
471
  "geovisio:image": {"type": "string", "format": "uri"},
464
472
  "geovisio:thumbnail": {"type": "string", "format": "uri"},
473
+ "geovisio:rank_in_collection": {
474
+ "type": "integer",
475
+ "minimum": 1,
476
+ "title": "Rank of the picture in its collection.",
477
+ },
465
478
  "original_file:size": {"type": "integer", "minimum": 0, "title": "Size of the original file, in bytes"},
466
479
  "original_file:name": {"type": "string", "title": "Original file name"},
467
480
  "panoramax:horizontal_pixel_density": {
@@ -742,6 +755,10 @@ Available properties are:
742
755
  "properties": {
743
756
  "user_profile": {"type": "object", "properties": {"url": {"type": "string"}}},
744
757
  "enabled": {"type": "boolean"},
758
+ "registration_is_open": {
759
+ "type": "boolean",
760
+ "description": "If true, users can create their own account on the instance. Only used for reference in the federation for the moment",
761
+ },
745
762
  "enforce_tos_acceptance": {"type": "boolean"},
746
763
  },
747
764
  "required": ["enabled"],
@@ -821,6 +838,12 @@ Available properties are:
821
838
  "payload": {"type": "object", "description": "The error payload"},
822
839
  },
823
840
  },
841
+ "GeoVisioAnnotation": annotations_utils.Annotation.model_json_schema(
842
+ ref_template="#/components/schemas/GeoVisioAnnotation/$defs/{model}", mode="serialization"
843
+ ),
844
+ "GeoVisioPostAnnotation": annotations_utils.AnnotationCreationParameter.model_json_schema(
845
+ ref_template="#/components/schemas/GeoVisioPostAnnotation/$defs/{model}", mode="serialization"
846
+ ),
824
847
  },
825
848
  "parameters": {
826
849
  "STAC_bbox": {"$ref": f"https://api.stacspec.org/v{utils.STAC_VERSION}/item-search/openapi.yaml#/components/parameters/bbox"},
@@ -916,6 +939,33 @@ Note that this parameter is not taken in account for 360° pictures, as by defin
916
939
  "required": False,
917
940
  "schema": {"type": "integer", "minimum": 2, "maximum": 180, "default": 30},
918
941
  },
942
+ "searchCQL2_filter": {
943
+ "name": "filter",
944
+ "in": "query",
945
+ "description": """
946
+ A CQL2 filter expression for filtering search results.
947
+
948
+ Only works for semantic search for the moment.
949
+
950
+ The attributes must start with "semantics." and formated like "semantics.some_key"='some_value'.
951
+
952
+ Note: it's important for the attribute to be quoted (`"`) and the value to around simple quotes (`'`) to avoid issues with CQL2 parsing.
953
+
954
+ For the moment only equality (`=`) and list (`IN`) filters are supported. We do not support searching for multiple different tags at once with an `AND` operator (for example, `"semantics.traffic_sign"='yes' AND "semantics.colour"='red'` __will not work__). We suggest to filter data on your side, after retrieving by the main attribute depending on your interest.
955
+
956
+ To search for any values of a semantic tag, use `semantics.some_key IS NOT NULL` (case matter here).
957
+
958
+ Examples:
959
+
960
+ * "semantics.osm|traffic_sign"='yes'
961
+ * "semantics.osm|traffic_sign" IS NOT NULL'
962
+ * "semantics.osm|amenity" IN ('bench', 'whatever') OR "semantics.osm|traffic_sign"='yes'
963
+ """,
964
+ "required": False,
965
+ "schema": {
966
+ "type": "string",
967
+ },
968
+ },
919
969
  "GeoVisioReports_filter": {
920
970
  "name": "filter",
921
971
  "in": "query",