geovisio 2.8.1__py3-none-any.whl → 2.10.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 +6 -1
- geovisio/config_app.py +16 -5
- geovisio/translations/ar/LC_MESSAGES/messages.mo +0 -0
- geovisio/translations/ar/LC_MESSAGES/messages.po +818 -0
- geovisio/translations/br/LC_MESSAGES/messages.po +1 -1
- geovisio/translations/da/LC_MESSAGES/messages.mo +0 -0
- geovisio/translations/da/LC_MESSAGES/messages.po +4 -3
- geovisio/translations/de/LC_MESSAGES/messages.mo +0 -0
- geovisio/translations/de/LC_MESSAGES/messages.po +55 -2
- geovisio/translations/el/LC_MESSAGES/messages.po +1 -1
- geovisio/translations/en/LC_MESSAGES/messages.mo +0 -0
- geovisio/translations/en/LC_MESSAGES/messages.po +193 -139
- geovisio/translations/eo/LC_MESSAGES/messages.mo +0 -0
- geovisio/translations/eo/LC_MESSAGES/messages.po +53 -4
- geovisio/translations/es/LC_MESSAGES/messages.po +1 -1
- geovisio/translations/fi/LC_MESSAGES/messages.po +1 -1
- geovisio/translations/fr/LC_MESSAGES/messages.mo +0 -0
- geovisio/translations/fr/LC_MESSAGES/messages.po +101 -6
- geovisio/translations/hu/LC_MESSAGES/messages.po +1 -1
- geovisio/translations/it/LC_MESSAGES/messages.mo +0 -0
- geovisio/translations/it/LC_MESSAGES/messages.po +63 -3
- geovisio/translations/ja/LC_MESSAGES/messages.po +1 -1
- geovisio/translations/ko/LC_MESSAGES/messages.po +1 -1
- geovisio/translations/messages.pot +185 -129
- geovisio/translations/nl/LC_MESSAGES/messages.mo +0 -0
- geovisio/translations/nl/LC_MESSAGES/messages.po +421 -86
- geovisio/translations/oc/LC_MESSAGES/messages.mo +0 -0
- geovisio/translations/oc/LC_MESSAGES/messages.po +818 -0
- geovisio/translations/pl/LC_MESSAGES/messages.po +1 -1
- geovisio/translations/sv/LC_MESSAGES/messages.mo +0 -0
- geovisio/translations/sv/LC_MESSAGES/messages.po +823 -0
- geovisio/translations/ti/LC_MESSAGES/messages.mo +0 -0
- geovisio/translations/ti/LC_MESSAGES/messages.po +762 -0
- geovisio/translations/zh_Hant/LC_MESSAGES/messages.po +1 -1
- geovisio/utils/annotations.py +183 -0
- geovisio/utils/auth.py +14 -13
- geovisio/utils/cql2.py +134 -0
- geovisio/utils/db.py +7 -0
- geovisio/utils/fields.py +38 -9
- geovisio/utils/items.py +44 -0
- geovisio/utils/model_query.py +4 -4
- geovisio/utils/pic_shape.py +63 -0
- geovisio/utils/pictures.py +164 -29
- geovisio/utils/reports.py +10 -17
- geovisio/utils/semantics.py +196 -57
- geovisio/utils/sentry.py +1 -2
- geovisio/utils/sequences.py +191 -93
- geovisio/utils/tags.py +31 -0
- geovisio/utils/upload_set.py +287 -209
- geovisio/utils/website.py +1 -1
- geovisio/web/annotations.py +346 -9
- geovisio/web/auth.py +1 -1
- geovisio/web/collections.py +73 -54
- geovisio/web/configuration.py +26 -5
- geovisio/web/docs.py +143 -11
- geovisio/web/items.py +232 -155
- geovisio/web/map.py +25 -13
- geovisio/web/params.py +55 -52
- geovisio/web/pictures.py +34 -0
- geovisio/web/stac.py +19 -12
- geovisio/web/tokens.py +49 -1
- geovisio/web/upload_set.py +148 -37
- geovisio/web/users.py +4 -4
- geovisio/web/utils.py +2 -2
- geovisio/workers/runner_pictures.py +190 -24
- {geovisio-2.8.1.dist-info → geovisio-2.10.0.dist-info}/METADATA +27 -26
- geovisio-2.10.0.dist-info/RECORD +105 -0
- {geovisio-2.8.1.dist-info → geovisio-2.10.0.dist-info}/WHEEL +1 -1
- geovisio-2.8.1.dist-info/RECORD +0 -92
- {geovisio-2.8.1.dist-info → geovisio-2.10.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
from typing import List, Optional
|
|
2
|
+
from uuid import UUID
|
|
3
|
+
from flask import current_app
|
|
4
|
+
import psycopg
|
|
5
|
+
from pydantic import BaseModel, Field, field_validator
|
|
6
|
+
from psycopg.sql import SQL
|
|
7
|
+
from psycopg.rows import class_row, dict_row
|
|
8
|
+
|
|
9
|
+
from geovisio import errors
|
|
10
|
+
from geovisio.utils import db, model_query
|
|
11
|
+
from geovisio.utils import semantics
|
|
12
|
+
from geovisio.utils.pic_shape import InputAnnotationShape, Geometry, get_coords_from_shape, shape_as_geometry
|
|
13
|
+
from geovisio.utils.tags import SemanticTag, SemanticTagUpdate
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class Annotation(BaseModel):
|
|
17
|
+
id: UUID
|
|
18
|
+
"""ID of the annotation"""
|
|
19
|
+
picture_id: UUID
|
|
20
|
+
"""ID of the picture to which the annotation is linked to"""
|
|
21
|
+
shape: Geometry
|
|
22
|
+
"""Polygon defining the annotation"""
|
|
23
|
+
semantics: List[SemanticTag] = Field(default_factory=list)
|
|
24
|
+
"""Semantic tags associated to the annotation"""
|
|
25
|
+
|
|
26
|
+
@field_validator("semantics", mode="before")
|
|
27
|
+
@classmethod
|
|
28
|
+
def parse_semantics(cls, value):
|
|
29
|
+
return value or []
|
|
30
|
+
|
|
31
|
+
@field_validator("shape", mode="before")
|
|
32
|
+
def validate_shape(cls, value):
|
|
33
|
+
# if its a bounding box, transform it to a polygon
|
|
34
|
+
return shape_as_geometry(value)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class AnnotationCreationRow(BaseModel):
|
|
38
|
+
picture_id: UUID
|
|
39
|
+
"""ID of the picture to which the annotation is linked to"""
|
|
40
|
+
shape: Geometry
|
|
41
|
+
"""shape defining the annotation"""
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class AnnotationCreationParameter(BaseModel):
|
|
45
|
+
account_id: UUID
|
|
46
|
+
"""ID of the account that created the annotation"""
|
|
47
|
+
picture_id: UUID
|
|
48
|
+
"""ID of the picture to which the annotation is linked to"""
|
|
49
|
+
shape: InputAnnotationShape
|
|
50
|
+
"""Shape defining the annotation.
|
|
51
|
+
The annotation shape is either a full geojson geometry or only a bounding box (4 floats).
|
|
52
|
+
|
|
53
|
+
The coordinates should be given in pixel, starting from the bottom left of the picture.
|
|
54
|
+
|
|
55
|
+
Note that the API will always output geometry as geojson geometry (thus will transform the bbox into a polygon).
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
semantics: List[SemanticTagUpdate] = Field(default_factory=list)
|
|
59
|
+
"""Semantic tags associated to the annotation"""
|
|
60
|
+
|
|
61
|
+
def shape_as_geometry(self) -> Geometry:
|
|
62
|
+
return shape_as_geometry(self.shape)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def creation_annotation(params: AnnotationCreationParameter, conn: psycopg.Connection) -> Annotation:
|
|
66
|
+
"""Create an annotation in the database.
|
|
67
|
+
Note, this should be called from an autocommit connection"""
|
|
68
|
+
|
|
69
|
+
model = model_query.get_db_params_and_values(
|
|
70
|
+
AnnotationCreationRow(picture_id=params.picture_id, shape=params.shape_as_geometry()), jsonb_fields={"shape"}
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
with conn.transaction(), conn.cursor(row_factory=class_row(Annotation)) as cursor:
|
|
74
|
+
# we check that the shape is valid
|
|
75
|
+
check_shape(conn, params)
|
|
76
|
+
|
|
77
|
+
annotation = cursor.execute(
|
|
78
|
+
"SELECT * FROM annotations WHERE picture_id = %(picture_id)s AND shape = %(shape)s", model.params_as_dict
|
|
79
|
+
).fetchone()
|
|
80
|
+
if annotation is None:
|
|
81
|
+
annotation = cursor.execute(
|
|
82
|
+
"""INSERT INTO annotations (picture_id, shape)
|
|
83
|
+
VALUES (%(picture_id)s, %(shape)s)
|
|
84
|
+
RETURNING *""",
|
|
85
|
+
model.params_as_dict,
|
|
86
|
+
).fetchone()
|
|
87
|
+
|
|
88
|
+
if annotation is None:
|
|
89
|
+
raise Exception("Impossible to insert annotation in database")
|
|
90
|
+
|
|
91
|
+
semantics.update_tags(
|
|
92
|
+
cursor=cursor,
|
|
93
|
+
entity=semantics.Entity(semantics.EntityType.annotation, annotation.id),
|
|
94
|
+
actions=params.semantics,
|
|
95
|
+
account=params.account_id,
|
|
96
|
+
annotation=annotation,
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
return get_annotation(conn, annotation.id)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def check_shape(conn: psycopg.Connection, params: AnnotationCreationParameter):
|
|
103
|
+
"""Check that the shape is valid"""
|
|
104
|
+
with conn.cursor(row_factory=dict_row) as cursor:
|
|
105
|
+
picture_size = cursor.execute(
|
|
106
|
+
SQL("SELECT (metadata->>'width')::int AS width, (metadata->>'height')::int AS height FROM pictures WHERE id = %(pic)s"),
|
|
107
|
+
{"pic": params.picture_id},
|
|
108
|
+
).fetchone()
|
|
109
|
+
|
|
110
|
+
for x, y in get_coords_from_shape(params.shape):
|
|
111
|
+
if x < 0 or x > picture_size["width"] or y < 0 or y > picture_size["height"]:
|
|
112
|
+
raise errors.InvalidAPIUsage(
|
|
113
|
+
message="Annotation shape is outside the range of the picture",
|
|
114
|
+
payload={
|
|
115
|
+
"details": f"Annotation shape's coordinates should be in pixel, between [0, 0] and [{picture_size['width']}, {picture_size['height']}]",
|
|
116
|
+
"value": {"x": x, "y": y},
|
|
117
|
+
},
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def get_annotation(conn: psycopg.Connection, id: UUID) -> Optional[Annotation]:
|
|
122
|
+
"""Get an annotation in the database"""
|
|
123
|
+
with conn.cursor(row_factory=class_row(Annotation)) as cursor:
|
|
124
|
+
return cursor.execute(
|
|
125
|
+
SQL(
|
|
126
|
+
"""SELECT
|
|
127
|
+
id,
|
|
128
|
+
shape,
|
|
129
|
+
picture_id,
|
|
130
|
+
t.semantics
|
|
131
|
+
FROM annotations a
|
|
132
|
+
LEFT JOIN (
|
|
133
|
+
SELECT annotation_id, json_agg(json_strip_nulls(json_build_object(
|
|
134
|
+
'key', key,
|
|
135
|
+
'value', value
|
|
136
|
+
)) ORDER BY key, value) AS semantics
|
|
137
|
+
FROM annotations_semantics
|
|
138
|
+
GROUP BY annotation_id
|
|
139
|
+
) t ON t.annotation_id = a.id
|
|
140
|
+
WHERE a.id = %(id)s"""
|
|
141
|
+
),
|
|
142
|
+
{"id": id},
|
|
143
|
+
).fetchone()
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def get_picture_annotations(conn: psycopg.Connection, picture_id: UUID) -> List[Annotation]:
|
|
147
|
+
"""Get all annotations linked to a picture"""
|
|
148
|
+
with conn.cursor() as cursor:
|
|
149
|
+
json_annotations = cursor.execute(
|
|
150
|
+
SQL("SELECT get_picture_annotations(p.id) FROM pictures p WHERE p.id = %(pic)s"),
|
|
151
|
+
{"pic": picture_id},
|
|
152
|
+
).fetchone()
|
|
153
|
+
if not json_annotations or not json_annotations[0]:
|
|
154
|
+
return []
|
|
155
|
+
return [Annotation(**a, picture_id=picture_id) for a in json_annotations[0]]
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def update_annotation(annotation: Annotation, tag_updates: List[SemanticTagUpdate], account_id: UUID) -> Optional[Annotation]:
|
|
159
|
+
"""update an annotation in the database.
|
|
160
|
+
If the annotation has no semantic tags anymore after the update, it will be deleted
|
|
161
|
+
"""
|
|
162
|
+
with db.conn(current_app) as conn, conn.transaction(), conn.cursor(row_factory=class_row(Annotation)) as cursor:
|
|
163
|
+
|
|
164
|
+
semantics.update_tags(
|
|
165
|
+
cursor=cursor,
|
|
166
|
+
entity=semantics.Entity(semantics.EntityType.annotation, annotation.id),
|
|
167
|
+
actions=tag_updates,
|
|
168
|
+
account=account_id,
|
|
169
|
+
annotation=annotation,
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
a = get_annotation(conn, annotation.id)
|
|
173
|
+
|
|
174
|
+
if a and len(a.semantics) == 0:
|
|
175
|
+
# the annotation will be deleted by a trigger if its empty
|
|
176
|
+
return None
|
|
177
|
+
return a
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def delete_annotation(conn: psycopg.Connection, annotation_id: UUID) -> None:
|
|
181
|
+
"""Delete an annotation from the database"""
|
|
182
|
+
with conn.cursor() as cursor:
|
|
183
|
+
cursor.execute("DELETE FROM annotations WHERE id = %(id)s", {"id": annotation_id})
|
geovisio/utils/auth.py
CHANGED
|
@@ -31,7 +31,7 @@ class OAuthUserAccount(object):
|
|
|
31
31
|
|
|
32
32
|
|
|
33
33
|
class OAuthProvider(ABC):
|
|
34
|
-
"""Base class for oauth provider. Need
|
|
34
|
+
"""Base class for oauth provider. Need to specify how to get user's info"""
|
|
35
35
|
|
|
36
36
|
name: str
|
|
37
37
|
client: Any
|
|
@@ -52,7 +52,7 @@ class OAuthProvider(ABC):
|
|
|
52
52
|
"""
|
|
53
53
|
URL to a user settings page.
|
|
54
54
|
This URL should point to a web page where user can edit its password or email address,
|
|
55
|
-
if that makes sense
|
|
55
|
+
if that makes sense regarding your GeoVisio instance.
|
|
56
56
|
|
|
57
57
|
This is useful if your instance has its own specific identity provider. It may not be used if you rely on third-party auth provider.
|
|
58
58
|
"""
|
|
@@ -235,7 +235,7 @@ class Account(BaseModel):
|
|
|
235
235
|
|
|
236
236
|
|
|
237
237
|
def account_allow_collaborative_editing(account_id: str | UUID):
|
|
238
|
-
"""An account
|
|
238
|
+
"""An account allows collaborative editing it if has been allowed at the account level else we check the instance configuration"""
|
|
239
239
|
r = db.fetchone(
|
|
240
240
|
current_app,
|
|
241
241
|
"""SELECT COALESCE(accounts.collaborative_metadata, configurations.collaborative_metadata, true) AS collaborative_metadata
|
|
@@ -249,15 +249,16 @@ WHERE accounts.id = %s""",
|
|
|
249
249
|
|
|
250
250
|
|
|
251
251
|
def login_required():
|
|
252
|
-
"""Check that the user is logged, and abort if it's not the case"""
|
|
252
|
+
"""Check that the user is logged in, and abort if it's not the case"""
|
|
253
253
|
|
|
254
254
|
def actual_decorator(f):
|
|
255
255
|
@wraps(f)
|
|
256
256
|
def decorator(*args, **kwargs):
|
|
257
|
-
account
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
257
|
+
if "account" not in kwargs:
|
|
258
|
+
account = get_current_account()
|
|
259
|
+
if not account:
|
|
260
|
+
return flask.abort(flask.make_response(flask.jsonify(message=_("Authentication is mandatory")), 401))
|
|
261
|
+
kwargs["account"] = account
|
|
261
262
|
|
|
262
263
|
return f(*args, **kwargs)
|
|
263
264
|
|
|
@@ -267,7 +268,7 @@ def login_required():
|
|
|
267
268
|
|
|
268
269
|
|
|
269
270
|
def login_required_by_setting(mandatory_login_param):
|
|
270
|
-
"""Check that the user is logged, and abort if it's not the case
|
|
271
|
+
"""Check that the user is logged in, and abort if it's not the case
|
|
271
272
|
|
|
272
273
|
Args:
|
|
273
274
|
mandatory_login_param (str): name of the configuration parameter used to decide if the login is mandatory or not
|
|
@@ -303,7 +304,7 @@ def login_required_by_setting(mandatory_login_param):
|
|
|
303
304
|
|
|
304
305
|
|
|
305
306
|
def login_required_with_redirect():
|
|
306
|
-
"""Check that the user is logged, and redirect if it's not the case"""
|
|
307
|
+
"""Check that the user is logged in, and redirect if it's not the case"""
|
|
307
308
|
|
|
308
309
|
def actual_decorator(f):
|
|
309
310
|
@wraps(f)
|
|
@@ -346,7 +347,7 @@ class UnknowAccountException(Exception):
|
|
|
346
347
|
status_code = 401
|
|
347
348
|
|
|
348
349
|
def __init__(self):
|
|
349
|
-
msg = "No account with this oauth id is
|
|
350
|
+
msg = "No account with this oauth id is known, you should login first"
|
|
350
351
|
super().__init__(msg)
|
|
351
352
|
|
|
352
353
|
|
|
@@ -358,12 +359,12 @@ class LoginRequiredException(Exception):
|
|
|
358
359
|
super().__init__(msg)
|
|
359
360
|
|
|
360
361
|
|
|
361
|
-
def get_current_account():
|
|
362
|
+
def get_current_account() -> Optional[Account]:
|
|
362
363
|
"""Get the authenticated account information.
|
|
363
364
|
|
|
364
365
|
This account is either stored in the flask's session or retrieved with the Bearer token passed with an `Authorization` header.
|
|
365
366
|
|
|
366
|
-
The flask session is usually used by browser, whereas the bearer token is
|
|
367
|
+
The flask session is usually used by browser, whereas the bearer token is handy for non interactive uses, like curls or CLI usage.
|
|
367
368
|
|
|
368
369
|
Returns:
|
|
369
370
|
Account: the current logged account, None if nobody is logged
|
geovisio/utils/cql2.py
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
from operator import not_
|
|
2
|
+
from lark import UnexpectedCharacters
|
|
3
|
+
from geovisio import errors
|
|
4
|
+
import logging
|
|
5
|
+
from flask_babel import gettext as _
|
|
6
|
+
from psycopg import sql
|
|
7
|
+
from pygeofilter import ast
|
|
8
|
+
from pygeofilter.backends.sql import to_sql_where
|
|
9
|
+
from pygeofilter.backends.evaluator import Evaluator, handle
|
|
10
|
+
from pygeofilter.parsers.ecql import parse as ecql_parser
|
|
11
|
+
from typing import Dict, Optional
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def parse_cql2_filter(value: Optional[str], field_mapping: Dict[str, str], *, ast_updater=None) -> Optional[sql.SQL]:
|
|
15
|
+
"""Reads CQL2 filter parameter and sends SQL condition back.
|
|
16
|
+
|
|
17
|
+
Need a mapping from API field name to database field name.
|
|
18
|
+
|
|
19
|
+
And if needed, can take a function to update the AST before sending it to SQL.
|
|
20
|
+
"""
|
|
21
|
+
if not value:
|
|
22
|
+
return None
|
|
23
|
+
try:
|
|
24
|
+
filterAst = ecql_parser(value)
|
|
25
|
+
|
|
26
|
+
if ast_updater:
|
|
27
|
+
filterAst = ast_updater(filterAst)
|
|
28
|
+
|
|
29
|
+
f = to_sql_where(filterAst, field_mapping).replace('"', "").replace("'me'", "%(account_id)s")
|
|
30
|
+
return sql.SQL(f) # type: ignore
|
|
31
|
+
except:
|
|
32
|
+
logging.error(f"Unsupported filter parameter: {value}")
|
|
33
|
+
raise errors.InvalidAPIUsage(_("Unsupported filter parameter"), status_code=400)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
SEMANTIC_FIELD_MAPPOING = {"key": "key", "value": "value"}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def parse_semantic_filter(value: Optional[str]) -> Optional[sql.SQL]:
|
|
40
|
+
"""Transform the semantic only filters to SQL
|
|
41
|
+
|
|
42
|
+
>>> parse_semantic_filter("semantics.pouet='stop'")
|
|
43
|
+
SQL("((key = 'pouet') AND (value = 'stop'))")
|
|
44
|
+
>>> parse_semantic_filter("\\"semantics.osm|traffic_sign\\"='stop'")
|
|
45
|
+
SQL("((key = 'osm|traffic_sign') AND (value = 'stop'))")
|
|
46
|
+
"""
|
|
47
|
+
return parse_cql2_filter(value, SEMANTIC_FIELD_MAPPOING, ast_updater=lambda a: SemanticAttributesAstUpdater().evaluate(a))
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def parse_search_filter(value: Optional[str]) -> Optional[sql.SQL]:
|
|
51
|
+
"""Transform STAC CQL2 filter on /search endpoint to SQL
|
|
52
|
+
|
|
53
|
+
Note that, for the moment, only semantics are supported. If more needs to be supported, we should evaluate the
|
|
54
|
+
non semantic filters separately (likely with a AstEvaluator).
|
|
55
|
+
"""
|
|
56
|
+
s = parse_semantic_filter(value)
|
|
57
|
+
|
|
58
|
+
if s is None:
|
|
59
|
+
return None
|
|
60
|
+
|
|
61
|
+
return sql.SQL(
|
|
62
|
+
"""(p.id in (
|
|
63
|
+
SELECT picture_id
|
|
64
|
+
FROM pictures_semantics
|
|
65
|
+
WHERE {semantic_filter}
|
|
66
|
+
UNION
|
|
67
|
+
SELECT DISTINCT(picture_id)
|
|
68
|
+
FROM annotations_semantics ans
|
|
69
|
+
JOIN annotations a ON a.id = ans.annotation_id
|
|
70
|
+
WHERE {semantic_filter}
|
|
71
|
+
UNION
|
|
72
|
+
SELECT sp.pic_id
|
|
73
|
+
FROM sequences_pictures sp
|
|
74
|
+
JOIN sequences_semantics sm ON sp.seq_id = sm.sequence_id
|
|
75
|
+
WHERE {semantic_filter}
|
|
76
|
+
LIMIT %(limit)s
|
|
77
|
+
))"""
|
|
78
|
+
).format(semantic_filter=s)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def get_semantic_attribute(node):
|
|
82
|
+
if isinstance(node, ast.Attribute) and node.name.startswith("semantics."):
|
|
83
|
+
return node.name[10:]
|
|
84
|
+
return None
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class SemanticAttributesAstUpdater(Evaluator):
|
|
88
|
+
"""
|
|
89
|
+
We alter the AST to handle semantic attributes.
|
|
90
|
+
|
|
91
|
+
So
|
|
92
|
+
* `semantics.some_tag='some_value'` becomes `(key = 'some_tag' AND value = 'some_value')`
|
|
93
|
+
* `semantics.some_tag IN ('some_value', 'some_other_value')` becomes `(key = 'some_tag' AND value IN ('some_value', 'some_other_value'))`
|
|
94
|
+
"""
|
|
95
|
+
|
|
96
|
+
@handle(ast.Equal)
|
|
97
|
+
def eq(self, node, lhs, rhs):
|
|
98
|
+
semantic_attribute = get_semantic_attribute(lhs)
|
|
99
|
+
if semantic_attribute is None:
|
|
100
|
+
return node
|
|
101
|
+
return ast.And(ast.Equal(ast.Attribute("key"), semantic_attribute), ast.Equal(ast.Attribute("value"), rhs))
|
|
102
|
+
|
|
103
|
+
@handle(ast.Or)
|
|
104
|
+
def or_(self, node, lhs, rhs):
|
|
105
|
+
return ast.Or(lhs, rhs)
|
|
106
|
+
|
|
107
|
+
@handle(ast.And)
|
|
108
|
+
def and_(self, node, lhs, rhs):
|
|
109
|
+
# uncomment this when we know how to handle `AND` in semantic filters
|
|
110
|
+
# return ast.And(lhs, rhs)
|
|
111
|
+
raise errors.InvalidAPIUsage("Unsupported filter parameter: AND semantic filters are not yet supported", status_code=400)
|
|
112
|
+
|
|
113
|
+
@handle(ast.IsNull)
|
|
114
|
+
def is_null(self, node, lhs):
|
|
115
|
+
if not node.not_:
|
|
116
|
+
raise errors.InvalidAPIUsage(
|
|
117
|
+
"Unsupported filter parameter: only `IS NOT NULL` is supported (to express that we want all values of a semantic tags)",
|
|
118
|
+
status_code=400,
|
|
119
|
+
)
|
|
120
|
+
semantic_attribute = get_semantic_attribute(lhs)
|
|
121
|
+
if semantic_attribute is None:
|
|
122
|
+
return node
|
|
123
|
+
return ast.Equal(ast.Attribute("key"), semantic_attribute)
|
|
124
|
+
|
|
125
|
+
@handle(ast.In)
|
|
126
|
+
def in_(self, node, lhs, *options):
|
|
127
|
+
semantic_attribute = get_semantic_attribute(lhs)
|
|
128
|
+
if semantic_attribute is None:
|
|
129
|
+
return node
|
|
130
|
+
|
|
131
|
+
return ast.And(ast.Equal(ast.Attribute("key"), semantic_attribute), ast.In(ast.Attribute("value"), node.sub_nodes, not_=False))
|
|
132
|
+
|
|
133
|
+
def adopt(self, node, *sub_args):
|
|
134
|
+
return node
|
geovisio/utils/db.py
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
from psycopg_pool import ConnectionPool
|
|
2
|
+
import psycopg
|
|
3
|
+
from psycopg.abc import Query
|
|
2
4
|
from contextlib import contextmanager
|
|
3
5
|
from typing import Optional
|
|
4
6
|
|
|
@@ -63,3 +65,8 @@ def long_queries_conn(app, connection_timeout: Optional[float] = None):
|
|
|
63
65
|
"""Get a psycopg connection for queries that are known to be long from the connection pool"""
|
|
64
66
|
with app.long_queries_pool.connection(timeout=connection_timeout) as conn:
|
|
65
67
|
yield conn
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def query_as_string(conn: psycopg.Connection, query: Query, params: dict):
|
|
71
|
+
"""Get query as string for debug purpose"""
|
|
72
|
+
return psycopg.ClientCursor(conn).mogrify(query, params)
|
geovisio/utils/fields.py
CHANGED
|
@@ -2,6 +2,8 @@ from enum import Enum
|
|
|
2
2
|
from dataclasses import dataclass, field
|
|
3
3
|
from typing import Any, List, Generic, TypeVar, Protocol
|
|
4
4
|
from psycopg import sql
|
|
5
|
+
from geovisio import errors
|
|
6
|
+
from gettext import gettext as _
|
|
5
7
|
|
|
6
8
|
|
|
7
9
|
@dataclass
|
|
@@ -12,8 +14,8 @@ class FieldMapping:
|
|
|
12
14
|
stac: str
|
|
13
15
|
|
|
14
16
|
@property
|
|
15
|
-
def sql_filter(self) -> sql.Composable:
|
|
16
|
-
return sql.SQL("
|
|
17
|
+
def sql_filter(self, row_alias="s.") -> sql.Composable:
|
|
18
|
+
return sql.SQL(row_alias + "{}").format(self.sql_column)
|
|
17
19
|
|
|
18
20
|
|
|
19
21
|
class SQLDirection(Enum):
|
|
@@ -38,6 +40,16 @@ class SortByField:
|
|
|
38
40
|
revert_dir = SQLDirection.ASC if self.direction == SQLDirection.DESC else SQLDirection.DESC
|
|
39
41
|
return sql.SQL("{column} {dir}").format(column=col, dir=revert_dir.value)
|
|
40
42
|
|
|
43
|
+
def as_non_aliased_sql(self) -> sql.Composable:
|
|
44
|
+
return sql.SQL("{column} {dir}").format(column=self.field.sql_column, dir=self.direction.value)
|
|
45
|
+
|
|
46
|
+
def revert_non_aliased_sql(self) -> sql.Composable:
|
|
47
|
+
revert_dir = SQLDirection.ASC if self.direction == SQLDirection.DESC else SQLDirection.DESC
|
|
48
|
+
return sql.SQL("{column} {dir}").format(column=self.field.sql_column, dir=revert_dir.value)
|
|
49
|
+
|
|
50
|
+
def as_stac(self) -> str:
|
|
51
|
+
return f"{'+' if self.direction == SQLDirection.ASC else '-'}{self.field.stac}"
|
|
52
|
+
|
|
41
53
|
|
|
42
54
|
@dataclass
|
|
43
55
|
class SortBy:
|
|
@@ -49,6 +61,18 @@ class SortBy:
|
|
|
49
61
|
def revert(self) -> sql.Composable:
|
|
50
62
|
return sql.SQL(", ").join([f.revert() for f in self.fields])
|
|
51
63
|
|
|
64
|
+
def as_non_aliased_sql(self) -> sql.Composable:
|
|
65
|
+
return sql.SQL(", ").join([f.as_non_aliased_sql() for f in self.fields])
|
|
66
|
+
|
|
67
|
+
def revert_non_aliased_sql(self) -> sql.Composable:
|
|
68
|
+
return sql.SQL(", ").join([f.revert_non_aliased_sql() for f in self.fields])
|
|
69
|
+
|
|
70
|
+
def as_stac(self) -> str:
|
|
71
|
+
return ",".join([f.as_stac() for f in self.fields])
|
|
72
|
+
|
|
73
|
+
def get_field_index(self, stac_name: str) -> int:
|
|
74
|
+
return next((i for i, f in enumerate(self.fields) if f.field.stac == stac_name))
|
|
75
|
+
|
|
52
76
|
|
|
53
77
|
class Comparable(Protocol):
|
|
54
78
|
"""Protocol for annotating comparable types."""
|
|
@@ -63,13 +87,8 @@ T = TypeVar("T", bound=Comparable)
|
|
|
63
87
|
class Bounds(Generic[T]):
|
|
64
88
|
"""Represent some bounds (min and max) over a generic type"""
|
|
65
89
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
def update(self, val: T):
|
|
70
|
-
"""Update the bounds if val is out of them"""
|
|
71
|
-
self.min = min(self.min, val)
|
|
72
|
-
self.max = max(self.max, val)
|
|
90
|
+
first: T
|
|
91
|
+
last: T
|
|
73
92
|
|
|
74
93
|
|
|
75
94
|
@dataclass
|
|
@@ -80,3 +99,13 @@ class BBox:
|
|
|
80
99
|
maxx: float
|
|
81
100
|
miny: float
|
|
82
101
|
maxy: float
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def parse_relative_heading(value: str) -> int:
|
|
105
|
+
try:
|
|
106
|
+
relHeading = int(value)
|
|
107
|
+
if relHeading < -180 or relHeading > 180:
|
|
108
|
+
raise ValueError()
|
|
109
|
+
return relHeading
|
|
110
|
+
except (ValueError, TypeError):
|
|
111
|
+
raise errors.InvalidAPIUsage(_("Relative heading is not valid, should be an integer in degrees from -180 to 180"), status_code=400)
|
geovisio/utils/items.py
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
from .fields import SQLDirection
|
|
2
|
+
from psycopg.sql import SQL, Identifier
|
|
3
|
+
from enum import Enum
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from typing import Optional, List
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class SortableItemField(Enum):
|
|
9
|
+
ts = Identifier("ts")
|
|
10
|
+
updated = Identifier("updated_at")
|
|
11
|
+
distance_to = ""
|
|
12
|
+
id = Identifier("id")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass
|
|
16
|
+
class ItemSortByField:
|
|
17
|
+
field: SortableItemField
|
|
18
|
+
direction: SQLDirection
|
|
19
|
+
|
|
20
|
+
# Note that this obj_to_compare is only used for the `distance_to` field, but we cannot put it in the enum
|
|
21
|
+
obj_to_compare: Optional[SQL] = None
|
|
22
|
+
|
|
23
|
+
def to_sql(self, alias) -> SQL:
|
|
24
|
+
sql_order = None
|
|
25
|
+
if self.obj_to_compare:
|
|
26
|
+
if self.field == SortableItemField.distance_to:
|
|
27
|
+
sql_order = SQL('{alias}."geom" <-> {obj_to_compare} {direction}').format(
|
|
28
|
+
alias=alias, obj_to_compare=self.obj_to_compare, direction=self.direction.value
|
|
29
|
+
)
|
|
30
|
+
else:
|
|
31
|
+
raise InvalidAPIUsage("For the moment only the distance comparison to another item is supported")
|
|
32
|
+
else:
|
|
33
|
+
sql_order = SQL("{alias}.{field} {direction}").format(alias=alias, field=self.field.value, direction=self.direction.value)
|
|
34
|
+
return sql_order
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass
|
|
38
|
+
class SortBy:
|
|
39
|
+
fields: List[ItemSortByField] = field(default_factory=lambda: [])
|
|
40
|
+
|
|
41
|
+
def to_sql(self, alias=Identifier("p")) -> SQL:
|
|
42
|
+
if len(self.fields) == 0:
|
|
43
|
+
return SQL("")
|
|
44
|
+
return SQL("ORDER BY {fields}").format(fields=SQL(", ").join([f.to_sql(alias=alias) for f in self.fields]))
|
geovisio/utils/model_query.py
CHANGED
|
@@ -9,11 +9,11 @@ class ParamsAndValues:
|
|
|
9
9
|
|
|
10
10
|
params_as_dict: Dict[str, Any]
|
|
11
11
|
|
|
12
|
-
def __init__(self, model: BaseModel, **kwargs):
|
|
12
|
+
def __init__(self, model: BaseModel, jsonb_fields=set(), **kwargs):
|
|
13
13
|
self.params_as_dict = model.model_dump(exclude_none=True) | kwargs
|
|
14
14
|
|
|
15
15
|
for k, v in self.params_as_dict.items():
|
|
16
|
-
if isinstance(v, Dict):
|
|
16
|
+
if isinstance(v, Dict) or k in jsonb_fields:
|
|
17
17
|
self.params_as_dict[k] = Jsonb(v) # convert dict to jsonb in database
|
|
18
18
|
|
|
19
19
|
def has_updates(self):
|
|
@@ -28,7 +28,7 @@ class ParamsAndValues:
|
|
|
28
28
|
return SQL(", ").join([Placeholder(f) for f in self.params_as_dict.keys()])
|
|
29
29
|
|
|
30
30
|
def fields_for_set(self) -> Composed:
|
|
31
|
-
"""Get the fields and the placeholders
|
|
31
|
+
"""Get the fields and the placeholders formatted for an update query like:
|
|
32
32
|
'"a" = %(a)s, "b" = %(b)s'
|
|
33
33
|
|
|
34
34
|
Can be used directly with a query like:
|
|
@@ -39,7 +39,7 @@ class ParamsAndValues:
|
|
|
39
39
|
return SQL(", ").join(self.fields_for_set_list())
|
|
40
40
|
|
|
41
41
|
def fields_for_set_list(self) -> List[Composed]:
|
|
42
|
-
"""Get the fields and the placeholders
|
|
42
|
+
"""Get the fields and the placeholders formatted for an update query like:
|
|
43
43
|
['"a" = %(a)s', '"b" = %(b)s']
|
|
44
44
|
|
|
45
45
|
Note that the returned list should be joined with SQL(", ").join()
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
from typing import Annotated, Any, Iterator, List, Literal, NamedTuple, Tuple
|
|
2
|
+
from pydantic import BaseModel, Discriminator, Field, Tag, field_validator
|
|
3
|
+
|
|
4
|
+
BBox = Annotated[List[int], Field(min_length=4, max_length=4)]
|
|
5
|
+
|
|
6
|
+
Position = NamedTuple("Position", [("x", int), ("y", int)])
|
|
7
|
+
|
|
8
|
+
LinearRing = Annotated[List[Position], Field(min_length=4)]
|
|
9
|
+
PolygonCoords = List[LinearRing]
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class Polygon(BaseModel):
|
|
13
|
+
type: Literal["Polygon"]
|
|
14
|
+
coordinates: PolygonCoords
|
|
15
|
+
|
|
16
|
+
@field_validator("coordinates")
|
|
17
|
+
def check_closure(cls, coordinates: List) -> List:
|
|
18
|
+
"""Validate that Polygon is closed (first and last coordinates are the same)."""
|
|
19
|
+
if any(ring[-1] != ring[0] for ring in coordinates):
|
|
20
|
+
raise ValueError("All linear rings have the same start and end coordinates")
|
|
21
|
+
|
|
22
|
+
return coordinates
|
|
23
|
+
|
|
24
|
+
def coords_iter(self) -> Iterator[Tuple[int, int]]:
|
|
25
|
+
for ring in self.coordinates:
|
|
26
|
+
yield from ring
|
|
27
|
+
|
|
28
|
+
@classmethod
|
|
29
|
+
def from_bounds(cls, xmin: float, ymin: float, xmax: float, ymax: float) -> "Polygon":
|
|
30
|
+
"""Create a Polygon geometry from a boundingbox."""
|
|
31
|
+
return cls(
|
|
32
|
+
type="Polygon",
|
|
33
|
+
coordinates=[[(xmin, ymin), (xmax, ymin), (xmax, ymax), (xmin, ymax), (xmin, ymin)]],
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
Geometry = Polygon # for the moment we support only polygons, but later we might add support for more, so the type is aliased
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def shape_discriminator(v: Any) -> str:
|
|
41
|
+
if isinstance(v, list):
|
|
42
|
+
return "bbox"
|
|
43
|
+
return "geometry"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
# Shapes can be provided as a bounding box or as a geometry
|
|
47
|
+
InputAnnotationShape = Annotated[(Annotated[Polygon, Tag("geometry")] | Annotated[BBox, Tag("bbox")]), Discriminator(shape_discriminator)]
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def get_coords_from_shape(shape: InputAnnotationShape) -> Iterator[Tuple[int, int]]:
|
|
51
|
+
"""Get an iterator to all coordinates of a shape"""
|
|
52
|
+
if shape_discriminator(shape) == "bbox":
|
|
53
|
+
yield shape[0], shape[1]
|
|
54
|
+
yield shape[2], shape[3]
|
|
55
|
+
else:
|
|
56
|
+
yield from shape.coords_iter()
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def shape_as_geometry(shape: InputAnnotationShape) -> Geometry:
|
|
60
|
+
"""If the shape has been provided as a bounding box, transform it to a polygon"""
|
|
61
|
+
if shape_discriminator(shape) == "bbox":
|
|
62
|
+
return Polygon.from_bounds(*shape)
|
|
63
|
+
return shape
|