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
@@ -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
- min: T
67
- max: T
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)
@@ -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
- if "user_agent" in detail["input"]:
16
- del detail["input"]["user_agent"]
17
- if len(detail["input"]) == 0:
18
- del detail["input"]
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
@@ -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
- def createBlurredHDPicture(fs, blurApi, pictureBytes, outputFilename):
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 not None:
44
- # Call blur API
45
- pictureBytes.seek(0)
46
- blurResponse = requests.post(blurApi + "/blur/", files={"picture": ("picture.jpg", pictureBytes.read(), "image/jpeg")})
47
- blurResponse.raise_for_status()
48
-
49
- # Save mask to FS
50
- fs.writebytes(outputFilename, blurResponse.content)
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
- return Image.open(io.BytesIO(blurResponse.content))
85
+ # Save mask to FS
86
+ fs.writebytes(outputFilename, blurred_pic)
53
87
 
54
- else:
55
- return None
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
- from pygeofilter.backends.sql import to_sql_where
128
- from pygeofilter.parsers.ecql import parse as ecql_parser
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: