geovisio 2.9.0__py3-none-any.whl → 2.11.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 (82) hide show
  1. geovisio/__init__.py +8 -1
  2. geovisio/admin_cli/user.py +7 -2
  3. geovisio/config_app.py +26 -12
  4. geovisio/translations/ar/LC_MESSAGES/messages.mo +0 -0
  5. geovisio/translations/ar/LC_MESSAGES/messages.po +818 -0
  6. geovisio/translations/be/LC_MESSAGES/messages.mo +0 -0
  7. geovisio/translations/be/LC_MESSAGES/messages.po +886 -0
  8. geovisio/translations/br/LC_MESSAGES/messages.po +1 -1
  9. geovisio/translations/da/LC_MESSAGES/messages.mo +0 -0
  10. geovisio/translations/da/LC_MESSAGES/messages.po +96 -4
  11. geovisio/translations/de/LC_MESSAGES/messages.mo +0 -0
  12. geovisio/translations/de/LC_MESSAGES/messages.po +214 -122
  13. geovisio/translations/el/LC_MESSAGES/messages.po +1 -1
  14. geovisio/translations/en/LC_MESSAGES/messages.mo +0 -0
  15. geovisio/translations/en/LC_MESSAGES/messages.po +234 -157
  16. geovisio/translations/eo/LC_MESSAGES/messages.mo +0 -0
  17. geovisio/translations/eo/LC_MESSAGES/messages.po +55 -5
  18. geovisio/translations/es/LC_MESSAGES/messages.po +1 -1
  19. geovisio/translations/fi/LC_MESSAGES/messages.po +1 -1
  20. geovisio/translations/fr/LC_MESSAGES/messages.mo +0 -0
  21. geovisio/translations/fr/LC_MESSAGES/messages.po +92 -3
  22. geovisio/translations/hu/LC_MESSAGES/messages.po +1 -1
  23. geovisio/translations/it/LC_MESSAGES/messages.mo +0 -0
  24. geovisio/translations/it/LC_MESSAGES/messages.po +63 -3
  25. geovisio/translations/ja/LC_MESSAGES/messages.po +1 -1
  26. geovisio/translations/ko/LC_MESSAGES/messages.po +1 -1
  27. geovisio/translations/messages.pot +216 -139
  28. geovisio/translations/nl/LC_MESSAGES/messages.mo +0 -0
  29. geovisio/translations/nl/LC_MESSAGES/messages.po +333 -62
  30. geovisio/translations/oc/LC_MESSAGES/messages.mo +0 -0
  31. geovisio/translations/oc/LC_MESSAGES/messages.po +821 -0
  32. geovisio/translations/pl/LC_MESSAGES/messages.po +1 -1
  33. geovisio/translations/pt/LC_MESSAGES/messages.mo +0 -0
  34. geovisio/translations/pt/LC_MESSAGES/messages.po +944 -0
  35. geovisio/translations/pt_BR/LC_MESSAGES/messages.mo +0 -0
  36. geovisio/translations/pt_BR/LC_MESSAGES/messages.po +942 -0
  37. geovisio/translations/sv/LC_MESSAGES/messages.mo +0 -0
  38. geovisio/translations/sv/LC_MESSAGES/messages.po +4 -3
  39. geovisio/translations/ti/LC_MESSAGES/messages.mo +0 -0
  40. geovisio/translations/ti/LC_MESSAGES/messages.po +762 -0
  41. geovisio/translations/tr/LC_MESSAGES/messages.mo +0 -0
  42. geovisio/translations/tr/LC_MESSAGES/messages.po +927 -0
  43. geovisio/translations/uk/LC_MESSAGES/messages.mo +0 -0
  44. geovisio/translations/uk/LC_MESSAGES/messages.po +920 -0
  45. geovisio/translations/zh_Hant/LC_MESSAGES/messages.po +1 -1
  46. geovisio/utils/annotations.py +21 -21
  47. geovisio/utils/auth.py +47 -13
  48. geovisio/utils/cql2.py +22 -5
  49. geovisio/utils/fields.py +14 -2
  50. geovisio/utils/items.py +44 -0
  51. geovisio/utils/model_query.py +2 -2
  52. geovisio/utils/pic_shape.py +1 -1
  53. geovisio/utils/pictures.py +127 -36
  54. geovisio/utils/semantics.py +32 -3
  55. geovisio/utils/sentry.py +1 -1
  56. geovisio/utils/sequences.py +155 -109
  57. geovisio/utils/upload_set.py +303 -206
  58. geovisio/utils/users.py +18 -0
  59. geovisio/utils/website.py +1 -1
  60. geovisio/web/annotations.py +303 -69
  61. geovisio/web/auth.py +1 -1
  62. geovisio/web/collections.py +194 -97
  63. geovisio/web/configuration.py +36 -4
  64. geovisio/web/docs.py +109 -13
  65. geovisio/web/items.py +319 -186
  66. geovisio/web/map.py +92 -54
  67. geovisio/web/pages.py +48 -4
  68. geovisio/web/params.py +100 -42
  69. geovisio/web/pictures.py +37 -3
  70. geovisio/web/prepare.py +4 -2
  71. geovisio/web/queryables.py +57 -0
  72. geovisio/web/stac.py +8 -2
  73. geovisio/web/tokens.py +49 -1
  74. geovisio/web/upload_set.py +226 -51
  75. geovisio/web/users.py +89 -8
  76. geovisio/web/utils.py +26 -8
  77. geovisio/workers/runner_pictures.py +128 -23
  78. {geovisio-2.9.0.dist-info → geovisio-2.11.0.dist-info}/METADATA +15 -14
  79. geovisio-2.11.0.dist-info/RECORD +117 -0
  80. geovisio-2.9.0.dist-info/RECORD +0 -98
  81. {geovisio-2.9.0.dist-info → geovisio-2.11.0.dist-info}/WHEEL +0 -0
  82. {geovisio-2.9.0.dist-info → geovisio-2.11.0.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,57 @@
1
+ import flask
2
+
3
+ bp = flask.Blueprint("queryables", __name__, url_prefix="/api")
4
+
5
+ ITEMS_QUERYABLES = {
6
+ "$schema": "https://json-schema.org/draft/2019-09/schema",
7
+ "$id": "https://stac-api.example.com/queryables",
8
+ "type": "object",
9
+ "title": "Queryables for Panoramax STAC API",
10
+ "description": "Queryable names for Panoramax STAC API Item Search filter.",
11
+ "properties": {
12
+ "semantics": {
13
+ "description": "Tag to represent the presence of semantics. Only support the IS NOT NULL operator for the moment, to search for all items with at least one semantic tag.",
14
+ "type": "string",
15
+ },
16
+ },
17
+ "patternProperties": {
18
+ "^semantics\\.(.+)$": {
19
+ "description": "Specific semantic tag. The semantic tag `key` should be after the prefix `semantics.`",
20
+ "type": "string",
21
+ }
22
+ },
23
+ "additionalProperties": False,
24
+ }
25
+
26
+ COLLECTION_QUERYABLES = {
27
+ "$schema": "https://json-schema.org/draft/2019-09/schema",
28
+ "$id": "https://stac-api.example.com/queryables",
29
+ "type": "object",
30
+ "title": "Queryables for Panoramax STAC API",
31
+ "description": "Queryable names for Panoramax STAC API Item Search filter.",
32
+ "properties": {
33
+ "created": {
34
+ "description": "Created date of the collection. The filter can be either a date or a datetime",
35
+ "type": "string",
36
+ "anyOf": [{"format": "date-time"}, {"format": "date"}],
37
+ },
38
+ "updated": {
39
+ "description": "Update date of the collection. The filter can be either a date or a datetime",
40
+ "type": "string",
41
+ "anyOf": [{"format": "date-time"}, {"format": "date"}],
42
+ },
43
+ },
44
+ "additionalProperties": False,
45
+ }
46
+
47
+
48
+ @bp.route("/queryables")
49
+ def search_queryables():
50
+ """List of queryables for search as defined by https://github.com/stac-api-extensions/filter?tab=readme-ov-file#queryables"""
51
+ return flask.jsonify(ITEMS_QUERYABLES), {"Cache-Control": "public, max-age=3600"}
52
+
53
+
54
+ @bp.route("/collections/queryables")
55
+ def collection_queryables():
56
+ """List of queryables for collection-search as defined by https://github.com/stac-api-extensions/filter?tab=readme-ov-file#queryables"""
57
+ return flask.jsonify(COLLECTION_QUERYABLES), {"Cache-Control": "public, max-age=3600"}
geovisio/web/stac.py CHANGED
@@ -144,6 +144,12 @@ def getLanding():
144
144
  "href": mapUrl,
145
145
  "title": "Pictures and sequences vector tiles",
146
146
  },
