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.
- geovisio/__init__.py +8 -1
- geovisio/admin_cli/user.py +7 -2
- geovisio/config_app.py +26 -12
- geovisio/translations/ar/LC_MESSAGES/messages.mo +0 -0
- geovisio/translations/ar/LC_MESSAGES/messages.po +818 -0
- geovisio/translations/be/LC_MESSAGES/messages.mo +0 -0
- geovisio/translations/be/LC_MESSAGES/messages.po +886 -0
- geovisio/translations/br/LC_MESSAGES/messages.po +1 -1
- geovisio/translations/da/LC_MESSAGES/messages.mo +0 -0
- geovisio/translations/da/LC_MESSAGES/messages.po +96 -4
- geovisio/translations/de/LC_MESSAGES/messages.mo +0 -0
- geovisio/translations/de/LC_MESSAGES/messages.po +214 -122
- geovisio/translations/el/LC_MESSAGES/messages.po +1 -1
- geovisio/translations/en/LC_MESSAGES/messages.mo +0 -0
- geovisio/translations/en/LC_MESSAGES/messages.po +234 -157
- geovisio/translations/eo/LC_MESSAGES/messages.mo +0 -0
- geovisio/translations/eo/LC_MESSAGES/messages.po +55 -5
- geovisio/translations/es/LC_MESSAGES/messages.po +1 -1
- geovisio/translations/fi/LC_MESSAGES/messages.po +1 -1
- geovisio/translations/fr/LC_MESSAGES/messages.mo +0 -0
- geovisio/translations/fr/LC_MESSAGES/messages.po +92 -3
- geovisio/translations/hu/LC_MESSAGES/messages.po +1 -1
- geovisio/translations/it/LC_MESSAGES/messages.mo +0 -0
- geovisio/translations/it/LC_MESSAGES/messages.po +63 -3
- geovisio/translations/ja/LC_MESSAGES/messages.po +1 -1
- geovisio/translations/ko/LC_MESSAGES/messages.po +1 -1
- geovisio/translations/messages.pot +216 -139
- geovisio/translations/nl/LC_MESSAGES/messages.mo +0 -0
- geovisio/translations/nl/LC_MESSAGES/messages.po +333 -62
- geovisio/translations/oc/LC_MESSAGES/messages.mo +0 -0
- geovisio/translations/oc/LC_MESSAGES/messages.po +821 -0
- geovisio/translations/pl/LC_MESSAGES/messages.po +1 -1
- geovisio/translations/pt/LC_MESSAGES/messages.mo +0 -0
- geovisio/translations/pt/LC_MESSAGES/messages.po +944 -0
- geovisio/translations/pt_BR/LC_MESSAGES/messages.mo +0 -0
- geovisio/translations/pt_BR/LC_MESSAGES/messages.po +942 -0
- geovisio/translations/sv/LC_MESSAGES/messages.mo +0 -0
- geovisio/translations/sv/LC_MESSAGES/messages.po +4 -3
- geovisio/translations/ti/LC_MESSAGES/messages.mo +0 -0
- geovisio/translations/ti/LC_MESSAGES/messages.po +762 -0
- geovisio/translations/tr/LC_MESSAGES/messages.mo +0 -0
- geovisio/translations/tr/LC_MESSAGES/messages.po +927 -0
- geovisio/translations/uk/LC_MESSAGES/messages.mo +0 -0
- geovisio/translations/uk/LC_MESSAGES/messages.po +920 -0
- geovisio/translations/zh_Hant/LC_MESSAGES/messages.po +1 -1
- geovisio/utils/annotations.py +21 -21
- geovisio/utils/auth.py +47 -13
- geovisio/utils/cql2.py +22 -5
- geovisio/utils/fields.py +14 -2
- geovisio/utils/items.py +44 -0
- geovisio/utils/model_query.py +2 -2
- geovisio/utils/pic_shape.py +1 -1
- geovisio/utils/pictures.py +127 -36
- geovisio/utils/semantics.py +32 -3
- geovisio/utils/sentry.py +1 -1
- geovisio/utils/sequences.py +155 -109
- geovisio/utils/upload_set.py +303 -206
- geovisio/utils/users.py +18 -0
- geovisio/utils/website.py +1 -1
- geovisio/web/annotations.py +303 -69
- geovisio/web/auth.py +1 -1
- geovisio/web/collections.py +194 -97
- geovisio/web/configuration.py +36 -4
- geovisio/web/docs.py +109 -13
- geovisio/web/items.py +319 -186
- geovisio/web/map.py +92 -54
- geovisio/web/pages.py +48 -4
- geovisio/web/params.py +100 -42
- geovisio/web/pictures.py +37 -3
- geovisio/web/prepare.py +4 -2
- geovisio/web/queryables.py +57 -0
- geovisio/web/stac.py +8 -2
- geovisio/web/tokens.py +49 -1
- geovisio/web/upload_set.py +226 -51
- geovisio/web/users.py +89 -8
- geovisio/web/utils.py +26 -8
- geovisio/workers/runner_pictures.py +128 -23
- {geovisio-2.9.0.dist-info → geovisio-2.11.0.dist-info}/METADATA +15 -14
- geovisio-2.11.0.dist-info/RECORD +117 -0
- geovisio-2.9.0.dist-info/RECORD +0 -98
- {geovisio-2.9.0.dist-info → geovisio-2.11.0.dist-info}/WHEEL +0 -0
- {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
|
|
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.
|
geovisio/web/upload_set.py
CHANGED
|
@@ -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
|
|
12
|
-
from
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
)
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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
|
-
|
|
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
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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 =
|
|
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.
|
|
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.
|
|
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)
|