geovisio 2.8.0__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.
Files changed (61) hide show
  1. geovisio/__init__.py +16 -3
  2. geovisio/config_app.py +11 -1
  3. geovisio/translations/br/LC_MESSAGES/messages.mo +0 -0
  4. geovisio/translations/br/LC_MESSAGES/messages.po +762 -0
  5. geovisio/translations/da/LC_MESSAGES/messages.mo +0 -0
  6. geovisio/translations/da/LC_MESSAGES/messages.po +10 -1
  7. geovisio/translations/de/LC_MESSAGES/messages.mo +0 -0
  8. geovisio/translations/de/LC_MESSAGES/messages.po +10 -1
  9. geovisio/translations/en/LC_MESSAGES/messages.mo +0 -0
  10. geovisio/translations/en/LC_MESSAGES/messages.po +9 -7
  11. geovisio/translations/eo/LC_MESSAGES/messages.mo +0 -0
  12. geovisio/translations/eo/LC_MESSAGES/messages.po +67 -1
  13. geovisio/translations/es/LC_MESSAGES/messages.mo +0 -0
  14. geovisio/translations/es/LC_MESSAGES/messages.po +4 -3
  15. geovisio/translations/fr/LC_MESSAGES/messages.mo +0 -0
  16. geovisio/translations/fr/LC_MESSAGES/messages.po +37 -4
  17. geovisio/translations/hu/LC_MESSAGES/messages.mo +0 -0
  18. geovisio/translations/hu/LC_MESSAGES/messages.po +4 -3
  19. geovisio/translations/it/LC_MESSAGES/messages.mo +0 -0
  20. geovisio/translations/it/LC_MESSAGES/messages.po +10 -1
  21. geovisio/translations/ja/LC_MESSAGES/messages.mo +0 -0
  22. geovisio/translations/ja/LC_MESSAGES/messages.po +242 -154
  23. geovisio/translations/nl/LC_MESSAGES/messages.mo +0 -0
  24. geovisio/translations/nl/LC_MESSAGES/messages.po +131 -25
  25. geovisio/translations/pl/LC_MESSAGES/messages.mo +0 -0
  26. geovisio/translations/pl/LC_MESSAGES/messages.po +4 -3
  27. geovisio/translations/sv/LC_MESSAGES/messages.mo +0 -0
  28. geovisio/translations/sv/LC_MESSAGES/messages.po +822 -0
  29. geovisio/utils/annotations.py +186 -0
  30. geovisio/utils/cql2.py +134 -0
  31. geovisio/utils/db.py +7 -0
  32. geovisio/utils/fields.py +24 -7
  33. geovisio/utils/loggers.py +14 -0
  34. geovisio/utils/model_query.py +2 -2
  35. geovisio/utils/params.py +7 -4
  36. geovisio/utils/pic_shape.py +63 -0
  37. geovisio/utils/pictures.py +54 -12
  38. geovisio/utils/reports.py +10 -17
  39. geovisio/utils/semantics.py +165 -55
  40. geovisio/utils/sentry.py +0 -1
  41. geovisio/utils/sequences.py +141 -60
  42. geovisio/utils/tags.py +31 -0
  43. geovisio/utils/upload_set.py +26 -21
  44. geovisio/utils/website.py +3 -0
  45. geovisio/web/annotations.py +205 -9
  46. geovisio/web/auth.py +3 -2
  47. geovisio/web/collections.py +49 -34
  48. geovisio/web/configuration.py +2 -1
  49. geovisio/web/docs.py +55 -16
  50. geovisio/web/items.py +55 -54
  51. geovisio/web/map.py +25 -13
  52. geovisio/web/params.py +11 -21
  53. geovisio/web/stac.py +19 -12
  54. geovisio/web/upload_set.py +92 -11
  55. geovisio/web/users.py +31 -4
  56. geovisio/workers/runner_pictures.py +71 -10
  57. {geovisio-2.8.0.dist-info → geovisio-2.9.0.dist-info}/METADATA +24 -22
  58. geovisio-2.9.0.dist-info/RECORD +98 -0
  59. {geovisio-2.8.0.dist-info → geovisio-2.9.0.dist-info}/WHEEL +1 -1
  60. geovisio-2.8.0.dist-info/RECORD +0 -89
  61. {geovisio-2.8.0.dist-info → geovisio-2.9.0.dist-info/licenses}/LICENSE +0 -0
