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.
Files changed (70) hide show
  1. geovisio/__init__.py +6 -1
  2. geovisio/config_app.py +16 -5
  3. geovisio/translations/ar/LC_MESSAGES/messages.mo +0 -0
  4. geovisio/translations/ar/LC_MESSAGES/messages.po +818 -0
  5. geovisio/translations/br/LC_MESSAGES/messages.po +1 -1
  6. geovisio/translations/da/LC_MESSAGES/messages.mo +0 -0
  7. geovisio/translations/da/LC_MESSAGES/messages.po +4 -3
  8. geovisio/translations/de/LC_MESSAGES/messages.mo +0 -0
  9. geovisio/translations/de/LC_MESSAGES/messages.po +55 -2
  10. geovisio/translations/el/LC_MESSAGES/messages.po +1 -1
  11. geovisio/translations/en/LC_MESSAGES/messages.mo +0 -0
  12. geovisio/translations/en/LC_MESSAGES/messages.po +193 -139
  13. geovisio/translations/eo/LC_MESSAGES/messages.mo +0 -0
  14. geovisio/translations/eo/LC_MESSAGES/messages.po +53 -4
  15. geovisio/translations/es/LC_MESSAGES/messages.po +1 -1
  16. geovisio/translations/fi/LC_MESSAGES/messages.po +1 -1
  17. geovisio/translations/fr/LC_MESSAGES/messages.mo +0 -0
  18. geovisio/translations/fr/LC_MESSAGES/messages.po +101 -6
  19. geovisio/translations/hu/LC_MESSAGES/messages.po +1 -1
  20. geovisio/translations/it/LC_MESSAGES/messages.mo +0 -0
  21. geovisio/translations/it/LC_MESSAGES/messages.po +63 -3
  22. geovisio/translations/ja/LC_MESSAGES/messages.po +1 -1
  23. geovisio/translations/ko/LC_MESSAGES/messages.po +1 -1
  24. geovisio/translations/messages.pot +185 -129
  25. geovisio/translations/nl/LC_MESSAGES/messages.mo +0 -0
  26. geovisio/translations/nl/LC_MESSAGES/messages.po +421 -86
  27. geovisio/translations/oc/LC_MESSAGES/messages.mo +0 -0
  28. geovisio/translations/oc/LC_MESSAGES/messages.po +818 -0
  29. geovisio/translations/pl/LC_MESSAGES/messages.po +1 -1
  30. geovisio/translations/sv/LC_MESSAGES/messages.mo +0 -0
  31. geovisio/translations/sv/LC_MESSAGES/messages.po +823 -0
  32. geovisio/translations/ti/LC_MESSAGES/messages.mo +0 -0
  33. geovisio/translations/ti/LC_MESSAGES/messages.po +762 -0
  34. geovisio/translations/zh_Hant/LC_MESSAGES/messages.po +1 -1
  35. geovisio/utils/annotations.py +183 -0
  36. geovisio/utils/auth.py +14 -13
  37. geovisio/utils/cql2.py +134 -0
  38. geovisio/utils/db.py +7 -0
  39. geovisio/utils/fields.py +38 -9
  40. geovisio/utils/items.py +44 -0
  41. geovisio/utils/model_query.py +4 -4
  42. geovisio/utils/pic_shape.py +63 -0
  43. geovisio/utils/pictures.py +164 -29
  44. geovisio/utils/reports.py +10 -17
  45. geovisio/utils/semantics.py +196 -57
  46. geovisio/utils/sentry.py +1 -2
  47. geovisio/utils/sequences.py +191 -93
  48. geovisio/utils/tags.py +31 -0
  49. geovisio/utils/upload_set.py +287 -209
  50. geovisio/utils/website.py +1 -1
  51. geovisio/web/annotations.py +346 -9
  52. geovisio/web/auth.py +1 -1
  53. geovisio/web/collections.py +73 -54
  54. geovisio/web/configuration.py +26 -5
  55. geovisio/web/docs.py +143 -11
  56. geovisio/web/items.py +232 -155
  57. geovisio/web/map.py +25 -13
  58. geovisio/web/params.py +55 -52
  59. geovisio/web/pictures.py +34 -0
  60. geovisio/web/stac.py +19 -12
  61. geovisio/web/tokens.py +49 -1
  62. geovisio/web/upload_set.py +148 -37
  63. geovisio/web/users.py +4 -4
  64. geovisio/web/utils.py +2 -2
  65. geovisio/workers/runner_pictures.py +190 -24
  66. {geovisio-2.8.1.dist-info → geovisio-2.10.0.dist-info}/METADATA +27 -26
  67. geovisio-2.10.0.dist-info/RECORD +105 -0
  68. {geovisio-2.8.1.dist-info → geovisio-2.10.0.dist-info}/WHEEL +1 -1
  69. geovisio-2.8.1.dist-info/RECORD +0 -92
  70. {geovisio-2.8.1.dist-info → geovisio-2.10.0.dist-info}/licenses/LICENSE +0 -0