147
+ {
148
+ "title": "Queryables",
149
+ "href": url_for("queryables.search_queryables", _external=True),
150
+ "rel": "http://www.opengis.net/def/rel/ogc/1.0/queryables",
151
+ "type": "application/schema+json",
152
+ },
147
153
  {
148
154
  "rel": "xyz-style",
149
155
  "type": "application/json",
@@ -348,7 +354,6 @@ def getUserCatalog(userId, userIdMatchesAccount=False):
348
354
  collection_request.pagination_filter = parse_collection_filter(request.args.get("page"))
349
355
 
350
356
  userName = None
351
- meta_collection = None
352
357
  with db.cursor(current_app, row_factory=dict_row) as cursor:
353
358
  userName = cursor.execute("SELECT name FROM accounts WHERE id = %s", [userId]).fetchone()
354
359
 
@@ -359,8 +364,9 @@ def getUserCatalog(userId, userIdMatchesAccount=False):
359
364
  datasetBounds = get_dataset_bounds(
360
365
  cursor.connection,
361
366
  collection_request.sort_by,
362
- additional_filters=SQL("s.account_id = %(account)s"),
367
+ additional_filters=SQL("s.account_id = %(account)s AND is_sequence_visible_by_user(s, %(account_to_query)s)"),
363
368
  additional_filters_params={"account": userId},
369
+ account_to_query_id=auth.get_current_account_id(),
364
370
  )
365
371
 
366
372
  if datasetBounds is None:
geovisio/web/tokens.py CHANGED
@@ -22,7 +22,7 @@ def list_tokens(account):
22
22
 
23
23
  The list of tokens will not contain their JWT counterpart (the JWT is the real token used in authentication).
24
24
 
25
- The JWT counterpart can be retreived by providing the token's id to the endpoint [/users/me/tokens/{token_id}](#/Auth/get_api_users_me_tokens__token_id_).
25
+ The JWT counterpart can be retrieved by providing the token's id to the endpoint [/users/me/tokens/{token_id}](#/Auth/get_api_users_me_tokens__token_id_).
26
26
  ---
27
27
  tags:
28
28
  - Auth
@@ -254,6 +254,54 @@ def claim_non_associated_token(token_id, account):
254
254
  return "You are now logged in the CLI, you can upload your pictures", 200
255
255
 
256
256
 
257
+ @bp.route("/users/me/tokens", methods=["POST"])
258
+ @auth.login_required_with_redirect()
259
+ def generate_associated_token(account: auth.Account):
260
+ """
261
+ Generate a new token associated to the current user
262
+
263
+ The response contains the JWT token and is directly usable (unlike tokens created by `/auth/tokens/generate` that are not associated to a user by default). This token does not need to be claimed.
264
+ ---
265
+ tags:
266
+ - Auth
267
+ requestBody:
268
+ content:
269
+ application/json:
270
+ schema:
271
+ $ref: '#/components/schemas/GeovisioPostToken'
272
+ responses:
273
+ 200:
274
+ description: The newly generated token
275
+ content:
276
+ application/json:
277
+ schema:
278
+ $ref: '#/components/schemas/GeoVisioEncodedToken'
279
+ """
280
+ if request.is_json:
281
+ description = request.json.get("description", "")
282
+ else:
283
+ description = None
284
+
285
+ token = db.fetchone(
286
+ current_app,
287
+ "INSERT INTO tokens (description, account_id) VALUES (%(description)s, %(account_id)s) RETURNING *",
288
+ {"account_id": account.id, "description": description},
289
+ row_factory=dict_row,
290
+ )
291
+ if not token:
292
+ raise errors.InternalError(_("Impossible to generate a new token"))
293
+
294
+ jwt_token = _generate_jwt_token(token["id"])
295
+ return flask.jsonify(
296
+ {
297
+ "jwt_token": jwt_token,
298
+ "id": token["id"],
299
+ "description": token["description"],
300
+ "generated_at": token["generated_at"].astimezone(tz.gettz("UTC")).isoformat(),
301
+ }
302
+ )
303
+
304
+
257
305
  def _generate_jwt_token(token_id: uuid.UUID) -> str:
258
306
  """
259
307
  Generate a JWT token from a token's id.
@@ -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
@@ -8,18 +7,16 @@ from psycopg.sql import SQL
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
- from geovisio.web.utils import accountIdOrDefault
12
- from psycopg.types.json import Jsonb
13
- from geovisio.web.params import (
14
- as_latitude,
15
- as_longitude,
16
- parse_datetime,
17
- )
10
+ from geovisio.web.utils import accountOrDefault
11
+ from geovisio.utils.fields import parse_relative_heading
12
+ from geovisio.web.params import as_latitude, as_longitude, parse_datetime, Visibility, check_visibility
18
13
  import logging
19
14
  from geovisio.utils import db
20
15
  from geovisio import utils
21
16
  from geopic_tag_reader.writer import writePictureMetadata, PictureMetadata
22
17
  from geovisio.utils.params import validation_error
18
+ from geovisio.utils.semantics import SemanticTagUpdate
19
+ from geovisio.utils import semantics
23
20
  from geovisio import errors
24
21
  from pydantic import BaseModel, ConfigDict, ValidationError, Field, field_validator, model_validator
25
22
  from uuid import UUID
@@ -37,7 +34,7 @@ from geovisio.utils.upload_set import (
37
34
  import os
38
35
  import hashlib
39
36
  import sentry_sdk
40
- from typing import Optional, Any, Dict
37
+ from typing import Optional, Any, Dict, List
41
38
 
42
39
 
43
40
  bp = Blueprint("upload_set", __name__, url_prefix="/api")
@@ -52,20 +49,62 @@ class UploadSetCreationParameter(BaseModel):
52
49
  """Estimated number of items that will be sent to the UploadSet"""
53
50
  sort_method: Optional[geopic_sequence.SortMethod] = None
54
51
  """Strategy used for sorting your pictures. Either by filename or EXIF time, in ascending or descending order."""
52
+ no_split: Optional[bool] = None
53
+ """If True, all pictures of this upload set will be grouped in the same sequence. Is incompatible with split_distance / split_time."""
55
54
  split_distance: Optional[int] = None
56
- """Maximum distance between two pictures to be considered in the same sequence (in meters)."""
55
+ """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
56
  split_time: Optional[timedelta] = None
58
- """Maximum time interval between two pictures to be considered in the same sequence."""
57
+ """Maximum time interval between two pictures to be considered in the same sequence.
58
+ If not set, the instance default will be used. The instance defaults can be see in /api/configuration."""
59
+ no_deduplication: Optional[bool] = None
60
+ """If True, no duplication will be done. Is incompatible with duplicate_distance / duplicate_rotation."""
59
61
  duplicate_distance: Optional[float] = None
60
- """Maximum distance between two pictures to be considered as duplicates (in meters)."""
62
+ """Maximum distance between two pictures to be considered as duplicates (in meters).
63
+ If not set, the instance default will be used. The instance defaults can be see in /api/configuration."""
61
64
  duplicate_rotation: Optional[int] = None
62
- """Maximum angle of rotation for two too-close-pictures to be considered as duplicates (in degrees)."""
65
+ """Maximum angle of rotation for two too-close-pictures to be considered as duplicates (in degrees).
66
+ If not set, the instance default will be used. The instance defaults can be see in /api/configuration."""
63
67
  metadata: Optional[Dict[str, Any]] = None
64
68
  """Optional metadata associated to the upload set. Can contain any key-value pair."""
65
69
  user_agent: Optional[str] = None
66
70
  """Software used by client to create this upload set, in HTTP Header User-Agent format"""
71
+ semantics: Optional[List[SemanticTagUpdate]] = None
72
+ """Semantic tags associated to the upload_set. Those tags will be added to all sequences linked to this upload set"""
73
+ relative_heading: Optional[int] = None
74
+ """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."""
75
+ visibility: Optional[Visibility] = None
76
+ """Visibility of the upload set. Can be set to:
77
+ * `anyone`: the upload is visible to anyone
78
+ * `owner-only`: the upload is visible to the owner and administrator only
79
+ * `logged-only`: the upload is visible to logged users only
67
80
 
68
- model_config = ConfigDict(use_attribute_docstrings=True)
81
+ This visibility can also be set for each picture individually, or each collections, using the `visibility` field of the pictures/collections.
82
+ If not set at those levels, it will default to the visibility of the `account` and if not set the default visibility of the instance."""
83
+
84
+ model_config = ConfigDict(use_enum_values=True, use_attribute_docstrings=True)
85
+
86
+ def validate(self):
87
+ if self.no_split is True and (self.split_distance is not None or self.split_time is not None):
88
+ raise errors.InvalidAPIUsage("The `no_split` parameter is incompatible with specifying `split_distance` / `split_duration`")
89
+ if self.no_deduplication is True and (self.duplicate_distance is not None or self.duplicate_rotation is not None):
90
+ raise errors.InvalidAPIUsage(
91
+ "The `no_deduplication` parameter is incompatible with specifying `duplicate_distance` / `duplicate_rotation`"
92
+ )
93
+
94
+ @field_validator("relative_heading", mode="before")
95
+ @classmethod
96
+ def parse_relative_heading(cls, value):
97
+ return parse_relative_heading(value)
98
+
99
+ @field_validator("visibility", mode="after")
100
+ @classmethod
101
+ def validate_visibility(cls, visibility):
102
+ if not check_visibility(visibility):
103
+ raise errors.InvalidAPIUsage(
104
+ _("The logged-only visibility is not allowed on this instance since anybody can create an account"),
105
+ status_code=400,
106
+ )
107
+ return visibility
69
108
 
70
109
 
71
110
  class UploadSetUpdateParameter(BaseModel):
@@ -73,46 +112,173 @@ class UploadSetUpdateParameter(BaseModel):
73
112
 
74
113
  sort_method: Optional[geopic_sequence.SortMethod] = None
75
114
  """Strategy used for sorting your pictures. Either by filename or EXIF time, in ascending or descending order."""
115
+ no_split: Optional[bool] = None
116
+ """If True, all pictures of this upload set will be grouped in the same sequence. Is incompatible with split_distance / split_time."""
76
117
  split_distance: Optional[int] = None
77
118
  """Maximum distance between two pictures to be considered in the same sequence (in meters)."""
78
119
  split_time: Optional[timedelta] = None
79
120
  """Maximum time interval between two pictures to be considered in the same sequence."""
121
+ no_deduplication: Optional[bool] = None
122
+ """If True, no deduplication will be done. Is incompatible with duplicate_distance / duplicate_rotation
123
+
124
+ Note that if the upload_set has already been dispatched, the deduplication has already been done so it cannot be deactivated.
125
+ """
80
126
  duplicate_distance: Optional[float] = None
81
127
  """Maximum distance between two pictures to be considered as duplicates (in meters)."""
82
128
  duplicate_rotation: Optional[int] = None
83
129
  """Maximum angle of rotation for two too-close-pictures to be considered as duplicates (in degrees)."""
130
+ semantics: Optional[List[SemanticTagUpdate]] = None
131
+ """Semantic tags associated to the upload_set. Those tags will be added to all sequences linked to this upload set.
132
+ 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`.
133
+
134
+ If you want to replace a tag, you need to first delete it, then add it again.
135
+
136
+ Like:
137
+ [
138
+ {"key": "some_key", "value": "some_value", "action": "delete"},
139
+ {"key": "some_key", "value": "some_new_value"}
140
+ ]
141
+
142
+ Note: for the moment it's not possible to update the semantics of an upload set after it has been dispatched.
143
+ If that is something needed, feel free to open an issue.
144
+ """
145
+ relative_heading: Optional[int] = None
146
+ """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."""
147
+ visibility: Optional[Visibility] = None
148
+ """Visibility of the upload set. Can be set to:
149
+ * `anyone`: the upload is visible to anyone
150
+ * `owner-only`: the upload is visible to the owner and administrator only
151
+ * `logged-only`: the upload is visible to logged users only
84
152
 
85
- model_config = ConfigDict(use_attribute_docstrings=True, extra="forbid")
153
+ This visibility can also be set for each picture individually, or each collections, using the `visibility` field of the pictures/collections.
154
+ If not set at those levels, it will default to the visibility of the `account` and if not set the default visibility of the instance."""
155
+
156
+ model_config = ConfigDict(use_enum_values=True, use_attribute_docstrings=True, extra="forbid")
157
+
158
+ def validate(self):
159
+ if self.no_split is True and (self.split_distance is not None or self.split_time is not None):
160
+ raise errors.InvalidAPIUsage("The `no_split` parameter is incompatible with specifying `split_distance` / `split_duration`")
161
+ if self.no_deduplication is True and (self.duplicate_distance is not None or self.duplicate_rotation is not None):
162
+ raise errors.InvalidAPIUsage(
163
+ "The `no_deduplication` parameter is incompatible with specifying `duplicate_distance` / `duplicate_rotation`"
164
+ )
165
+
166
+ @field_validator("visibility", mode="after")
167
+ @classmethod
168
+ def validate_visibility(cls, visibility):
169
+ if not check_visibility(visibility):
170
+ raise errors.InvalidAPIUsage(
171
+ _("The logged-only visibility is not allowed on this instance since anybody can create an account"),
172
+ status_code=400,
173
+ )
174
+ return visibility
175
+
176
+ def has_only_semantics_updates(self):
177
+ return self.model_fields_set == {"semantics"}
178
+
179
+ @field_validator("relative_heading", mode="before")
180
+ @classmethod
181
+ def parse_relative_heading(cls, value):
182
+ return parse_relative_heading(value)
86
183
 
87
184
 
88
185
  def create_upload_set(params: UploadSetCreationParameter, accountId: UUID) -> UploadSet:
186
+ sem = params.semantics
187
+ params.semantics = None
188
+ # we handle visibility a bit differently, to be able to default to the account's default visibility / instance's default visibility
189
+ visibility = params.visibility
190
+ params.visibility = None
89
191
  db_params = model_query.get_db_params_and_values(params, account_id=accountId)
90
192
 
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
- )
99
-
100
- if db_upload_set is None:
101
- raise Exception("Impossible to insert upload_set in database")
193
+ with db.conn(current_app) as conn, conn.transaction():
194
+
195
+ with conn.cursor(row_factory=class_row(UploadSet)) as cursor:
196
+ db_upload_set = cursor.execute(
197
+ SQL(
198
+ """INSERT INTO upload_sets({fields}, visibility)
199
+ VALUES({values},
200
+ (COALESCE(%(visibility)s,
201
+ (SELECT default_visibility FROM accounts WHERE id = %(account_id)s),
202
+ (SELECT default_visibility FROM configurations LIMIT 1))))
203
+ RETURNING *"""
204
+ ).format(fields=db_params.fields(), values=db_params.placeholders()),
205
+ db_params.params_as_dict | {"visibility": visibility},
206
+ ).fetchone()
207
+
208
+ if db_upload_set is None:
209
+ raise Exception("Impossible to insert upload_set in database")
210
+
211
+ if sem:
212
+ with conn.cursor() as cursor:
213
+ semantics.update_tags(
214
+ cursor=cursor,
215
+ entity=semantics.Entity(semantics.EntityType.upload_set, db_upload_set.id),
216
+ actions=sem,
217
+ account=accountId,
218
+ )
102
219
 
