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.
- geovisio/__init__.py +16 -3
- geovisio/config_app.py +11 -1
- geovisio/translations/br/LC_MESSAGES/messages.mo +0 -0
- geovisio/translations/br/LC_MESSAGES/messages.po +762 -0
- geovisio/translations/da/LC_MESSAGES/messages.mo +0 -0
- geovisio/translations/da/LC_MESSAGES/messages.po +10 -1
- geovisio/translations/de/LC_MESSAGES/messages.mo +0 -0
- geovisio/translations/de/LC_MESSAGES/messages.po +10 -1
- geovisio/translations/en/LC_MESSAGES/messages.mo +0 -0
- geovisio/translations/en/LC_MESSAGES/messages.po +9 -7
- geovisio/translations/eo/LC_MESSAGES/messages.mo +0 -0
- geovisio/translations/eo/LC_MESSAGES/messages.po +67 -1
- geovisio/translations/es/LC_MESSAGES/messages.mo +0 -0
- geovisio/translations/es/LC_MESSAGES/messages.po +4 -3
- geovisio/translations/fr/LC_MESSAGES/messages.mo +0 -0
- geovisio/translations/fr/LC_MESSAGES/messages.po +37 -4
- geovisio/translations/hu/LC_MESSAGES/messages.mo +0 -0
- geovisio/translations/hu/LC_MESSAGES/messages.po +4 -3
- geovisio/translations/it/LC_MESSAGES/messages.mo +0 -0
- geovisio/translations/it/LC_MESSAGES/messages.po +10 -1
- geovisio/translations/ja/LC_MESSAGES/messages.mo +0 -0
- geovisio/translations/ja/LC_MESSAGES/messages.po +242 -154
- geovisio/translations/nl/LC_MESSAGES/messages.mo +0 -0
- geovisio/translations/nl/LC_MESSAGES/messages.po +131 -25
- geovisio/translations/pl/LC_MESSAGES/messages.mo +0 -0
- geovisio/translations/pl/LC_MESSAGES/messages.po +4 -3
- geovisio/translations/sv/LC_MESSAGES/messages.mo +0 -0
- geovisio/translations/sv/LC_MESSAGES/messages.po +822 -0
- geovisio/utils/annotations.py +186 -0
- geovisio/utils/cql2.py +134 -0
- geovisio/utils/db.py +7 -0
- geovisio/utils/fields.py +24 -7
- geovisio/utils/loggers.py +14 -0
- geovisio/utils/model_query.py +2 -2
- geovisio/utils/params.py +7 -4
- geovisio/utils/pic_shape.py +63 -0
- geovisio/utils/pictures.py +54 -12
- geovisio/utils/reports.py +10 -17
- geovisio/utils/semantics.py +165 -55
- geovisio/utils/sentry.py +0 -1
- geovisio/utils/sequences.py +141 -60
- geovisio/utils/tags.py +31 -0
- geovisio/utils/upload_set.py +26 -21
- geovisio/utils/website.py +3 -0
- geovisio/web/annotations.py +205 -9
- geovisio/web/auth.py +3 -2
- geovisio/web/collections.py +49 -34
- geovisio/web/configuration.py +2 -1
- geovisio/web/docs.py +55 -16
- geovisio/web/items.py +55 -54
- geovisio/web/map.py +25 -13
- geovisio/web/params.py +11 -21
- geovisio/web/stac.py +19 -12
- geovisio/web/upload_set.py +92 -11
- geovisio/web/users.py +31 -4
- geovisio/workers/runner_pictures.py +71 -10
- {geovisio-2.8.0.dist-info → geovisio-2.9.0.dist-info}/METADATA +24 -22
- geovisio-2.9.0.dist-info/RECORD +98 -0
- {geovisio-2.8.0.dist-info → geovisio-2.9.0.dist-info}/WHEEL +1 -1
- geovisio-2.8.0.dist-info/RECORD +0 -89
- {geovisio-2.8.0.dist-info → geovisio-2.9.0.dist-info/licenses}/LICENSE +0 -0
geovisio/utils/semantics.py
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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("
|
|
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)
|
|
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
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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(
|
|
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
|
|
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
geovisio/utils/sequences.py
CHANGED
|
@@ -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
|
-
|
|
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
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
)
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
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
|
-
|
|
206
|
-
|
|
207
|
-
|
|
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
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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 (
|
|
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
|
-
|
|
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 (
|
|
277
|
-
|
|
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
|
-
|
|
428
|
-
|
|
429
|
-
|
|
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"""
|