@@ -1,6 +1,5 @@
1
1
  from copy import deepcopy
2
2
  from dataclasses import dataclass
3
-
4
3
  import PIL
5
4
  from geovisio.utils import auth, model_query
6
5
  from psycopg.rows import class_row, dict_row
@@ -9,7 +8,7 @@ from flask import current_app, request, Blueprint, url_for
9
8
  from flask_babel import gettext as _, get_locale
10
9
  from geopic_tag_reader import sequence as geopic_sequence
11
10
  from geovisio.web.utils import accountIdOrDefault
12
- from psycopg.types.json import Jsonb
11
+ from geovisio.utils.fields import parse_relative_heading
13
12
  from geovisio.web.params import (
14
13
  as_latitude,
15
14
  as_longitude,
@@ -20,6 +19,8 @@ from geovisio.utils import db
20
19
  from geovisio import utils
21
20
  from geopic_tag_reader.writer import writePictureMetadata, PictureMetadata
22
21
  from geovisio.utils.params import validation_error
22
+ from geovisio.utils.semantics import SemanticTagUpdate
23
+ from geovisio.utils import semantics
23
24
  from geovisio import errors
24
25
  from pydantic import BaseModel, ConfigDict, ValidationError, Field, field_validator, model_validator
25
26
  from uuid import UUID
@@ -37,7 +38,7 @@ from geovisio.utils.upload_set import (
37
38
  import os
38
39
  import hashlib
39
40
  import sentry_sdk
40
- from typing import Optional, Any, Dict
41
+ from typing import Optional, Any, Dict, List
41
42
 
42
43
 
43
44
  bp = Blueprint("upload_set", __name__, url_prefix="/api")
@@ -52,74 +53,177 @@ class UploadSetCreationParameter(BaseModel):
52
53
  """Estimated number of items that will be sent to the UploadSet"""
53
54
  sort_method: Optional[geopic_sequence.SortMethod] = None
54
55
  """Strategy used for sorting your pictures. Either by filename or EXIF time, in ascending or descending order."""
56
+ no_split: Optional[bool] = None
57
+ """If True, all pictures of this upload set will be grouped in the same sequence. Is incompatible with split_distance / split_time."""
55
58
  split_distance: Optional[int] = None
56
- """Maximum distance between two pictures to be considered in the same sequence (in meters)."""
59
+ """Maximum distance between two pictures to be considered in the same sequence (in meters). If not set, the instance default will be used. The instance defaults can be see in /api/configuration."""
57
60
  split_time: Optional[timedelta] = None
58
- """Maximum time interval between two pictures to be considered in the same sequence."""
61
+ """Maximum time interval between two pictures to be considered in the same sequence.
62
+ If not set, the instance default will be used. The instance defaults can be see in /api/configuration."""
63
+ no_deduplication: Optional[bool] = None
64
+ """If True, no duplication will be done. Is incompatible with duplicate_distance / duplicate_rotation."""
59
65
  duplicate_distance: Optional[float] = None
60
- """Maximum distance between two pictures to be considered as duplicates (in meters)."""
66
+ """Maximum distance between two pictures to be considered as duplicates (in meters).
67
+ If not set, the instance default will be used. The instance defaults can be see in /api/configuration."""
61
68
  duplicate_rotation: Optional[int] = None
62
- """Maximum angle of rotation for two too-close-pictures to be considered as duplicates (in degrees)."""
69
+ """Maximum angle of rotation for two too-close-pictures to be considered as duplicates (in degrees).
70
+ If not set, the instance default will be used. The instance defaults can be see in /api/configuration."""
63
71
  metadata: Optional[Dict[str, Any]] = None
64
72
  """Optional metadata associated to the upload set. Can contain any key-value pair."""
65
73
  user_agent: Optional[str] = None
66
74
  """Software used by client to create this upload set, in HTTP Header User-Agent format"""
75
+ semantics: Optional[List[SemanticTagUpdate]] = None
76
+ """Semantic tags associated to the upload_set. Those tags will be added to all sequences linked to this upload set"""
77
+ relative_heading: Optional[int] = None
78
+ """The relative heading (in degrees), offset based on movement path (0° = looking forward, -90° = looking left, 90° = looking right). For single picture upload_sets, 0° is heading north). Headings are unchanged if this parameter is not set."""
67
79
 
68
80
  model_config = ConfigDict(use_attribute_docstrings=True)
69
81
 
82
+ def validate(self):
83
+ if self.no_split is True and (self.split_distance is not None or self.split_time is not None):
84
+ raise errors.InvalidAPIUsage("The `no_split` parameter is incompatible with specifying `split_distance` / `split_duration`")
85
+ if self.no_deduplication is True and (self.duplicate_distance is not None or self.duplicate_rotation is not None):
86
+ raise errors.InvalidAPIUsage(
87
+ "The `no_deduplication` parameter is incompatible with specifying `duplicate_distance` / `duplicate_rotation`"
88
+ )
89
+
90
+ @field_validator("relative_heading", mode="before")
91
+ @classmethod
92
+ def parse_relative_heading(cls, value):
93
+ return parse_relative_heading(value)
94
+
70
95
 
71
96
  class UploadSetUpdateParameter(BaseModel):
72
97
  """Parameters used to update an UploadSet"""
73
98
 
74
99
  sort_method: Optional[geopic_sequence.SortMethod] = None
75
100
  """Strategy used for sorting your pictures. Either by filename or EXIF time, in ascending or descending order."""
101
+ no_split: Optional[bool] = None
102
+ """If True, all pictures of this upload set will be grouped in the same sequence. Is incompatible with split_distance / split_time."""
76
103
  split_distance: Optional[int] = None
77
104
  """Maximum distance between two pictures to be considered in the same sequence (in meters)."""
78
105
  split_time: Optional[timedelta] = None
79
106
  """Maximum time interval between two pictures to be considered in the same sequence."""
107
+ no_deduplication: Optional[bool] = None
108
+ """If True, no deduplication will be done. Is incompatible with duplicate_distance / duplicate_rotation
109
+
110
+ Note that if the upload_set has already been dispatched, the deduplication has already been done so it cannot be deactivated.
111
+ """
80
112
  duplicate_distance: Optional[float] = None
81
113
  """Maximum distance between two pictures to be considered as duplicates (in meters)."""
82
114
  duplicate_rotation: Optional[int] = None
83
115
  """Maximum angle of rotation for two too-close-pictures to be considered as duplicates (in degrees)."""
116
+ semantics: Optional[List[SemanticTagUpdate]] = None
117
+ """Semantic tags associated to the upload_set. Those tags will be added to all sequences linked to this upload set.
118
+ By default each tag will be added to the upload set's tags, but you can change this behavior by setting the `action` parameter to `delete`.
119
+
120
+ If you want to replace a tag, you need to first delete it, then add it again.
121
+
122
+ Like:
123
+ [
124
+ {"key": "some_key", "value": "some_value", "action": "delete"},
125
+ {"key": "some_key", "value": "some_new_value"}
126
+ ]
127
+
128
+ Note: for the moment it's not possible to update the semantics of an upload set after it has been dispatched.
129
+ If that is something needed, feel free to open an issue.
130
+ """
131
+ relative_heading: Optional[int] = None
132
+ """The relative heading (in degrees), offset based on movement path (0° = looking forward, -90° = looking left, 90° = looking right). For single picture upload_sets, 0° is heading north). Headings are unchanged if this parameter is not set."""
84
133
 
85
134
  model_config = ConfigDict(use_attribute_docstrings=True, extra="forbid")
86
135
 
136
+ def validate(self):
137
+ if self.no_split is True and (self.split_distance is not None or self.split_time is not None):
138
+ raise errors.InvalidAPIUsage("The `no_split` parameter is incompatible with specifying `split_distance` / `split_duration`")
139
+ if self.no_deduplication is True and (self.duplicate_distance is not None or self.duplicate_rotation is not None):
140
+ raise errors.InvalidAPIUsage(
141
+ "The `no_deduplication` parameter is incompatible with specifying `duplicate_distance` / `duplicate_rotation`"
142
+ )
143
+
144
+ def has_only_semantics_updates(self):
145
+ return self.model_fields_set == {"semantics"}
146
+
147
+ @field_validator("relative_heading", mode="before")
148
+ @classmethod
149
+ def parse_relative_heading(cls, value):
150
+ return parse_relative_heading(value)
151
+
87
152
 
88
153
  def create_upload_set(params: UploadSetCreationParameter, accountId: UUID) -> UploadSet:
154
+ sem = params.semantics
155
+ params.semantics = None
89
156
  db_params = model_query.get_db_params_and_values(params, account_id=accountId)
157
+ with db.conn(current_app) as conn, conn.transaction():
90
158
 
91
- db_upload_set = db.fetchone(
92
- current_app,
93
- SQL("INSERT INTO upload_sets({fields}) VALUES({values}) RETURNING *").format(
94
- fields=db_params.fields(), values=db_params.placeholders()
95
- ),
96
- db_params.params_as_dict,
97
- row_factory=class_row(UploadSet),
98
- )
159
+ with conn.cursor(row_factory=class_row(UploadSet)) as cursor:
160
+ db_upload_set = cursor.execute(
161
+ SQL("INSERT INTO upload_sets({fields}) VALUES({values}) RETURNING *").format(
162
+ fields=db_params.fields(), values=db_params.placeholders()
163
+ ),
164
+ db_params.params_as_dict,
165
+ ).fetchone()
99
166
 
100
- if db_upload_set is None:
101
- raise Exception("Impossible to insert sequence in database")
167
+ if db_upload_set is None:
168
+ raise Exception("Impossible to insert upload_set in database")
102
169
 
103
- return db_upload_set
170
+ if sem:
171
+ with conn.cursor() as cursor:
172
+ semantics.update_tags(
173
+ cursor=cursor,
174
+ entity=semantics.Entity(semantics.EntityType.upload_set, db_upload_set.id),
175
+ actions=sem,
176
+ account=accountId,
177
+ )
104
178
 
179
+ return db_upload_set
105
180
 
106
- def update_upload_set(upload_set_id: UUID, params: UploadSetUpdateParameter) -> UploadSet:
107
- db_params = model_query.get_db_params_and_values(params)
108
181
 
182
+ def update_upload_set(upload_set_id: UUID, params: UploadSetUpdateParameter, account) -> UploadSet:
183
+ """Update an upload set
184
+ Since the semantic tags are handled in a separate table, split the update in 2, the semantic update, and the upload_sets table update"""
109
185
  with db.conn(current_app) as conn, conn.transaction():
110
- import psycopg
186
+ if params.semantics:
187
+ # update the semantics if needed, and remove the semantic from the params for the other fields update
188
+ sem = params.semantics
189
+ params.semantics = None
111
190
 
112
- cur = psycopg.ClientCursor(conn)
113
- q = SQL("UPDATE upload_sets SET {fields} WHERE id = %(upload_set_id)s").format(fields=db_params.fields_for_set())
114
- print(cur.mogrify(q, db_params.params_as_dict | {"upload_set_id": upload_set_id}))
191
+ with conn.cursor() as cursor:
192
+ semantics.update_tags(
193
+ cursor=cursor,
194
+ entity=semantics.Entity(semantics.EntityType.upload_set, upload_set_id),
195
+ actions=sem,
196
+ account=account.id if account is not None else None,
197
+ )
115
198
 
116
- with db.execute(
117
- current_app,
118
- SQL("UPDATE upload_sets SET {fields} WHERE id = %(upload_set_id)s").format(fields=db_params.fields_for_set()),
119
- db_params.params_as_dict | {"upload_set_id": upload_set_id},
120
- ):
121
- # we get a full uploadset response
122
- return get_upload_set(upload_set_id)
199
+ us_dispatched = cursor.execute(
200
+ SQL("SELECT dispatched FROM upload_sets WHERE id = %(upload_set_id)s"),
201
+ {"upload_set_id": upload_set_id},
202
+ ).fetchone()
203
+
204
+ if us_dispatched[0] is True:
205
+ # if the upload set is already dispatched, we propagate the semantic update to all the associated collections
206
+ # Note that there is a lock on the `upload_sets` row to avoid updating the semantics while dispatching the upload set
207
+ associated_cols = conn.execute("SELECT id FROM sequences WHERE upload_set_id = %s", [upload_set_id]).fetchall()
208
+ for c in associated_cols:
209
+ col_id = c[0]
210
+ semantics.update_tags(
211
+ cursor=cursor,
212
+ entity=semantics.Entity(semantics.EntityType.seq, col_id),
213
+ actions=sem,
214
+ account=account.id if account is not None else None,
215
+ )
216
+
217
+ if params.model_fields_set != {"semantics"}:
218
+ # if there was other fields to update
219
+ db_params = model_query.get_db_params_and_values(params)
220
+
221
+ conn.execute(
222
+ SQL("UPDATE upload_sets SET {fields} WHERE id = %(upload_set_id)s").format(fields=db_params.fields_for_set()),
223
+ db_params.params_as_dict | {"upload_set_id": upload_set_id},
224
+ )
225
+ # we get a full uploadset response
226
+ return get_upload_set(upload_set_id)
123
227
 
124
228
 
125
229
  @bp.route("/upload_sets", methods=["POST"])
@@ -139,7 +243,7 @@ def postUploadSet(account=None):
139
243
  required: false
140
244
  schema:
141
245
  type: string
142
- description: An explicit User-Agent value is prefered if you create a production-ready tool, formatted like "GeoVisioCLI/1.0"
246
+ description: An explicit User-Agent value is preferred if you create a production-ready tool, formatted like "GeoVisioCLI/1.0"
143
247
  requestBody:
144
248
  content:
145
249
  application/json:
@@ -165,6 +269,7 @@ def postUploadSet(account=None):
165
269
  else:
166
270
  raise errors.InvalidAPIUsage(_("Parameter for creating an UploadSet should be a valid JSON"), status_code=415)
167
271
 
272
+ params.validate()
168
273
  account_id = UUID(accountIdOrDefault(account))
169
274
 
170
275
  upload_set = create_upload_set(params, account_id)
@@ -185,7 +290,9 @@ def postUploadSet(account=None):
185
290
  def patchUploadSet(upload_set_id, account=None):
186
291
  """Update an existing UploadSet.
187
292
 
188
- Note that the upload set will not be dispatched again, so if you changed the dispatch parameters (like split_distance, split_time, duplicate_distance, duplicate_rotation, ...), you need to call the `POST /api/upload_sets/:id/complete` endpoint to dispatch the upload set afterward.
293
+ For most fields, only the owner of the UploadSet can update it. The only exception is the `semantics` field, which can be updated by any user.
294
+
295
+ Note that the upload set will not be dispatched again, so if you changed the dispatch parameters (like split_distance, split_time, duplicate_distance, duplicate_rotation, relative_heading, ...), you need to call the `POST /api/upload_sets/:id/complete` endpoint to dispatch the upload set afterward.
189
296
  ---
190
297
  tags:
191
298
  - Upload
@@ -201,7 +308,7 @@ def patchUploadSet(upload_set_id, account=None):
201
308
  content:
202
309
  application/json:
203
310
  schema:
204
- $ref: '#/components/schemas/GeoVisioUploadSet'
311
+ $ref: '#/components/schemas/UploadSetUpdateParameter'
205
312
  security:
206
313
  - bearerToken: []
207
314
  - cookieAuth: []
@@ -222,18 +329,20 @@ def patchUploadSet(upload_set_id, account=None):
222
329
  else:
223
330
  raise errors.InvalidAPIUsage(_("Parameter for updating an UploadSet should be a valid JSON"), status_code=415)
224
331
 
332
+ params.validate()
225
333
  upload_set = get_simple_upload_set(upload_set_id)
226
334
  if upload_set is None:
227
335
  raise errors.InvalidAPIUsage(_("UploadSet doesn't exist"), status_code=404)
228
336
 
229
337
  if account and str(upload_set.account_id) != account.id:
230
- raise errors.InvalidAPIUsage(_("You are not allowed to update this upload set"), status_code=403)
338
+ if not params.has_only_semantics_updates():
339
+ raise errors.InvalidAPIUsage(_("You are not allowed to update this upload set"), status_code=403)
231
340
 
232
341
  if not params.model_fields_set:
233
342
  # nothing to update, return the upload set
234
343
  upload_set = get_upload_set(upload_set_id)
235
344
  else:
236
- upload_set = update_upload_set(upload_set_id, params)
345
+ upload_set = update_upload_set(upload_set_id, params, account)
237
346
 
238
347
  return upload_set.model_dump_json(exclude_none=True), 200, {"Content-Type": "application/json"}
239
348
 
@@ -522,7 +631,7 @@ def mark_upload_set_completed_if_needed(cursor, upload_set_id: UUID) -> bool:
522
631
  """WITH nb_items AS (
523
632
  SELECT count(*) AS nb, upload_set_id
524
633
  FROM files f
525
- WHERE upload_set_id = %(id)s
634
+ WHERE upload_set_id = %(id)s
526
635
  GROUP BY upload_set_id
527
636
  )
528
637
  UPDATE upload_sets
@@ -853,6 +962,8 @@ def deleteUploadSet(upload_set_id: UUID, account=None):
853
962
 
854
963
  upload_set = get_upload_set(upload_set_id)
855
964
 
965
+ if not upload_set:
966
+ raise errors.InvalidAPIUsage(_("UploadSet %(u)s does not exist", u=upload_set_id), status_code=404)
856
967
  # Account associated to uploadset doesn't match current user
857
968
  if account is not None and account.id != str(upload_set.account_id):
858
969
  raise errors.InvalidAPIUsage(_("You're not authorized to delete this upload set"), status_code=403)
geovisio/web/users.py CHANGED
@@ -91,13 +91,13 @@ def _get_user_info(account: auth.Account):
91
91
  @bp.route("/me")
92
92
  @auth.login_required_with_redirect()
93
93
  def getMyUserInfo(account):
94
- """Get current logged user informations
94
+ """Get current logged user information
95
95
  ---
96
96
  tags:
97
97
  - Users
98
98
  responses:
99
99
  200:
100
- description: Information about the logged account
100
+ description: Information about the logged in account
101
101
  content:
102
102
  application/json:
103
103
  schema:
@@ -108,7 +108,7 @@ def getMyUserInfo(account):
108
108
 
109
109
  @bp.route("/<uuid:userId>")
110
110
  def getUserInfo(userId):
111
- """Get user informations
111
+ """Get user information
112
112
  ---
113
113
  tags:
114
114
  - Users
@@ -152,7 +152,7 @@ def getMyCatalog(account):
152
152
  deprecated: true
153
153
  responses:
154
154
  200:
155
- description: the Catalog listing all sequences associated to given user. Note that it's similar to the user's colletion, but with less metadata since a STAC collection is an enhanced STAC catalog.
155
+ description: the Catalog listing all sequences associated to given user. Note that it's similar to the user's collection, but with less metadata since a STAC collection is an enhanced STAC catalog.
156
156
  content:
157
157
  application/json:
158
158
  schema:
geovisio/web/utils.py CHANGED
@@ -14,12 +14,12 @@ STAC_VERSION = "1.0.0"
14
14
 
15
15
 
16
16
  def removeNoneInDict(val):
17
- """Removes empty values from dictionnary"""
17
+ """Removes empty values from dictionary"""
18
18
  return {k: v for k, v in val.items() if v is not None}
19
19
 
20
20
 
21
21
  def cleanNoneInDict(val):
22
- """Removes empty values from dictionnary, and return None if dict is empty"""
22
+ """Removes empty values from dictionary, and return None if dict is empty"""
23
23
  res = removeNoneInDict(val)
24
24
  return res if len(res) > 0 else None
25
25