@@ -1,42 +1,20 @@
1
+ from ast import Dict
2
+ from collections import defaultdict
1
3
  from dataclasses import dataclass
4
+ import re
2
5
  from uuid import UUID
3
- from psycopg import Cursor
4
- from psycopg.sql import SQL, Identifier
6
+ from psycopg import Connection, Cursor
7
+ from psycopg.sql import SQL, Identifier, Placeholder
5
8
  from psycopg.types.json import Jsonb
6
9
  from psycopg.errors import UniqueViolation
7
- from pydantic import BaseModel, ConfigDict, Field
8
- from typing import List
10
+ from psycopg.rows import dict_row
11
+ from typing import Generator, List, Optional
9
12
  from enum import Enum
10
13
 
11
14
  from geovisio import errors
12
-
13
-
14
- class TagAction(str, Enum):
15
- """Actions to perform on a tag list"""
16
-
17
- add = "add"
18
- delete = "delete"
19
-
20
-
21
- class SemanticTagUpdate(BaseModel):
22
- """Parameters used to update a tag list"""
23
-
24
- action: TagAction = Field(default=TagAction.add)
25
- """Action to perform on the tag list. The default action is `add` which will add the given tag to the list.
26
- The action can also be to `delete` the key/value"""
27
- key: str = Field(max_length=256)
28
- """Key of the tag to update limited to 256 characters"""
29
- value: str = Field(max_length=2048)
30
- """Value of the tag to update limited ot 2048 characters"""
31
-
32
- model_config = ConfigDict(use_attribute_docstrings=True)
33
-
34
-
35
- class SemanticTag(BaseModel):
36
- key: str
37
- """Key of the tag"""
38
- value: str
39
- """Value of the tag"""
15
+ from geovisio.utils.annotations import Annotation, get_picture_annotations
16
+ from geovisio.utils.pic_shape import Geometry
17
+ from geovisio.utils.tags import SemanticTag, SemanticTagUpdate, TagAction
40
18
 
41
19
 
42
20
  class EntityType(Enum):
@@ -45,9 +23,6 @@ class EntityType(Enum):
45
23
  seq = "sequence_id"
46
24
  annotation = "annotation_id"
47
25
 
48
- def entitiy_id_field(self) -> Identifier:
49
- return Identifier(self.value)
50
-
51
26
 
52
27
  @dataclass
53
28
  class Entity:
@@ -72,49 +47,184 @@ class Entity:
72
47
  case EntityType.seq:
73
48
  return Identifier("sequences_semantics_history")
74
49
  case EntityType.annotation:
75
- return Identifier("annotations_semantics_history")
50
+ return Identifier("pictures_semantics_history")
76
51
  case _:
77
52
  raise ValueError(f"Unknown entity type: {self.type}")
78
53
 
79
54
 
80
- def update_tags(cursor: Cursor, entity: Entity, actions: List[SemanticTagUpdate], account: UUID) -> SemanticTag:
55
+ def update_tags(cursor: Cursor, entity: Entity, actions: List[SemanticTagUpdate], account: UUID, annotation=None):
81
56
  """Update tags for an entity
82
57
  Note: this should be done inside an autocommit transaction
83
58
  """
84
59
  table_name = entity.get_table()
85
- fields = [entity.type.entitiy_id_field(), Identifier("key"), Identifier("value")]
86
60
  tag_to_add = [t for t in actions if t.action == TagAction.add]
87
61
  tag_to_delete = [t for t in actions if t.action == TagAction.delete]
88
62
  try:
89
63
  if tag_to_delete:
