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
|
@@ -0,0 +1,186 @@
|
|
|
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) -> Annotation:
|
|
66
|
+
"""Create an annotation in the database"""
|
|
67
|
+
|
|
68
|
+
model = model_query.get_db_params_and_values(
|
|
69
|
+
AnnotationCreationRow(picture_id=params.picture_id, shape=params.shape_as_geometry()), jsonb_fields={"shape"}
|
|
70
|
+
)
|
|
71
|
+
insert_query = SQL(
|
|
72
|
+
"""WITH existing_annotations AS (
|
|
73
|
+
SELECT * FROM annotations WHERE picture_id = %(picture_id)s AND shape = %(shape)s
|
|
74
|
+
)
|
|
75
|
+
, new_ones AS (
|
|
76
|
+
INSERT INTO annotations (picture_id, shape)
|
|
77
|
+
SELECT %(picture_id)s, %(shape)s
|
|
78
|
+
WHERE NOT EXISTS (SELECT FROM existing_annotations)
|
|
79
|
+
RETURNING *
|
|
80
|
+
)
|
|
81
|
+
SELECT * FROM existing_annotations UNION ALL SELECT * FROM new_ones
|
|
82
|
+
;"""
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
with db.conn(current_app) as conn, conn.transaction(), conn.cursor(row_factory=class_row(Annotation)) as cursor:
|
|
86
|
+
# we check that the shape is valid
|
|
87
|
+
check_shape(conn, params)
|
|
88
|
+
|
|
89
|
+
annotation = cursor.execute(insert_query, model.params_as_dict).fetchone()
|
|
90
|
+
|
|
91
|
+
if annotation is None:
|
|
92
|
+
raise Exception("Impossible to insert annotation in database")
|
|
93
|
+
|
|
94
|
+
semantics.update_tags(
|
|
95
|
+
cursor=cursor,
|
|
96
|
+
entity=semantics.Entity(semantics.EntityType.annotation, annotation.id),
|
|
97
|
+
actions=params.semantics,
|
|
98
|
+
account=params.account_id,
|
|
99
|
+
annotation=annotation,
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
return get_annotation(conn, annotation.id)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def check_shape(conn: psycopg.Connection, params: AnnotationCreationParameter):
|
|
106
|
+
"""Check that the shape is valid"""
|
|
107
|
+
with conn.cursor(row_factory=dict_row) as cursor:
|
|
108
|
+
picture_size = cursor.execute(
|
|
109
|
+
SQL("SELECT (metadata->>'width')::int AS width, (metadata->>'height')::int AS height FROM pictures WHERE id = %(pic)s"),
|
|
110
|
+
{"pic": params.picture_id},
|
|
111
|
+
).fetchone()
|
|
112
|
+
|
|
113
|
+
for x, y in get_coords_from_shape(params.shape):
|
|
114
|
+
if x < 0 or x > picture_size["width"] or y < 0 or y > picture_size["height"]:
|
|
115
|
+
raise errors.InvalidAPIUsage(
|
|
116
|
+
message="Annotation shape is outside the range of the picture",
|
|
117
|
+
payload={
|
|
118
|
+
"details": f"Annotation shape's coordinates should be in pixel, between [0, 0] and [{picture_size['width']}, {picture_size['height']}]",
|
|
119
|
+
"value": {"x": x, "y": y},
|
|
120
|
+
},
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def get_annotation(conn: psycopg.Connection, id: UUID) -> Optional[Annotation]:
|
|
125
|
+
"""Get an annotation in the database"""
|
|
126
|
+
with conn.cursor(row_factory=class_row(Annotation)) as cursor:
|
|
127
|
+
return cursor.execute(
|
|
128
|
+
SQL(
|
|
129
|
+
"""SELECT
|
|
130
|
+
id,
|
|
131
|
+
shape,
|
|
132
|
+
picture_id,
|
|
133
|
+
t.semantics
|
|
134
|
+
FROM annotations a
|
|
135
|
+
LEFT JOIN (
|
|
136
|
+
SELECT annotation_id, json_agg(json_strip_nulls(json_build_object(
|
|
137
|
+
'key', key,
|
|
138
|
+
'value', value
|
|
139
|
+
)) ORDER BY key, value) AS semantics
|
|
140
|
+
FROM annotations_semantics
|
|
141
|
+
GROUP BY annotation_id
|
|
142
|
+
) t ON t.annotation_id = a.id
|
|
143
|
+
WHERE a.id = %(id)s"""
|
|
144
|
+
),
|
|
145
|
+
{"id": id},
|
|
146
|
+
).fetchone()
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def get_picture_annotations(conn: psycopg.Connection, picture_id: UUID) -> List[Annotation]:
|
|
150
|
+
"""Get all annotations linked to a picture"""
|
|
151
|
+
with conn.cursor() as cursor:
|
|
152
|
+
json_annotations = cursor.execute(
|
|
153
|
+
SQL("SELECT get_picture_annotations(p.id) FROM pictures p WHERE p.id = %(pic)s"),
|
|
154
|
+
{"pic": picture_id},
|
|
155
|
+
).fetchone()
|
|
156
|
+
if not json_annotations or not json_annotations[0]:
|
|
157
|
+
return []
|
|
158
|
+
return [Annotation(**a, picture_id=picture_id) for a in json_annotations[0]]
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def update_annotation(annotation: Annotation, tag_updates: List[SemanticTagUpdate], account_id: UUID) -> Optional[Annotation]:
|
|
162
|
+
"""update an annotation in the database.
|
|
163
|
+
If the annotation has no semantic tags anymore after the update, it will be deleted
|
|
164
|
+
"""
|
|
165
|
+
with db.conn(current_app) as conn, conn.transaction(), conn.cursor(row_factory=class_row(Annotation)) as cursor:
|
|
166
|
+
|
|
167
|
+
semantics.update_tags(
|
|
168
|
+
cursor=cursor,
|
|
169
|
+
entity=semantics.Entity(semantics.EntityType.annotation, annotation.id),
|
|
170
|
+
actions=tag_updates,
|
|
171
|
+
account=account_id,
|
|
172
|
+
annotation=annotation,
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
a = get_annotation(conn, annotation.id)
|
|
176
|
+
|
|
177
|
+
if a and len(a.semantics) == 0:
|
|
178
|
+
# the annotation will be deleted by a trigger if its empty
|
|
179
|
+
return None
|
|
180
|
+
return a
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def delete_annotation(conn: psycopg.Connection, annotation_id: UUID) -> None:
|
|
184
|
+
"""Delete an annotation from the database"""
|
|
185
|
+
with conn.cursor() as cursor:
|
|
186
|
+
cursor.execute("DELETE FROM annotations WHERE id = %(id)s", {"id": annotation_id})
|
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
|
@@ -38,6 +38,16 @@ class SortByField:
|
|
|
38
38
|
revert_dir = SQLDirection.ASC if self.direction == SQLDirection.DESC else SQLDirection.DESC
|
|
39
39
|
return sql.SQL("{column} {dir}").format(column=col, dir=revert_dir.value)
|
|
40
40
|
|
|
41
|
+
def as_non_aliased_sql(self) -> sql.Composable:
|
|
42
|
+
return sql.SQL("{column} {dir}").format(column=self.field.sql_column, dir=self.direction.value)
|
|
43
|
+
|
|
44
|
+
def revert_non_aliased_sql(self) -> sql.Composable:
|
|
45
|
+
revert_dir = SQLDirection.ASC if self.direction == SQLDirection.DESC else SQLDirection.DESC
|
|
46
|
+
return sql.SQL("{column} {dir}").format(column=self.field.sql_column, dir=revert_dir.value)
|
|
47
|
+
|
|
48
|
+
def as_stac(self) -> str:
|
|
49
|
+
return f"{'+' if self.direction == SQLDirection.ASC else '-'}{self.field.stac}"
|
|
50
|
+
|
|
41
51
|
|
|
42
52
|
@dataclass
|
|
43
53
|
class SortBy:
|
|
@@ -49,6 +59,18 @@ class SortBy:
|
|
|
49
59
|
def revert(self) -> sql.Composable:
|
|
50
60
|
return sql.SQL(", ").join([f.revert() for f in self.fields])
|
|
51
61
|
|
|
62
|
+
def as_non_aliased_sql(self) -> sql.Composable:
|
|
63
|
+
return sql.SQL(", ").join([f.as_non_aliased_sql() for f in self.fields])
|
|
64
|
+
|
|
65
|
+
def revert_non_aliased_sql(self) -> sql.Composable:
|
|
66
|
+
return sql.SQL(", ").join([f.revert_non_aliased_sql() for f in self.fields])
|
|
67
|
+
|
|
68
|
+
def as_stac(self) -> str:
|
|
69
|
+
return ",".join([f.as_stac() for f in self.fields])
|
|
70
|
+
|
|
71
|
+
def get_field_index(self, stac_name: str) -> int:
|
|
72
|
+
return next((i for i, f in enumerate(self.fields) if f.field.stac == stac_name))
|
|
73
|
+
|
|
52
74
|
|
|
53
75
|
class Comparable(Protocol):
|
|
54
76
|
"""Protocol for annotating comparable types."""
|
|
@@ -63,13 +85,8 @@ T = TypeVar("T", bound=Comparable)
|
|
|
63
85
|
class Bounds(Generic[T]):
|
|
64
86
|
"""Represent some bounds (min and max) over a generic type"""
|
|
65
87
|
|
|
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)
|
|
88
|
+
first: T
|
|
89
|
+
last: T
|
|
73
90
|
|
|
74
91
|
|
|
75
92
|
@dataclass
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class LoggingWithExtra(logging.LoggerAdapter):
|
|
5
|
+
"""Add some metadata to the log message"""
|
|
6
|
+
|
|
7
|
+
def process(self, msg, kwargs):
|
|
8
|
+
sep = " " if self.extra else ""
|
|
9
|
+
return f"{self.extra if self.extra else ''}{sep}{msg}", kwargs
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def getLoggerWithExtra(logger_name, extra):
|
|
13
|
+
"""Create a logger with extra information. Those information will be displayed in the log message"""
|
|
14
|
+
return LoggingWithExtra(logging.getLogger(logger_name), extra)
|
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):
|
geovisio/utils/params.py
CHANGED
|
@@ -12,9 +12,12 @@ def validation_error(e: ValidationError):
|
|
|
12
12
|
}
|
|
13
13
|
if d["input"]:
|
|
14
14
|
detail["input"] = d["input"]
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
15
|
+
try:
|
|
16
|
+
if "user_agent" in detail["input"]:
|
|
17
|
+
del detail["input"]["user_agent"]
|
|
18
|
+
if len(detail["input"]) == 0:
|
|
19
|
+
del detail["input"]
|
|
20
|
+
except TypeError:
|
|
21
|
+
pass
|
|
19
22
|
details.append(detail)
|
|
20
23
|
return {"details": details}
|
|
@@ -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 coordinate 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
|
geovisio/utils/pictures.py
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
|
+
import json
|
|
1
2
|
import math
|
|
2
3
|
from typing import Dict, Optional
|
|
3
4
|
from uuid import UUID
|
|
5
|
+
from attr import dataclass
|
|
4
6
|
from flask import current_app, redirect, send_file
|
|
5
7
|
from flask_babel import gettext as _
|
|
6
8
|
import os
|
|
@@ -13,14 +15,24 @@ import logging
|
|
|
13
15
|
from dataclasses import asdict
|
|
14
16
|
from fs.path import dirname
|
|
15
17
|
from psycopg.errors import UniqueViolation, InvalidParameterValue
|
|
18
|
+
import sentry_sdk
|
|
16
19
|
from geovisio import utils, errors
|
|
17
20
|
from geopic_tag_reader import reader
|
|
18
21
|
import re
|
|
22
|
+
import multipart
|
|
19
23
|
|
|
20
24
|
log = logging.getLogger(__name__)
|
|
21
25
|
|
|
22
26
|
|
|
23
|
-
|
|
27
|
+
@dataclass
|
|
28
|
+
class BlurredPicture:
|
|
29
|
+
"""Blurred picture's response"""
|
|
30
|
+
|
|
31
|
+
image: Image
|
|
32
|
+
metadata: Dict[str, str] = {}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def createBlurredHDPicture(fs, blurApi, pictureBytes, outputFilename, keep_unblured_parts=False) -> Optional[BlurredPicture]:
|
|
24
36
|
"""Create the blurred version of a picture using a blurMask
|
|
25
37
|
|
|
26
38
|
Parameters
|
|
@@ -40,19 +52,49 @@ def createBlurredHDPicture(fs, blurApi, pictureBytes, outputFilename):
|
|
|
40
52
|
The blurred version of the image
|
|
41
53
|
"""
|
|
42
54
|
|
|
43
|
-
if blurApi is
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
55
|
+
if blurApi is None:
|
|
56
|
+
return None
|
|
57
|
+
# Call blur API, asking for multipart response if available
|
|
58
|
+
pictureBytes.seek(0)
|
|
59
|
+
query_params = {"keep": 1} if keep_unblured_parts else {}
|
|
60
|
+
blurResponse = requests.post(
|
|
61
|
+
f"{blurApi}/blur/",
|
|
62
|
+
files={"picture": ("picture.jpg", pictureBytes.read(), "image/jpeg")},
|
|
63
|
+
headers={"Accept": "multipart/form-data"},
|
|
64
|
+
params=query_params,
|
|
65
|
+
)
|
|
66
|
+
blurResponse.raise_for_status()
|
|
67
|
+
|
|
68
|
+
metadata, blurred_pic = None, None
|
|
69
|
+
content_type, content_type_params = multipart.parse_options_header(blurResponse.headers.get("content-type", ""))
|
|
70
|
+
if content_type == "multipart/form-data":
|
|
71
|
+
# New blurring api can return multipart response, with separated blurring picture/metadata
|
|
72
|
+
multipart_response = multipart.MultipartParser(io.BytesIO(blurResponse.content), boundary=content_type_params["boundary"])
|
|
73
|
+
|
|
74
|
+
metadata = multipart_response.get("metadata")
|
|
75
|
+
if metadata:
|
|
76
|
+
metadata = metadata.raw
|
|
77
|
+
blurred_pic = multipart_response.get("image")
|
|
78
|
+
if blurred_pic:
|
|
79
|
+
blurred_pic = blurred_pic.raw
|
|
80
|
+
else:
|
|
81
|
+
# old blurring API, no multipart response, we read the `x-sgblur` header
|
|
82
|
+
blurred_pic = blurResponse.content
|
|
83
|
+
metadata = blurResponse.headers.get("x-sgblur")
|
|
51
84
|
|
|
52
|
-
|
|
85
|
+
# Save mask to FS
|
|
86
|
+
fs.writebytes(outputFilename, blurred_pic)
|
|
53
87
|
|
|
54
|
-
|
|
55
|
-
|
|
88
|
+
if metadata:
|
|
89
|
+
try:
|
|
90
|
+
metadata = json.loads(metadata)
|
|
91
|
+
except (json.decoder.JSONDecodeError, TypeError) as e:
|
|
92
|
+
# we skip the metadata's response if we are not able to understand it
|
|
93
|
+
log.warning(f"Impossible to parse blurring metadata API response: {e}")
|
|
94
|
+
sentry_sdk.capture_exception(e)
|
|
95
|
+
metadata = None
|
|
96
|
+
|
|
97
|
+
return BlurredPicture(image=Image.open(io.BytesIO(blurred_pic)), metadata=metadata)
|
|
56
98
|
|
|
57
99
|
|
|
58
100
|
def getTileSize(imgSize):
|
geovisio/utils/reports.py
CHANGED
|
@@ -4,7 +4,7 @@ from typing import Optional, List
|
|
|
4
4
|
from typing_extensions import Self
|
|
5
5
|
from datetime import datetime
|
|
6
6
|
from pydantic import BaseModel, ConfigDict
|
|
7
|
-
from geovisio.utils import db
|
|
7
|
+
from geovisio.utils import cql2, db
|
|
8
8
|
from geovisio.errors import InvalidAPIUsage
|
|
9
9
|
from flask import current_app
|
|
10
10
|
from psycopg.sql import SQL
|
|
@@ -103,6 +103,13 @@ def is_picture_owner(report: Report, account_id: UUID):
|
|
|
103
103
|
return isOwner
|
|
104
104
|
|
|
105
105
|
|
|
106
|
+
REPORT_FILTER_TO_DB_FIELDS = {
|
|
107
|
+
"status": "r.status",
|
|
108
|
+
"reporter": "reporter_account_id",
|
|
109
|
+
"owner": "COALESCE(p.account_id, s.account_id)",
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
|
|
106
113
|
def _parse_filter(filter: Optional[str]) -> SQL:
|
|
107
114
|
"""
|
|
108
115
|
Parse a filter string and return a SQL expression
|
|
@@ -124,22 +131,8 @@ def _parse_filter(filter: Optional[str]) -> SQL:
|
|
|
124
131
|
"""
|
|
125
132
|
if not filter:
|
|
126
133
|
return SQL("TRUE")
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
try:
|
|
131
|
-
filterAst = ecql_parser(filter)
|
|
132
|
-
fieldsToFilter = {
|
|
133
|
-
"status": "r.status",
|
|
134
|
-
"reporter": "reporter_account_id",
|
|
135
|
-
"owner": "COALESCE(p.account_id, s.account_id)",
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
f = to_sql_where(filterAst, fieldsToFilter).replace('"', "").replace("'me'", "%(account_id)s") # type: ignore
|
|
139
|
-
return SQL(f) # type: ignore
|
|
140
|
-
except Exception as e:
|
|
141
|
-
print(e)
|
|
142
|
-
raise InvalidAPIUsage(_("Unsupported filter parameter"), status_code=400)
|
|
134
|
+
|
|
135
|
+
return cql2.parse_cql2_filter(filter, REPORT_FILTER_TO_DB_FIELDS)
|
|
143
136
|
|
|
144
137
|
|
|
145
138
|
def list_reports(account_id: UUID, limit: int = 100, filter: Optional[str] = None, forceAccount: bool = True) -> Reports:
|