103
220
  return db_upload_set
104
221
 
105
222
 
106
- def update_upload_set(upload_set_id: UUID, params: UploadSetUpdateParameter) -> UploadSet:
107
- db_params = model_query.get_db_params_and_values(params)
223
+ def update_upload_set(upload_set_id: UUID, params: UploadSetUpdateParameter, account) -> UploadSet:
224
+ """Update an upload set
225
+ Since the semantic tags are handled in a separate table, split the update in 2, the semantic update, and the upload_sets table update"""
226
+ with db.conn(current_app) as conn, conn.transaction():
227
+ if params.semantics:
228
+ # update the semantics if needed, and remove the semantic from the params for the other fields update
229
+ sem = params.semantics
230
+ params.semantics = None
108
231
 
109
- with db.execute(
110
- current_app,
111
- SQL("UPDATE upload_sets SET {fields} WHERE id = %(upload_set_id)s").format(fields=db_params.fields_for_set()),
112
- db_params.params_as_dict | {"upload_set_id": upload_set_id},
113
- ):
114
- # we get a full uploadset response
115
- return get_upload_set(upload_set_id)
232
+ with conn.cursor() as cursor:
233
+ semantics.update_tags(
234
+ cursor=cursor,
235
+ entity=semantics.Entity(semantics.EntityType.upload_set, upload_set_id),
236
+ actions=sem,
237
+ account=account.id if account is not None else None,
238
+ )
239
+
240
+ us_dispatched = cursor.execute(
241
+ SQL("SELECT dispatched FROM upload_sets WHERE id = %(upload_set_id)s"),
242
+ {"upload_set_id": upload_set_id},
243
+ ).fetchone()
244
+
245
+ if us_dispatched[0] is True:
246
+ # if the upload set is already dispatched, we propagate the semantic update to all the associated collections
247
+ # Note that there is a lock on the `upload_sets` row to avoid updating the semantics while dispatching the upload set
248
+ associated_cols = conn.execute("SELECT id FROM sequences WHERE upload_set_id = %s", [upload_set_id]).fetchall()
249
+ for c in associated_cols:
250
+ col_id = c[0]
251
+ semantics.update_tags(
252
+ cursor=cursor,
253
+ entity=semantics.Entity(semantics.EntityType.seq, col_id),
254
+ actions=sem,
255
+ account=account.id if account is not None else None,
256
+ )
257
+
258
+ if params.model_fields_set != {"semantics"}:
259
+ # if there was other fields to update
260
+ db_params = model_query.get_db_params_and_values(params)
261
+
262
+ conn.execute(
263
+ SQL("UPDATE upload_sets SET {fields} WHERE id = %(upload_set_id)s").format(fields=db_params.fields_for_set()),
264
+ db_params.params_as_dict | {"upload_set_id": upload_set_id},
265
+ )
266
+ if params.visibility is not None:
267
+ # if we change the visibility, we check if some collections have been created to change their visibility too
268
+ with conn.cursor() as cursor:
269
+ us_dispatched = cursor.execute(
270
+ SQL("SELECT dispatched FROM upload_sets WHERE id = %(upload_set_id)s"),
271
+ {"upload_set_id": upload_set_id},
272
+ ).fetchone()
273
+
274
+ if us_dispatched[0] is True:
275
+ cursor.execute(
276
+ SQL("UPDATE sequences SET visibility = %(visibility)s WHERE upload_set_id = %(upload_set_id)s"),
277
+ {"visibility": params.visibility, "upload_set_id": upload_set_id},
278
+ )
279
+
280
+ # we get a full uploadset response
281
+ return get_upload_set(upload_set_id, account_to_query=account.id if account else None)
116
282
 