90
- cursor.execute(SQL("CREATE TEMPORARY TABLE tags_to_delete(key TEXT, value TEXT) ON COMMIT DROP"))
91
- with cursor.copy(SQL("COPY tags_to_delete (key, value) FROM STDIN")) as copy:
92
- for tag in tag_to_delete:
93
- copy.write_row((tag.key, tag.value))
64
+ filter_query = []
65
+ params = [entity.id]
66
+ for tag in tag_to_delete:
67
+ filter_query.append(SQL("(key = %s AND value = %s)"))
68
+ params.append(tag.key)
69
+ params.append(tag.value)
70
+
94
71
  cursor.execute(
95
72
  SQL(
96
73
  """DELETE FROM {table}
97
- WHERE {entity_id} = %(entity)s
98
- AND (key, value) IN (
99
- SELECT key, value FROM tags_to_delete
100
- )"""
101
- ).format(table=table_name, entity_id=entity.type.entitiy_id_field()),
102
- {"entity": entity.id, "key_values": [(t.key, t.value) for t in tag_to_delete]},
74
+ WHERE {entity_id} = %s
75
+ AND ({filter})"""
76
+ ).format(table=table_name, entity_id=Identifier(entity.type.value), filter=SQL(" OR ").join(filter_query)),
77
+ params,
103
78
  )
104
79
  if tag_to_add:
105
- with cursor.copy(SQL("COPY {table} ({fields}) FROM STDIN").format(table=table_name, fields=SQL(",").join(fields))) as copy:
80
+ with cursor.copy(
81
+ SQL("COPY {table} ({fields}) FROM STDIN").format(
82
+ table=table_name,
83
+ fields=SQL(",").join([Identifier(entity.type.value), Identifier("key"), Identifier("value")]),
84
+ )
85
+ ) as copy:
106
86
  for tag in tag_to_add:
107
87
  copy.write_row((entity.id, tag.key, tag.value))
108
88
  if tag_to_add or tag_to_delete:
109
89
  # we track the history changes of the semantic tags
110
- cursor.execute(
111
- SQL("INSERT INTO {history_table} ({entity_id_field}, account_id, updates) VALUES (%(id)s, %(account)s, %(tags)s)").format(
112
- history_table=entity.get_history_table(), entity_id_field=entity.type.entitiy_id_field()
113
- ),
114
- {"id": entity.id, "account": account, "tags": Jsonb([t.model_dump() for t in tag_to_add + tag_to_delete])},
115
- )
90
+ track_semantic_history(cursor, entity, actions, account, annotation)
116
91
  except UniqueViolation as e:
117
92
  # if the tag already exists, we don't want to add it again
118
93
  raise errors.InvalidAPIUsage(
119
94
  "Impossible to add semantic tags because of duplicates", payload={"details": {"duplicate": e.diag.message_detail}}
120
95
  )
96
+
97
+
98
+ class SemanticTagUpdateOnAnnotation(SemanticTagUpdate):
99
+ annotation_shape: Geometry
100
+
101
+
102
+ def track_semantic_history(cursor: Cursor, entity: Entity, actions: List[SemanticTagUpdate], account: UUID, annotation):
103
+ params = {
104
+ "account_id": account,
105
+ }
106
+ if annotation is not None:
107
+ # the annotations are historized in the pictures_semantics_history table
108
+ # and additional information about the annotation.
109
+ # This makes it easier to track annotations deletions
110
+ params["picture_id"] = annotation.picture_id
111
+
112
+ params["updates"] = Jsonb(
113
+ [
114
+ SemanticTagUpdateOnAnnotation(action=t.action, key=t.key, value=t.value, annotation_shape=annotation.shape).model_dump()
115
+ for t in actions
116
+ ]
117
+ )
118
+ else:
119
+ params[entity.type.value] = entity.id
120
+ params["updates"] = Jsonb([t.model_dump() for t in actions])
121
+
122
+ sql = SQL("INSERT INTO {history_table} ({fields}) VALUES ({values})").format(
123
+ history_table=entity.get_history_table(),
124
+ fields=SQL(", ").join([Identifier(k) for k in params.keys()]),
125
+ values=SQL(", ").join([Placeholder(k) for k in params.keys()]),
126
+ )
127
+ cursor.execute(sql, params)
128
+
129
+
130
+ def delete_annotation_tags_from_service(conn: Connection, picture_id: UUID, service_name: str, account: UUID) -> List[Dict]:
131
+ """Delete all tags from a blurring service on a given picture"""
132
+ annotations_tags = list(get_annotation_tags_from_service(conn, picture_id, service_name))
133
+
134
+ with conn.transaction(), conn.cursor(row_factory=dict_row) as cursor:
135
+ for a in annotations_tags:
136
+ actions = [SemanticTagUpdate(action=TagAction.delete, key=t.key, value=t.value) for t in a.semantics]
137
+ entity = Entity(id=a.id, type=EntityType.annotation)
138
+ update_tags(cursor, entity, actions, account, annotation=a)
139
+
140
+ return annotations_tags
141
+
142
+
143
+ QUALIFIER_REGEXP = re.compile(r"^(?P<qualifier>[^\[]*)\[(?P<key>[^=]+)(=(?P<value>.*))?\]$")
144
+
145
+
146
+ @dataclass
147
+ class QualifierSemantic:
148
+ qualifier: str
149
+ associated_key: str
150
+ associated_value: Optional[str]
151
+ raw_tag: SemanticTag
152
+
153
+ def qualifies(self, semantic_tag: SemanticTag) -> bool:
154
+ """Check if a semantic tag is qualified by the qualifier"""
155
+ if semantic_tag.key != self.associated_key:
156
+ return False
157
+ if self.associated_value is None or self.associated_value == "*":
158
+ return True
159
+ return semantic_tag.value == self.associated_value
160
+
161
+
162
+ def as_qualifier(s: SemanticTag) -> Optional[QualifierSemantic]:
163
+ """Try to convert a semantic tag into a qualifier"""
164
+ m = QUALIFIER_REGEXP.search(s.key)
165
+ if m:
166
+ return QualifierSemantic(
167
+ qualifier=m.group("qualifier"),
168
+ associated_key=m.group("key"),
169
+ associated_value=m.group("value"),
170
+ raw_tag=s,
171
+ )
172
+
173
+
174
+ def get_qualifiers(semantics: List[SemanticTag]) -> List[QualifierSemantic]:
175
+ """Find all qualifiers in a list of semantic tags"""
176
+ res = []
177
+ for s in semantics:
178
+ q = as_qualifier(s)
179
+ if q is not None:
180
+ res.append(q)
181
+ return res
182
+
183
+
184
+ def find_detection_model_tags(qualifiers: List[QualifierSemantic], service_name: str) -> List[QualifierSemantic]:
185
+ """Find all detection models associated to a picture, from a given service"""
186
+ res = []
187
+ for q in qualifiers:
188
+ if not q.raw_tag.value.startswith(f"{service_name}-"):
189
+ continue
190
+ if q.qualifier != "detection_model":
191
+ continue
192
+ res.append(q)
193
+ return res
194
+
195
+
196
+ def find_semantics_from_service(annotation, service_name: str) -> Generator[SemanticTag, None, None]:
197
+ """Find all semantics tags related from a given bluring service
198
+
199
+ The blurring service will add a `detection_model` qualifier with a value starting by its name (like `SGBlur-yolo11n/0.1.0` for `SGBlur`)
200
+
201
+ This method will return all linked semantics tags, and all their qualifiers.
202
+ """
203
+ qualifiers = get_qualifiers(annotation.semantics)
204
+ detection_model_tags = find_detection_model_tags(qualifiers, service_name)
205
+ qualified_tags = []
206
+ for s in annotation.semantics:
207
+ for qualifier_tag in detection_model_tags:
208
+ if qualifier_tag.qualifies(s):
209
+ qualified_tags.append(s)
210
+ break
211
+
212
+ # we then have to get all qualifiers on those tags
213
+ related_qualifiers = []
214
+ for q in qualifiers:
215
+ for t in qualified_tags:
216
+ if q.qualifies(t):
217
+ related_qualifiers.append(q.raw_tag)
218
+ break
219
+
220
+ return qualified_tags + related_qualifiers
221
+
222
+
223
+ def get_annotation_tags_from_service(conn: Connection, picture_id: UUID, service_name: str) -> Generator[Annotation, None, None]:
224
+ """Get all annotations semantics from a blurring service"""
225
+
226
+ annotations = get_picture_annotations(conn, picture_id)
227
+
228
+ for a in annotations:
229
+ semantics = [s for s in find_semantics_from_service(a, service_name)]
230
+ yield Annotation(id=a.id, picture_id=a.picture_id, shape=a.shape, semantics=semantics)
geovisio/utils/sentry.py CHANGED
@@ -60,7 +60,6 @@ def _wrap_cursor_execute(f):
60
60
  return f(self, query, params, prepare=prepare, binary=binary)
61
61
 
62
62
  with record_sql_queries(
63
- hub=hub,
64
63
  cursor=self,
65
64
  query=query,
66
65
  params_list=params,
@@ -1,5 +1,8 @@
1
+ from operator import ne
2
+ from click import Option
3
+ from numpy import sort
1
4
  import psycopg
2
- from flask import current_app, url_for
5
+ from flask import current_app, g, url_for
3
6
  from flask_babel import gettext as _
4
7
  from psycopg.types.json import Jsonb
5
8
  from psycopg.sql import SQL, Composable
@@ -11,7 +14,7 @@ from uuid import UUID
11
14
  from enum import Enum
12
15
  from geovisio.utils import db
13
16
  from geovisio.utils.auth import Account
14
- from geovisio.utils.fields import FieldMapping, SortBy, SQLDirection, BBox, Bounds
17
+ from geovisio.utils.fields import FieldMapping, SortBy, SQLDirection, BBox, Bounds, SortByField
15
18
  from geopic_tag_reader import reader
16
19
  from pathlib import PurePath
17
20
  from geovisio import errors, utils
@@ -39,6 +42,7 @@ STAC_FIELD_MAPPINGS = {
39
42
  FieldMapping(sql_column=SQL("updated_at"), stac="updated"),
40
43
  FieldMapping(sql_column=SQL("computed_capture_date"), stac="datetime"),
41
44
  FieldMapping(sql_column=SQL("status"), stac="status"),
45
+ FieldMapping(sql_column=SQL("id"), stac="id"),
42
46
  ]
43
47
  }
44
48
  STAC_FIELD_TO_SQL_FILTER = {p.stac: p.sql_filter.as_string(None) for p in STAC_FIELD_MAPPINGS.values()}
@@ -52,7 +56,7 @@ class Collections:
52
56
 
53
57
  collections: List[Dict[Any, Any]] = field(default_factory=lambda: [])
54
58
  # Bounds of the field used by the first field of the `ORDER BY` (usefull especially for pagination)
55
- query_first_order_bounds: Optional[Bounds] = None
59
+ query_bounds: Optional[Bounds] = None
56
60
 
57
61
 
58
62
  @dataclass
@@ -166,7 +170,7 @@ def get_collections(request: CollectionsRequest) -> Collections:
166
170
  SELECT sequence_id, json_agg(json_strip_nulls(json_build_object(
167
171
  'key', key,
168
172
  'value', value
169
- ))) AS semantics
173
+ )) ORDER BY key, value) AS semantics
170
174
  FROM sequences_semantics
171
175
  GROUP BY sequence_id
172
176
  ) t ON t.sequence_id = s.id
@@ -184,71 +188,150 @@ def get_collections(request: CollectionsRequest) -> Collections:
184
188
 
185
189
  # Different request if we want the last n sequences
186
190
  # Useful for paginating from last page to first
187
- if request.pagination_filter and (
188
- (
189
- request.sort_by.fields[0].direction == SQLDirection.ASC
190
- and request.pagination_filter.as_string(None).startswith(f"({request.sort_by.fields[0].field.sql_filter.as_string(None)} <")
191
- )
192
- or (
193
- request.sort_by.fields[0].direction == SQLDirection.DESC
194
- and request.pagination_filter.as_string(None).startswith(f"({request.sort_by.fields[0].field.sql_filter.as_string(None)} >")
195
- )
196
- ):
197
- base_query = sqlSequencesRaw.format(
198
- filter=SQL(" AND ").join(seq_filter),
199
- order1=request.sort_by.revert(),
200
- limit=request.limit,
201
- status=status_field,
202
- )
203
- sqlSequences = SQL(
191
+ if request.pagination_filter:
192
+ # note: we don't want to compare the leading parenthesis
193
+ pagination = request.pagination_filter.as_string(None).strip("(")
194
+ first_sort = request.sort_by.fields[0]
195
+ if (first_sort.direction == SQLDirection.ASC and pagination.startswith(f"{first_sort.field.sql_filter.as_string(None)} <")) or (
196
+ first_sort.direction == SQLDirection.DESC and pagination.startswith(f"{first_sort.field.sql_filter.as_string(None)} >")
197
+ ):
198
+ base_query = sqlSequencesRaw.format(
199
+ filter=SQL(" AND ").join(seq_filter),
200
+ order1=request.sort_by.revert(),
201
+ limit=request.limit,
202
+ status=status_field,
203
+ )
204
+ sqlSequences = SQL(
205
+ """
206
+ SELECT *
207
+ FROM ({base_query}) s
208
+ ORDER BY {order2}
204
209
  """
205
- SELECT *
206
- FROM ({base_query}) s
207
- ORDER BY {order2}
208
- """
209
- ).format(
210
- order2=request.sort_by.as_sql(),
211
- base_query=base_query,
212
- )
210
+ ).format(
211
+ order2=request.sort_by.as_sql(),
212
+ base_query=base_query,
213
+ )
213
214
 