117
283
 
118
284
  @bp.route("/upload_sets", methods=["POST"])
@@ -132,7 +298,7 @@ def postUploadSet(account=None):
132
298
  required: false
133
299
  schema:
134
300
  type: string
135
- description: An explicit User-Agent value is prefered if you create a production-ready tool, formatted like "GeoVisioCLI/1.0"
301
+ description: An explicit User-Agent value is preferred if you create a production-ready tool, formatted like "GeoVisioCLI/1.0"
136
302
  requestBody:
137
303
  content:
138
304
  application/json:
@@ -158,7 +324,8 @@ def postUploadSet(account=None):
158
324
  else:
159
325
  raise errors.InvalidAPIUsage(_("Parameter for creating an UploadSet should be a valid JSON"), status_code=415)
160
326
 
161
- account_id = UUID(accountIdOrDefault(account))
327
+ params.validate()
328
+ account_id = UUID(accountOrDefault(account).id)
162
329
 
163
330
  upload_set = create_upload_set(params, account_id)
164
331
 
@@ -178,7 +345,9 @@ def postUploadSet(account=None):
178
345
  def patchUploadSet(upload_set_id, account=None):
179
346
  """Update an existing UploadSet.
180
347
 
181
- 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.
348
+ 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.
349
+
350
+ 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.
182
351
  ---
183
352
  tags:
184
353
  - Upload
@@ -215,18 +384,20 @@ def patchUploadSet(upload_set_id, account=None):
215
384
  else:
216
385
  raise errors.InvalidAPIUsage(_("Parameter for updating an UploadSet should be a valid JSON"), status_code=415)
217
386
 
387
+ params.validate()
218
388
  upload_set = get_simple_upload_set(upload_set_id)
219
389
  if upload_set is None:
220
390
  raise errors.InvalidAPIUsage(_("UploadSet doesn't exist"), status_code=404)
221
391
 
222
- if account and str(upload_set.account_id) != account.id:
223
- raise errors.InvalidAPIUsage(_("You are not allowed to update this upload set"), status_code=403)
224
-
225
392
  if not params.model_fields_set:
226
393
  # nothing to update, return the upload set
227
- upload_set = get_upload_set(upload_set_id)
394
+ upload_set = get_upload_set(upload_set_id, account_to_query=account.id if account else None)
228
395
  else:
229
- upload_set = update_upload_set(upload_set_id, params)
396
+ if account and str(upload_set.account_id) != account.id:
397
+ if not params.has_only_semantics_updates() and not account.can_edit_upload_set(str(upload_set.account_id)):
398
+ raise errors.InvalidAPIUsage(_("You are not allowed to update this upload set"), status_code=403)
399
+
400
+ upload_set = update_upload_set(upload_set_id, params, account)
230
401
 
231
402
  return upload_set.model_dump_json(exclude_none=True), 200, {"Content-Type": "application/json"}
232
403
 
@@ -258,7 +429,7 @@ def getUploadSet(upload_set_id):
258
429
  schema:
259
430
  $ref: '#/components/schemas/GeoVisioUploadSet'
260
431
  """