214
215
  records = cursor.execute(sqlSequences, seq_params).fetchall()
215
216
 
216
217
  query_bounds = None
217
- for s in records:
218
- first_order_val = s.get(request.sort_by.fields[0].field.stac)
219
- if first_order_val is None:
220
- continue
221
- if query_bounds is None:
222
- query_bounds = Bounds(first_order_val, first_order_val)
223
- else:
224
- query_bounds.update(first_order_val)
218
+ if records:
219
+ first = [records[0].get(f.field.stac) for f in request.sort_by.fields]
220
+ last = [records[-1].get(f.field.stac) for f in request.sort_by.fields]
221
+ query_bounds = Bounds(first, last)
225
222
 
226
223
  return Collections(
227
224
  collections=records,
228
- query_first_order_bounds=query_bounds,
225
+ query_bounds=query_bounds,
229
226
  )
230
227
 
231
228
 
229
+ def get_pagination_stac_filter(sortBy: SortBy, dataBounds: Optional[Bounds[List[Any]]], next: bool) -> str:
230
+ """Create a pagination API filters, using the sorts and the bounds of the current query"""
231
+ filters = []
232
+ bounds = dataBounds.last if next else dataBounds.first
233
+ for i, f in enumerate(sortBy.fields):
234
+ direction = f.direction
235
+ # bounds is a list of values, for all sorty_by fields
236
+ if (next and direction == SQLDirection.ASC) or (not next and direction == SQLDirection.DESC):
237
+ cmp = ">"
238
+ else:
239
+ cmp = "<"
240
+ field_pagination = f"{f.field.stac} {cmp} '{bounds[i]}'"
241
+
242
+ previous_filters = sortBy.fields[:i]
243
+ if previous_filters:
244
+ prev_fields = " AND ".join([f"{f.field.stac} = '{bounds[prev_i]}'" for prev_i, f in enumerate(previous_filters)]) + " AND "
245
+ filters.append(f"({prev_fields}{field_pagination})")
246
+ else:
247
+ filters.append(field_pagination)
248
+ return " OR ".join(filters)
249
+
250
+
251
+ def get_dataset_bounds(
252
+ conn: psycopg.Connection,
253
+ sortBy: SortBy,
254
+ additional_filters: Optional[SQL] = None,
255
+ additional_filters_params: Optional[Dict[str, Any]] = None,
256
+ ) -> Optional[Bounds]:
257
+ """Computes the dataset bounds from the sortBy field (using lexicographic order)
258
+
259
+ if there are several sort-by fields like (inserted_at, updated_at), this will return a bound with minimum (resp maximum)
260
+ inserted_at value, and for this value, the minimum (resp maximum) updated_at value.
261
+ """
262
+ with conn.cursor() as cursor:
263
+
264
+ sql_bounds = cursor.execute(
265
+ SQL(
266
+ """WITH min_bounds AS (
267
+ SELECT {fields} from sequences s WHERE {filters} ORDER BY {ordered_fields} LIMIT 1
268
+ ),
269
+ max_bounds AS (
270
+ SELECT {fields} from sequences s WHERE {filters} ORDER BY {reverse_fields} LIMIT 1
271
+ )
272
+ SELECT * FROM min_bounds, max_bounds;
273
+ """
274
+ ).format(
275
+ fields=SQL(", ").join([f.field.sql_column for f in sortBy.fields]),
276
+ ordered_fields=sortBy.as_non_aliased_sql(),
277
+ reverse_fields=sortBy.revert_non_aliased_sql(),
278
+ filters=additional_filters or SQL("TRUE"),
279
+ ),
280
+ params=additional_filters_params or {},
281
+ ).fetchone()
282
+ if not sql_bounds:
283
+ return None
284
+ min = [sql_bounds[i] for i, f in enumerate(sortBy.fields)]
285
+ max = [sql_bounds[i + len(sortBy.fields)] for i, f in enumerate(sortBy.fields)]
286
+ return Bounds(first=min, last=max)
287
+
288
+
289
+ def has_previous_results(sortBy: SortBy, datasetBounds: Bounds, dataBounds: Bounds) -> bool:
290
+ """Check if there are results in the database before the one returned by the queries
291
+ To do this, we do a lexicographic comparison of the bounds, using the fields direction
292
+
293
+ Note: the bounds are reversed for the DESC direction, so the bounds.min >= bounds.max for the DESC direction
294
+ """
295
+ for i, f in enumerate(sortBy.fields):
296
+ if dataBounds.first[i] is None or datasetBounds.first[i] is None:
297
+ continue
298
+ if (f.direction == SQLDirection.ASC and dataBounds.first[i] > datasetBounds.first[i]) or (
299
+ f.direction == SQLDirection.DESC and datasetBounds.first[i] > dataBounds.first[i]
300
+ ):
301
+ return True
302
+ return False
303
+
304
+
305
+ def has_next_results(sortBy: SortBy, datasetBounds: Bounds, dataBounds: Bounds) -> bool:
306
+ """Check if there are results in the database after the one returned by the queries
307
+ To do this, we do a lexicographic comparison of the bounds, using the fields direction"""
308
+ for i, f in enumerate(sortBy.fields):
309
+ if dataBounds.last[i] is None or datasetBounds.last[i] is None:
310
+ continue
311
+ if (f.direction == SQLDirection.ASC and dataBounds.last[i] < datasetBounds.last[i]) or (
312
+ f.direction == SQLDirection.DESC and datasetBounds.last[i] < dataBounds.last[i]
313
+ ):
314
+ return True
315
+ return False
316
+
317
+
232
318
  def get_pagination_links(
233
319
  route: str,
234
320
  routeArgs: dict,
235
- field: str,
236
- direction: SQLDirection,
321
+ sortBy: SortBy,
237
322
  datasetBounds: Bounds,
238
323
  dataBounds: Optional[Bounds],
239
324
  additional_filters: Optional[str],
240
325
  ) -> List:
241
326
  """Computes STAC links to handle pagination"""
242
327
 
243
- sortby = f"{'+' if direction == SQLDirection.ASC else '-'}{field}"
328
+ sortby = sortBy.as_stac()
244
329
  links = []
245
- if dataBounds is None:
330
+ if dataBounds is None or datasetBounds is None:
246
331
  return links
247
332
 
248
333
  # Check if first/prev links are necessary
249
- if (direction == SQLDirection.ASC and datasetBounds.min < dataBounds.min) or (
250
- direction == SQLDirection.DESC and dataBounds.max < datasetBounds.max
251
- ):
334
+ if has_previous_results(sortBy, datasetBounds=datasetBounds, dataBounds=dataBounds):
252
335
  links.append(
253
336
  {
254
337
  "rel": "first",
@@ -256,7 +339,9 @@ def get_pagination_links(
256
339
  "href": url_for(route, _external=True, **routeArgs, filter=additional_filters, sortby=sortby),
257
340
  }
258
341
  )
259
- page_filter = f"{field} {'<' if direction == SQLDirection.ASC else '>'} '{dataBounds.min if direction == SQLDirection.ASC else dataBounds.max}'"
342
+
343
+ page_filter = get_pagination_stac_filter(sortBy, dataBounds, next=False)
344
+
260
345
  links.append(
261
346
  {
262
347
  "rel": "prev",
@@ -273,11 +358,8 @@ def get_pagination_links(
273
358
  )
274
359
 
275
360
  # Check if next/last links are required
276
- if (direction == SQLDirection.ASC and dataBounds.max < datasetBounds.max) or (
277
- direction == SQLDirection.DESC and datasetBounds.min < dataBounds.min
278
- ):
279
- next_filter = f"{field} {'>' if direction == SQLDirection.ASC else '<'} '{dataBounds.max if direction == SQLDirection.ASC else dataBounds.min}'"
280
- last_filter = f"{field} {'<=' if direction == SQLDirection.ASC else '>='} '{datasetBounds.max if direction == SQLDirection.ASC else datasetBounds.min}'"
361
+ if has_next_results(sortBy, datasetBounds=datasetBounds, dataBounds=dataBounds):
362
+ next_filter = get_pagination_stac_filter(sortBy, dataBounds, next=True)
281
363
  links.append(
282
364
  {
283
365
  "rel": "next",
@@ -292,6 +374,10 @@ def get_pagination_links(
292
374
  ),
293
375
  }
294
376
  )
377
+ # for last, we only consider the first field used for sorting, the rest are useless
378
+ # Note: we compare to the datasetBounds last since it depends on the sort direction (so, for DESC, it last<first)
379
+ f = sortBy.fields[0]
380
+ last_filter = f"{f.field.stac} {'<=' if f.direction == SQLDirection.ASC else '>='} '{datasetBounds.last[0]}'"
295
381
  links.append(
296
382
  {
297
383
  "rel": "last",
@@ -423,15 +509,10 @@ def sort_collection(db, collectionId: UUID, sortby: CollectionSort):
423
509
  picForDb = [(collectionId, i + 1, p["id"]) for i, p in enumerate(picMetas)]
424
510
 
425
511
  # Inject back pictures in sequence
426
- db.executemany(
427
- SQL(
428
- """
429
- INSERT INTO sequences_pictures(seq_id, rank, pic_id)
430
- VALUES (%s, %s, %s)
431
- """
432
- ),
433
- picForDb,
434
- )
512
+ db.executemany(SQL("INSERT INTO sequences_pictures(seq_id, rank, pic_id) VALUES (%s, %s, %s)"), picForDb)
513
+
514
+ # we update the geometry of the sequence after this (the other computed fields have no need for an update)
515
+ db.execute(SQL("UPDATE sequences SET geom = compute_sequence_geom(id) WHERE id = %s"), [collectionId])
435
516
 
436
517
 
437
518
  def update_headings(
geovisio/utils/tags.py ADDED
@@ -0,0 +1,31 @@
1
+ from enum import Enum
2
+
3
+ from pydantic import BaseModel, ConfigDict, Field
4
+
5
+
6
+ class TagAction(str, Enum):
7
+ """Actions to perform on a tag list"""
8
+
9
+ add = "add"
10
+ delete = "delete"
11
+
12
+
13
+ class SemanticTagUpdate(BaseModel):
14
+ """Parameters used to update a tag list"""
15
+
16
+ action: TagAction = Field(default=TagAction.add)
17
+ """Action to perform on the tag list. The default action is `add` which will add the given tag to the list.
18
+ The action can also be to `delete` the key/value"""
19
+ key: str = Field(max_length=256)
20
+ """Key of the tag to update limited to 256 characters"""
21
+ value: str = Field(max_length=2048)
22
+ """Value of the tag to update limited ot 2048 characters"""
23
+
24
+ model_config = ConfigDict(use_attribute_docstrings=True)
25
+
26
+
27
+ class SemanticTag(BaseModel):
28
+ key: str
29
+ """Key of the tag"""
30
+ value: str
31
+ """Value of the tag"""