261
- upload_set = get_upload_set(upload_set_id)
432
+ upload_set = get_upload_set(upload_set_id, account_to_query=auth.get_current_account_id())
262
433
  if upload_set is None:
263
434
  raise errors.InvalidAPIUsage(_("UploadSet doesn't exist"), status_code=404)
264
435
 
@@ -352,7 +523,9 @@ def listUserUpload(account):
352
523
  except ValidationError as ve:
353
524
  raise errors.InvalidAPIUsage(_("Impossible to parse parameters"), payload=validation_error(ve))
354
525
 
355
- upload_sets = list_upload_sets(account_id=params.account_id, limit=params.limit, filter=params.filter)
526
+ upload_sets = list_upload_sets(
527
+ account_id=params.account_id, limit=params.limit, filter=params.filter, account_to_query=account.id if account else None
528
+ )
356
529
 
357
530
  return upload_sets.model_dump_json(exclude_none=True), 200, {"Content-Type": "application/json"}
358
531
 
@@ -515,7 +688,7 @@ def mark_upload_set_completed_if_needed(cursor, upload_set_id: UUID) -> bool:
515
688
  """WITH nb_items AS (
516
689
  SELECT count(*) AS nb, upload_set_id
517
690
  FROM files f
518
- WHERE upload_set_id = %(id)s
691
+ WHERE upload_set_id = %(id)s
519
692
  GROUP BY upload_set_id
520
693
  )
521
694
  UPDATE upload_sets
@@ -633,7 +806,7 @@ def addFilesToUploadSet(upload_set_id: UUID, account=None):
633
806
  file_type=params.file_type,
634
807
  )
635
808
  # Compute various metadata
636
- accountId = accountIdOrDefault(account)
809
+ accountId = accountOrDefault(account).id
637
810
  raw_pic = params.file.read()
638
811
  filesize = len(raw_pic)
639
812
  file["size"] = filesize
@@ -802,7 +975,7 @@ def completeUploadSet(upload_set_id: UUID, account=None):
802
975
  raise errors.InvalidAPIUsage(_("UploadSet %(u)s does not exist", u=upload_set_id), status_code=404)
803
976
 
804
977
  # Account associated to uploadset doesn't match current user
805
- if account is not None and account.id != str(upload_set["account_id"]):
978
+ if account is not None and not account.can_edit_upload_set(str(upload_set["account_id"])):
806
979
  raise errors.InvalidAPIUsage(_("You're not authorized to complete this upload set"), status_code=403)
807
980
 
808
981
  cursor.execute("UPDATE upload_sets SET completed = True WHERE id = %(id)s", {"id": upload_set_id})
@@ -811,7 +984,7 @@ def completeUploadSet(upload_set_id: UUID, account=None):
811
984
  current_app.background_processor.process_pictures() # type: ignore
812
985
 
813
986
  # query again the upload set, to get the updated status
814
- upload_set = get_upload_set(upload_set_id)
987
+ upload_set = get_upload_set(upload_set_id, account_to_query=account.id if account else None)
815
988
  if upload_set is None:
816
989
  raise errors.InvalidAPIUsage(_("UploadSet doesn't exist"), status_code=404)
817
990
 
@@ -844,10 +1017,12 @@ def deleteUploadSet(upload_set_id: UUID, account=None):
844
1017
  description: The UploadSet has been correctly deleted
845
1018
  """
846
1019
 
847
- upload_set = get_upload_set(upload_set_id)
1020
+ upload_set = get_upload_set(upload_set_id, account_to_query=account.id if account else None)
848
1021
 
1022
+ if not upload_set:
1023
+ raise errors.InvalidAPIUsage(_("UploadSet %(u)s does not exist", u=upload_set_id), status_code=404)
849
1024
  # Account associated to uploadset doesn't match current user
850
- if account is not None and account.id != str(upload_set.account_id):
1025
+ if account is not None and not account.can_edit_upload_set(str(upload_set.account_id)):
851
1026
  raise errors.InvalidAPIUsage(_("You're not authorized to delete this upload set"), status_code=403)
852
1027
 
853
1028
  utils.upload_set.delete(upload_set)