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
geovisio/web/collections.py
CHANGED
|
@@ -1,6 +1,4 @@
|
|
|
1
|
-
from copy import deepcopy
|
|
2
1
|
from enum import Enum
|
|
3
|
-
from attr import dataclass
|
|
4
2
|
from geovisio import errors, utils, db
|
|
5
3
|
from geovisio.utils import auth, sequences
|
|
6
4
|
from geovisio.utils.params import validation_error
|
|
@@ -11,8 +9,11 @@ from geovisio.web.params import (
|
|
|
11
9
|
parse_datetime_interval,
|
|
12
10
|
parse_bbox,
|
|
13
11
|
parse_collection_filter,
|
|
14
|
-
|
|
12
|
+
parse_collection_sortby,
|
|
15
13
|
parse_collections_limit,
|
|
14
|
+
parse_boolean,
|
|
15
|
+
Visibility,
|
|
16
|
+
check_visibility,
|
|
16
17
|
)
|
|
17
18
|
from geovisio.utils.sequences import (
|
|
18
19
|
STAC_FIELD_MAPPINGS,
|
|
@@ -20,16 +21,16 @@ from geovisio.utils.sequences import (
|
|
|
20
21
|
get_collections,
|
|
21
22
|
get_dataset_bounds,
|
|
22
23
|
)
|
|
23
|
-
from geovisio.utils.fields import SortBy, SortByField, SQLDirection,
|
|
24
|
+
from geovisio.utils.fields import SortBy, SortByField, SQLDirection, BBox, parse_relative_heading
|
|
24
25
|
from geovisio.web.rss import dbSequencesToGeoRSS
|
|
25
26
|
from psycopg.rows import dict_row
|
|
26
27
|
from psycopg.sql import SQL
|
|
27
|
-
from pydantic import BaseModel, Field, ValidationError, field_validator
|
|
28
|
+
from pydantic import BaseModel, Field, ValidationError, field_validator, model_validator
|
|
28
29
|
from flask import current_app, request, url_for, Blueprint, stream_with_context
|
|
29
30
|
from flask_babel import gettext as _
|
|
30
31
|
from geovisio.web.utils import (
|
|
31
32
|
STAC_VERSION,
|
|
32
|
-
|
|
33
|
+
accountOrDefault,
|
|
33
34
|
cleanNoneInDict,
|
|
34
35
|
cleanNoneInList,
|
|
35
36
|
dbTsToStac,
|
|
@@ -68,6 +69,18 @@ def userAgentToClient(user_agent: Optional[str] = None) -> UploadClient:
|
|
|
68
69
|
return UploadClient.other
|
|
69
70
|
|
|
70
71
|
|
|
72
|
+
def retrocompatible_sequence_status(dbSeq, explicit=False):
|
|
73
|
+
"""We used to display status='hidden' for hidden sequence, now that the status and the visiblity has been split, we still return a 'hidden' status for retrocompatibility"""
|
|
74
|
+
db_status = dbSeq.get("status")
|
|
75
|
+
if db_status not in ("ready", "hidden"):
|
|
76
|
+
# for all preparing/error/deleted status, we display the real status
|
|
77
|
+
return db_status
|
|
78
|
+
# Note: hidden is a deprecated status value, we consider hidden pictures as ready (it's the visibility that matters)
|
|
79
|
+
# if the sequence is 'ready' we do not display any status (as it is the default state)
|
|
80
|
+
# Note that some route are explicit about the default value, so we return it
|
|
81
|
+
return "ready" if explicit else None
|
|
82
|
+
|
|
83
|
+
|
|
71
84
|
def dbSequenceToStacCollection(dbSeq, description="A sequence of geolocated pictures"):
|
|
72
85
|
"""Transforms a sequence extracted from database into a STAC Collection
|
|
73
86
|
|
|
@@ -83,8 +96,15 @@ def dbSequenceToStacCollection(dbSeq, description="A sequence of geolocated pict
|
|
|
83
96
|
object
|
|
84
97
|
The equivalent in STAC Collection format
|
|
85
98
|
"""
|
|
99
|
+
if dbSeq.get("is_sequence_visible_by_user") is False or dbSeq.get("status") == "deleted":
|
|
100
|
+
# if the sequence is not visible for a given user (it might be because it has been deleted or hidden), we only display its id and its status
|
|
101
|
+
return {"id": dbSeq["id"], "geovisio:status": "deleted"}
|
|
86
102
|
mints, maxts = dbSeq.get("mints"), dbSeq.get("maxts")
|
|
87
103
|
nb_pic = int(dbSeq.get("nbpic")) if "nbpic" in dbSeq else None
|
|
104
|
+
|
|
105
|
+
# we do not want to add a `geovisio:status` = 'ready', we only use it for hidden/deleted status
|
|
106
|
+
exposed_status = retrocompatible_sequence_status(dbSeq)
|
|
107
|
+
|
|
88
108
|
return removeNoneInDict(
|
|
89
109
|
{
|
|
90
110
|
"type": "Collection",
|
|
@@ -97,13 +117,12 @@ def dbSequenceToStacCollection(dbSeq, description="A sequence of geolocated pict
|
|
|
97
117
|
"title": str(dbSeq["name"]),
|
|
98
118
|
"description": description,
|
|
99
119
|
"keywords": ["pictures", str(dbSeq["name"])],
|
|
100
|
-
"semantics": dbSeq
|
|
120
|
+
"semantics": dbSeq.get("semantics", []),
|
|
101
121
|
"license": current_app.config["API_PICTURES_LICENSE_SPDX_ID"],
|
|
102
122
|
"created": dbTsToStac(dbSeq["created"]),
|
|
103
123
|
"updated": dbTsToStac(dbSeq.get("updated")),
|
|
104
|
-
"geovisio:status":
|
|
105
|
-
|
|
106
|
-
), # we do not want to add a `geovisio:status` = 'ready', we only use it for hidden/deleted status
|
|
124
|
+
"geovisio:status": exposed_status,
|
|
125
|
+
"geovisio:visibility": dbSeq.get("visibility"),
|
|
107
126
|
"geovisio:sorted-by": dbSeq.get("current_sort"),
|
|
108
127
|
"geovisio:upload-software": userAgentToClient(dbSeq.get("user_agent")).value,
|
|
109
128
|
"geovisio:length_km": dbSeq.get("length_km"),
|
|
@@ -160,6 +179,16 @@ def dbSequenceToStacCollection(dbSeq, description="A sequence of geolocated pict
|
|
|
160
179
|
"href": url_for("stac_collections.getCollection", _external=True, collectionId=dbSeq["id"]),
|
|
161
180
|
},
|
|
162
181
|
get_license_link(),
|
|
182
|
+
(
|
|
183
|
+
{
|
|
184
|
+
"rel": "upload_set",
|
|
185
|
+
"type": "application/json",
|
|
186
|
+
"title": "Link to the upload set",
|
|
187
|
+
"href": url_for("upload_set.getUploadSet", _external=True, upload_set_id=dbSeq["upload_set_id"]),
|
|
188
|
+
}
|
|
189
|
+
if dbSeq.get("upload_set_id")
|
|
190
|
+
else None
|
|
191
|
+
),
|
|
163
192
|
]
|
|
164
193
|
),
|
|
165
194
|
}
|
|
@@ -200,6 +229,17 @@ def getAllCollections():
|
|
|
200
229
|
default: json
|
|
201
230
|
- $ref: '#/components/parameters/STAC_bbox'
|
|
202
231
|
- $ref: '#/components/parameters/STAC_collections_filter'
|
|
232
|
+
|
|
233
|
+
- name: show_deleted
|
|
234
|
+
in: query
|
|
235
|
+
description: >-
|
|
236
|
+
Show the deleted collections in a separate `deleted_collections` field. Usefull when crawling the catalog to know which collections have been deleted.
|
|
237
|
+
The deleted collections are returned in the same `collections` list, but the deleted collection will only have their `id` and a `deleted` `geovisio:status`, without additional fields.
|
|
238
|
+
Note that thus, when using this parameter, the response does no longer follow the STAC format for deleted collections.
|
|
239
|
+
required: false
|
|
240
|
+
schema:
|
|
241
|
+
type: boolean
|
|
242
|
+
default: false
|
|
203
243
|
- name: datetime
|
|
204
244
|
in: query
|
|
205
245
|
required: false
|
|
@@ -235,7 +275,6 @@ def getAllCollections():
|
|
|
235
275
|
schema:
|
|
236
276
|
$ref: '#/components/schemas/GeoVisioCollectionsRSS'
|
|
237
277
|
"""
|
|
238
|
-
|
|
239
278
|
args = request.args
|
|
240
279
|
|
|
241
280
|
# Expected output format
|
|
@@ -247,7 +286,7 @@ def getAllCollections():
|
|
|
247
286
|
format = "rss"
|
|
248
287
|
|
|
249
288
|
# Sort-by parameter
|
|
250
|
-
sortBy =
|
|
289
|
+
sortBy = parse_collection_sortby(request.args.get("sortby"))
|
|
251
290
|
if not sortBy:
|
|
252
291
|
direction = SQLDirection.DESC if format == "rss" else SQLDirection.ASC
|
|
253
292
|
sortBy = SortBy(fields=[SortByField(field=STAC_FIELD_MAPPINGS["created"], direction=direction)])
|
|
@@ -259,11 +298,21 @@ def getAllCollections():
|
|
|
259
298
|
sortBy.fields.append(SortByField(field=STAC_FIELD_MAPPINGS["id"], direction=SQLDirection.ASC))
|
|
260
299
|
|
|
261
300
|
collection_request = CollectionsRequest(sort_by=sortBy)
|
|
301
|
+
collection_request.show_deleted = parse_boolean(request.args.get("show_deleted"))
|
|
262
302
|
|
|
263
303
|
# Filter parameter
|
|
264
|
-
|
|
304
|
+
cql_filter = request.args.get("filter")
|
|
305
|
+
if cql_filter and "status IN ('deleted','ready') AND" in cql_filter:
|
|
306
|
+
# Note handle a bit or retrocompatibility: we used to accept a `status` filter for the metacatalog, this we deprecated this in favour of the `show_deleted` parameter
|
|
307
|
+
collection_request.show_deleted = True
|
|
308
|
+
cql_filter = cql_filter.replace("status IN ('deleted','ready') AND", "")
|
|
309
|
+
collection_request.user_filter = parse_collection_filter(cql_filter)
|
|
310
|
+
|
|
265
311
|
collection_request.pagination_filter = parse_collection_filter(request.args.get("page"))
|
|
266
312
|
|
|
313
|
+
if collection_request.show_deleted and format == "rss":
|
|
314
|
+
raise errors.InvalidAPIUsage(_("RSS format does not support deleted sequences"), status_code=400)
|
|
315
|
+
|
|
267
316
|
# Limit parameter
|
|
268
317
|
collection_request.limit = parse_collections_limit(request.args.get("limit"))
|
|
269
318
|
|
|
@@ -305,17 +354,34 @@ def getAllCollections():
|
|
|
305
354
|
created_after=args.get("created_after"),
|
|
306
355
|
),
|
|
307
356
|
},
|
|
357
|
+
{
|
|
358
|
+
"title": "Queryables",
|
|
359
|
+
"href": url_for("queryables.collection_queryables", _external=True),
|
|
360
|
+
"rel": "http://www.opengis.net/def/rel/ogc/1.0/queryables",
|
|
361
|
+
"type": "application/schema+json",
|
|
362
|
+
},
|
|
308
363
|
]
|
|
309
364
|
|
|
310
365
|
with db.conn(current_app) as conn:
|
|
311
|
-
|
|
312
|
-
if
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
366
|
+
account_to_query = auth.get_current_account()
|
|
367
|
+
if account_to_query is not None and account_to_query.can_see_all():
|
|
368
|
+
meta_filter = [SQL("TRUE")]
|
|
369
|
+
else:
|
|
370
|
+
meta_filter = [SQL("is_sequence_visible_by_user(s, %(account_to_query)s)")]
|
|
371
|
+
if collection_request.user_filter is not None:
|
|
372
|
+
meta_filter.append(collection_request.user_filter)
|
|
373
|
+
datasetBounds = get_dataset_bounds(
|
|
374
|
+
conn,
|
|
375
|
+
collection_request.sort_by,
|
|
376
|
+
additional_filters=SQL(" AND ").join(meta_filter),
|
|
377
|
+
account_to_query_id=account_to_query.id if account_to_query is not None else None,
|
|
378
|
+
)
|
|
379
|
+
if datasetBounds is not None:
|
|
380
|
+
creation_date_index = collection_request.sort_by.get_field_index("created")
|
|
381
|
+
if collection_request.created_after and collection_request.created_after > datasetBounds.last[creation_date_index]:
|
|
382
|
+
raise errors.InvalidAPIUsage(_("There is no collection created after %(d)s", d=collection_request.created_after))
|
|
383
|
+
if collection_request.created_before and collection_request.created_before < datasetBounds.first[creation_date_index]:
|
|
384
|
+
raise errors.InvalidAPIUsage(_("There is no collection created before %(d)s", d=collection_request.created_before))
|
|
319
385
|
|
|
320
386
|
db_collections = get_collections(collection_request)
|
|
321
387
|
|
|
@@ -324,34 +390,32 @@ def getAllCollections():
|
|
|
324
390
|
return (dbSequencesToGeoRSS(db_collections.collections).rss(), 200, {"Content-Type": "text/xml"})
|
|
325
391
|
|
|
326
392
|
stac_collections = [dbSequenceToStacCollection(c) for c in db_collections.collections]
|
|
327
|
-
|
|
393
|
+
if datasetBounds is not None:
|
|
394
|
+
pagination_links = []
|
|
328
395
|
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
pagination_links = sequences.get_pagination_links(
|
|
332
|
-
route="stac_collections.getAllCollections",
|
|
333
|
-
routeArgs={"limit": collection_request.limit},
|
|
334
|
-
sortBy=sortBy,
|
|
335
|
-
datasetBounds=datasetBounds,
|
|
336
|
-
dataBounds=db_collections.query_bounds,
|
|
337
|
-
additional_filters=additional_filters,
|
|
338
|
-
)
|
|
396
|
+
additional_filters = request.args.get("filter")
|
|
339
397
|
|
|
340
|
-
|
|
341
|
-
|
|
398
|
+
# Compute paginated links
|
|
399
|
+
pagination_links = sequences.get_pagination_links(
|
|
400
|
+
route="stac_collections.getAllCollections",
|
|
401
|
+
routeArgs={"limit": collection_request.limit},
|
|
402
|
+
sortBy=sortBy,
|
|
403
|
+
datasetBounds=datasetBounds,
|
|
404
|
+
dataBounds=db_collections.query_bounds,
|
|
405
|
+
additional_filters=additional_filters,
|
|
406
|
+
showDeleted=collection_request.show_deleted,
|
|
407
|
+
)
|
|
408
|
+
links.extend(pagination_links)
|
|
342
409
|
|
|
343
410
|
return (
|
|
344
|
-
{
|
|
345
|
-
"collections": stac_collections,
|
|
346
|
-
"links": links,
|
|
347
|
-
},
|
|
411
|
+
removeNoneInDict({"collections": stac_collections, "links": links}),
|
|
348
412
|
200,
|
|
349
413
|
{"Content-Type": "application/json"},
|
|
350
414
|
)
|
|
351
415
|
|
|
352
416
|
|
|
353
417
|
@bp.route("/collections/<uuid:collectionId>")
|
|
354
|
-
def getCollection(collectionId):
|
|
418
|
+
def getCollection(collectionId, account=None):
|
|
355
419
|
"""Retrieve metadata of a single collection
|
|
356
420
|
---
|
|
357
421
|
tags:
|
|
@@ -372,18 +436,24 @@ def getCollection(collectionId):
|
|
|
372
436
|
$ref: '#/components/schemas/GeoVisioCollection'
|
|
373
437
|
"""
|
|
374
438
|
|
|
375
|
-
account = auth.get_current_account()
|
|
439
|
+
account = account or auth.get_current_account()
|
|
376
440
|
|
|
377
441
|
params = {
|
|
378
442
|
"id": collectionId,
|
|
379
443
|
# Only the owner of an account can view sequence not 'ready'
|
|
380
444
|
"account": account.id if account is not None else None,
|
|
381
445
|
}
|
|
446
|
+
perm_filter = SQL("")
|
|
447
|
+
if account is not None and account.can_see_all():
|
|
448
|
+
# admins can see all the collections
|
|
449
|
+
perm_filter = SQL("TRUE")
|
|
450
|
+
else:
|
|
451
|
+
perm_filter = SQL("is_sequence_visible_by_user(s, %(account)s)")
|
|
382
452
|
|
|
383
453
|
record = db.fetchone(
|
|
384
454
|
current_app,
|
|
385
|
-
|
|
386
|
-
SELECT
|
|
455
|
+
SQL(
|
|
456
|
+
"""SELECT
|
|
387
457
|
s.id,
|
|
388
458
|
s.metadata->>'title' AS name,
|
|
389
459
|
ST_XMin(s.bbox) AS minx,
|
|
@@ -391,8 +461,10 @@ def getCollection(collectionId):
|
|
|
391
461
|
ST_XMax(s.bbox) AS maxx,
|
|
392
462
|
ST_YMax(s.bbox) AS maxy,
|
|
393
463
|
s.status AS status,
|
|
464
|
+
s.visibility,
|
|
394
465
|
accounts.name AS account_name,
|
|
395
466
|
s.account_id AS account_id,
|
|
467
|
+
s.upload_set_id,
|
|
396
468
|
s.inserted_at AS created,
|
|
397
469
|
s.updated_at AS updated,
|
|
398
470
|
s.current_sort AS current_sort,
|
|
@@ -404,7 +476,7 @@ def getCollection(collectionId):
|
|
|
404
476
|
ROUND(ST_Length(s.geom::geography)) / 1000 as length_km,
|
|
405
477
|
s.computed_h_pixel_density,
|
|
406
478
|
s.computed_gps_accuracy,
|
|
407
|
-
|
|
479
|
+
COALESCE(seq_sem.semantics, '[]'::json) AS semantics
|
|
408
480
|
FROM sequences s
|
|
409
481
|
LEFT JOIN (
|
|
410
482
|
SELECT sequence_id, json_agg(json_strip_nulls(json_build_object(
|
|
@@ -413,7 +485,7 @@ def getCollection(collectionId):
|
|
|
413
485
|
)) ORDER BY key, value) AS semantics
|
|
414
486
|
FROM sequences_semantics
|
|
415
487
|
GROUP BY sequence_id
|
|
416
|
-
)
|
|
488
|
+
) seq_sem ON seq_sem.sequence_id = s.id
|
|
417
489
|
JOIN accounts ON s.account_id = accounts.id, (
|
|
418
490
|
SELECT
|
|
419
491
|
array_agg(DISTINCT jsonb_build_object(
|
|
@@ -426,9 +498,10 @@ def getCollection(collectionId):
|
|
|
426
498
|
JOIN sequences_pictures sp ON sp.seq_id = %(id)s AND sp.pic_id = p.id
|
|
427
499
|
) a
|
|
428
500
|
WHERE s.id = %(id)s
|
|
429
|
-
AND
|
|
501
|
+
AND {perm_filter}
|
|
430
502
|
AND s.status != 'deleted'
|
|
431
|
-
"""
|
|
503
|
+
"""
|
|
504
|
+
).format(perm_filter=perm_filter),
|
|
432
505
|
params,
|
|
433
506
|
row_factory=dict_row,
|
|
434
507
|
)
|
|
@@ -467,12 +540,10 @@ def getCollectionThumbnail(collectionId):
|
|
|
467
540
|
type: string
|
|
468
541
|
format: binary
|
|
469
542
|
"""
|
|
470
|
-
account = auth.get_current_account()
|
|
471
|
-
|
|
472
543
|
params = {
|
|
473
544
|
"seq": collectionId,
|
|
474
545
|
# Only the owner of an account can view pictures not 'ready'
|
|
475
|
-
"account":
|
|
546
|
+
"account": auth.get_current_account_id(),
|
|
476
547
|
}
|
|
477
548
|
|
|
478
549
|
records = db.fetchone(
|
|
@@ -485,6 +556,7 @@ def getCollectionThumbnail(collectionId):
|
|
|
485
556
|
WHERE
|
|
486
557
|
sp.seq_id = %(seq)s
|
|
487
558
|
AND (p.status = 'ready' OR p.account_id = %(account)s)
|
|
559
|
+
AND is_picture_visible_by_user(p, %(account)s)
|
|
488
560
|
AND is_sequence_visible_by_user(s, %(account)s)
|
|
489
561
|
ORDER BY RANK ASC
|
|
490
562
|
LIMIT 1""",
|
|
@@ -502,6 +574,10 @@ def getCollectionThumbnail(collectionId):
|
|
|
502
574
|
@auth.login_required_by_setting("API_FORCE_AUTH_ON_UPLOAD")
|
|
503
575
|
def postCollection(account=None):
|
|
504
576
|
"""Create a new sequence
|
|
577
|
+
|
|
578
|
+
Note that this is the legacy API, upload should be done using the [UploadSet](#UploadSet) endpoints if possible.
|
|
579
|
+
|
|
580
|
+
Using an upload set makes it possible to handle more use cases like dispatching pictures into several collections, removing capture duplicates, parralele upload, ...
|
|
505
581
|
---
|
|
506
582
|
tags:
|
|
507
583
|
- Upload
|
|
@@ -511,7 +587,7 @@ def postCollection(account=None):
|
|
|
511
587
|
required: false
|
|
512
588
|
schema:
|
|
513
589
|
type: string
|
|
514
|
-
description: An explicit User-Agent value is
|
|
590
|
+
description: An explicit User-Agent value is preferred if you create a production-ready tool, formatted like "PanoramaxCLI/1.0"
|
|
515
591
|
requestBody:
|
|
516
592
|
content:
|
|
517
593
|
application/json:
|
|
@@ -546,12 +622,12 @@ def postCollection(account=None):
|
|
|
546
622
|
metadata = removeNoneInDict(metadata)
|
|
547
623
|
|
|
548
624
|
# Create sequence folder
|
|
549
|
-
|
|
550
|
-
seqId = sequences.createSequence(metadata,
|
|
625
|
+
account = accountOrDefault(account)
|
|
626
|
+
seqId = sequences.createSequence(metadata, account.id, request.user_agent.string)
|
|
551
627
|
|
|
552
628
|
# Return created sequence
|
|
553
629
|
return (
|
|
554
|
-
getCollection(seqId)[0],
|
|
630
|
+
getCollection(seqId, account=account)[0],
|
|
555
631
|
200,
|
|
556
632
|
{
|
|
557
633
|
"Content-Type": "application/json",
|
|
@@ -565,9 +641,23 @@ class PatchCollectionParameter(BaseModel):
|
|
|
565
641
|
"""Parameters used to add an item to an UploadSet"""
|
|
566
642
|
|
|
567
643
|
relative_heading: Optional[int] = None
|
|
568
|
-
"""The relative heading (in degrees), offset based on movement path (0° = looking forward, -90° = looking left, 90° = looking right). Headings are unchanged if this parameter is not set."""
|
|
644
|
+
"""The relative heading (in degrees), offset based on movement path (0° = looking forward, -90° = looking left, 90° = looking right). For single picture collections, 0° is heading north). Headings are unchanged if this parameter is not set."""
|
|
569
645
|
visible: Optional[bool] = None
|
|
570
|
-
"""Should the sequence be publicly visible ?
|
|
646
|
+
"""Should the sequence be publicly visible ?
|
|
647
|
+
|
|
648
|
+
This parameter is deprecated in favor of the finer grained `visibility` parameter.
|
|
649
|
+
`visible=true` is equivalent to `visibility=anyone`.
|
|
650
|
+
`visible=false` is equivalent to `visibility=logged-only`.
|
|
651
|
+
"""
|
|
652
|
+
visibility: Optional[Visibility] = None
|
|
653
|
+
"""Visibility of the sequence. Can be set to:
|
|
654
|
+
* `anyone`: the sequence is visible to anyone
|
|
655
|
+
* `owner-only`: the sequence is visible to the owner and administrator only
|
|
656
|
+
* `logged-only`: the sequence is visible to logged users only
|
|
657
|
+
|
|
658
|
+
This visibility can also be set for each picture individually, using the `visibility` field of the pictures.
|
|
659
|
+
If not set at the sequence level, it will default to the visibility of the `upload_set` and if not set the default visibility of the `account` and if not set the default visibility of the instance.
|
|
660
|
+
"""
|
|
571
661
|
title: Optional[str] = Field(max_length=250, default=None)
|
|
572
662
|
"""The sequence title (publicly displayed)"""
|
|
573
663
|
sortby: Optional[str] = None
|
|
@@ -610,19 +700,30 @@ If unset, sort order is unchanged."""
|
|
|
610
700
|
@field_validator("relative_heading", mode="before")
|
|
611
701
|
@classmethod
|
|
612
702
|
def parse_relative_heading(cls, value):
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
703
|
+
return parse_relative_heading(value)
|
|
704
|
+
|
|
705
|
+
@model_validator(mode="after")
|
|
706
|
+
def validate(self):
|
|
707
|
+
if self.visibility is not None and self.visible is not None:
|
|
708
|
+
raise errors.InvalidAPIUsage(_("Visibility and visible parameters are mutually exclusive parameters"))
|
|
709
|
+
# handle retrocompatibility on the visible parameter
|
|
710
|
+
if self.visible is not None:
|
|
711
|
+
self.visibility = Visibility.anyone if self.visible is True else Visibility.owner_only
|
|
712
|
+
return self
|
|
622
713
|
|
|
623
714
|
def has_only_semantics_updates(self):
|
|
624
715
|
return self.model_fields_set == {"semantics"}
|
|
625
716
|
|
|
717
|
+
@field_validator("visibility", mode="after")
|
|
718
|
+
@classmethod
|
|
719
|
+
def validate_visibility(cls, visibility):
|
|
720
|
+
if not check_visibility(visibility):
|
|
721
|
+
raise errors.InvalidAPIUsage(
|
|
722
|
+
_("The logged-only visibility is not allowed on this instance since anybody can create an account"),
|
|
723
|
+
status_code=400,
|
|
724
|
+
)
|
|
725
|
+
return visibility
|
|
726
|
+
|
|
626
727
|
|
|
627
728
|
@bp.route("/collections/<uuid:collectionId>", methods=["PATCH"])
|
|
628
729
|
@auth.login_required()
|
|
@@ -688,14 +789,15 @@ def patchCollection(collectionId, account):
|
|
|
688
789
|
with conn.transaction():
|
|
689
790
|
with conn.cursor(row_factory=dict_row) as cursor:
|
|
690
791
|
seq = cursor.execute(
|
|
691
|
-
"SELECT
|
|
792
|
+
"SELECT metadata, account_id, current_sort, visibility FROM sequences WHERE id = %s AND status != 'deleted'",
|
|
793
|
+
[collectionId],
|
|
692
794
|
).fetchone()
|
|
693
795
|
|
|
694
796
|
# Sequence not found
|
|
695
797
|
if not seq:
|
|
696
798
|
raise errors.InvalidAPIUsage(_("Collection %(c)s wasn't found in database", c=collectionId), status_code=404)
|
|
697
799
|
|
|
698
|
-
if account is not None and account.
|
|
800
|
+
if account is not None and not account.can_edit_collection(str(seq["account_id"])):
|
|
699
801
|
# Only owner of the sequence is allower to change its visibility and title
|
|
700
802
|
# tags and headings can be changed by anyone
|
|
701
803
|
if metadata.visible is not None or metadata.title is not None:
|
|
@@ -714,26 +816,18 @@ def patchCollection(collectionId, account):
|
|
|
714
816
|
status_code=403,
|
|
715
817
|
)
|
|
716
818
|
|
|
717
|
-
|
|
819
|
+
oldVisibility = seq["visibility"]
|
|
718
820
|
oldMetadata = seq["metadata"]
|
|
719
821
|
oldTitle = oldMetadata.get("title")
|
|
720
822
|
|
|
721
|
-
# Check if sequence is in a preparing/broken/... state so no edit possible
|
|
722
|
-
if oldStatus not in ["ready", "hidden"]:
|
|
723
|
-
if metadata.visible is not None:
|
|
724
|
-
raise errors.InvalidAPIUsage(
|
|
725
|
-
_("Sequence %(c)s is in %(s)s state, its visibility can't be changed for now", c=collectionId, s=oldStatus),
|
|
726
|
-
status_code=400,
|
|
727
|
-
)
|
|
728
|
-
|
|
729
823
|
sqlUpdates = []
|
|
730
824
|
sqlParams = {"id": collectionId, "account": account.id}
|
|
731
825
|
|
|
732
|
-
if metadata.
|
|
733
|
-
|
|
734
|
-
if
|
|
735
|
-
sqlUpdates.append(SQL("
|
|
736
|
-
sqlParams["
|
|
826
|
+
if metadata.visibility is not None:
|
|
827
|
+
newVisibility = metadata.visibility.value
|
|
828
|
+
if newVisibility != oldVisibility:
|
|
829
|
+
sqlUpdates.append(SQL("visibility = %(visibility)s"))
|
|
830
|
+
sqlParams["visibility"] = newVisibility
|
|
737
831
|
|
|
738
832
|
new_metadata = {}
|
|
739
833
|
if metadata.title is not None and oldTitle != metadata.title:
|
|
@@ -841,21 +935,20 @@ def getCollectionImportStatus(collectionId):
|
|
|
841
935
|
schema:
|
|
842
936
|
$ref: '#/components/schemas/GeoVisioCollectionImportStatus'
|
|
843
937
|
"""
|
|
844
|
-
|
|
845
|
-
account = auth.get_current_account()
|
|
846
|
-
params = {"seq_id": collectionId, "account": account.id if account is not None else None}
|
|
938
|
+
params = {"seq_id": collectionId, "account": auth.get_current_account_id()}
|
|
847
939
|
with db.cursor(current_app, row_factory=dict_row) as cursor:
|
|
848
940
|
sequence_status = cursor.execute(
|
|
849
941
|
SQL(
|
|
850
|
-
"""SELECT status
|
|
942
|
+
"""SELECT status, visibility
|
|
851
943
|
FROM sequences
|
|
852
944
|
WHERE id = %(seq_id)s
|
|
853
|
-
AND (
|
|
945
|
+
AND is_sequence_visible_by_user(sequences, %(account)s)
|
|
946
|
+
AND status != 'deleted'""",
|
|
854
947
|
),
|
|
855
948
|
params,
|
|
856
949
|
).fetchone()
|
|
857
950
|
if sequence_status is None:
|
|
858
|
-
raise errors.InvalidAPIUsage(_("Sequence doesn't
|
|
951
|
+
raise errors.InvalidAPIUsage(_("Sequence doesn't exist"), status_code=404)
|
|
859
952
|
|
|
860
953
|
pics_status = cursor.execute(
|
|
861
954
|
"""WITH
|
|
@@ -888,8 +981,8 @@ pic_jobs_stats AS (
|
|
|
888
981
|
JOIN pictures p ON sp.pic_id = p.id
|
|
889
982
|
LEFT JOIN pic_jobs_stats ON pic_jobs_stats.picture_id = p.id
|
|
890
983
|
WHERE
|
|
891
|
-
|
|
892
|
-
|
|
984
|
+
s.id = %(seq_id)s
|
|
985
|
+
AND (p IS NULL OR is_picture_visible_by_user(p, %(account)s))
|
|
893
986
|
ORDER BY s.id, sp.rank
|
|
894
987
|
)
|
|
895
988
|
SELECT json_strip_nulls(
|
|
@@ -1036,7 +1129,7 @@ def getUserCollection(userId, userIdMatchesAccount=False):
|
|
|
1036
1129
|
format = "csv"
|
|
1037
1130
|
|
|
1038
1131
|
# Sort-by parameter
|
|
1039
|
-
sortBy =
|
|
1132
|
+
sortBy = parse_collection_sortby(request.args.get("sortby"))
|
|
1040
1133
|
if not sortBy:
|
|
1041
1134
|
sortBy = SortBy(fields=[SortByField(field=STAC_FIELD_MAPPINGS["created"], direction=SQLDirection.DESC)])
|
|
1042
1135
|
|
|
@@ -1046,6 +1139,8 @@ def getUserCollection(userId, userIdMatchesAccount=False):
|
|
|
1046
1139
|
sortBy.fields.append(SortByField(field=STAC_FIELD_MAPPINGS["id"], direction=SQLDirection.ASC))
|
|
1047
1140
|
collection_request = CollectionsRequest(sort_by=sortBy, userOwnsAllCollections=userIdMatchesAccount)
|
|
1048
1141
|
|
|
1142
|
+
account_to_query_id = auth.get_current_account_id()
|
|
1143
|
+
|
|
1049
1144
|
# Filter parameter
|
|
1050
1145
|
collection_request.user_filter = parse_collection_filter(request.args.get("filter"))
|
|
1051
1146
|
|
|
@@ -1071,16 +1166,16 @@ def getUserCollection(userId, userIdMatchesAccount=False):
|
|
|
1071
1166
|
meta_filter = [
|
|
1072
1167
|
SQL("{field} IS NOT NULL").format(field=collection_request.sort_by.fields[0].field.sql_filter),
|
|
1073
1168
|
SQL("s.account_id = %(account)s"),
|
|
1169
|
+
SQL("is_sequence_visible_by_user(s, %(account_to_query)s)"),
|
|
1074
1170
|
]
|
|
1075
1171
|
if collection_request.user_filter is not None:
|
|
1076
1172
|
meta_filter.append(collection_request.user_filter)
|
|
1077
1173
|
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
meta_filter.append(SQL("s.status != 'deleted'"))
|
|
1174
|
+
# we want to show only 'ready' collection to the general users, and non deleted one for the owner
|
|
1175
|
+
if not userIdMatchesAccount:
|
|
1176
|
+
meta_filter.extend([SQL("s.status = 'ready'")])
|
|
1177
|
+
else:
|
|
1178
|
+
meta_filter.append(SQL("s.status != 'deleted'"))
|
|
1084
1179
|
|
|
1085
1180
|
# Check user account parameter
|
|
1086
1181
|
with db.cursor(current_app, row_factory=dict_row) as cursor:
|
|
@@ -1114,7 +1209,7 @@ def getUserCollection(userId, userIdMatchesAccount=False):
|
|
|
1114
1209
|
filter=SQL(" AND ").join(meta_filter),
|
|
1115
1210
|
order_column=collection_request.sort_by.fields[0].field.sql_filter,
|
|
1116
1211
|
),
|
|
1117
|
-
params={"account": userId},
|
|
1212
|
+
params={"account": userId, "account_to_query": account_to_query_id},
|
|
1118
1213
|
).fetchone()
|
|
1119
1214
|
|
|
1120
1215
|
if not meta_collection or meta_collection["created"] is None:
|
|
@@ -1129,6 +1224,7 @@ def getUserCollection(userId, userIdMatchesAccount=False):
|
|
|
1129
1224
|
collection_request.sort_by,
|
|
1130
1225
|
additional_filters=SQL(" AND ").join(meta_filter),
|
|
1131
1226
|
additional_filters_params={"account": userId},
|
|
1227
|
+
account_to_query_id=account_to_query_id,
|
|
1132
1228
|
)
|
|
1133
1229
|
|
|
1134
1230
|
collections = get_collections(collection_request)
|
|
@@ -1154,7 +1250,8 @@ def getUserCollection(userId, userIdMatchesAccount=False):
|
|
|
1154
1250
|
},
|
|
1155
1251
|
"spatial": {"bbox": [[s["minx"] or -180.0, s["miny"] or -90.0, s["maxx"] or 180.0, s["maxy"] or 90.0]]},
|
|
1156
1252
|
},
|
|
1157
|
-
"geovisio:status": s
|
|
1253
|
+
"geovisio:status": retrocompatible_sequence_status(s, explicit=True) if userIdMatchesAccount else None,
|
|
1254
|
+
"geovisio:visibility": s["visibility"] if userIdMatchesAccount else None,
|
|
1158
1255
|
"geovisio:length_km": s.get("length_km"),
|
|
1159
1256
|
}
|
|
1160
1257
|
)
|
geovisio/web/configuration.py
CHANGED
|
@@ -1,15 +1,21 @@
|
|
|
1
1
|
import flask
|
|
2
2
|
from typing import Dict, Any
|
|
3
|
-
from flask import jsonify
|
|
3
|
+
from flask import jsonify, current_app
|
|
4
4
|
from flask_babel import get_locale
|
|
5
5
|
from geovisio.web.utils import get_api_version
|
|
6
|
+
from geovisio.web.params import Visibility
|
|
7
|
+
from geovisio.utils import db
|
|
8
|
+
from psycopg.rows import class_row
|
|
9
|
+
from typing import Optional
|
|
10
|
+
from pydantic import BaseModel, Field, ConfigDict, field_serializer
|
|
11
|
+
import datetime
|
|
6
12
|
|
|
7
13
|
bp = flask.Blueprint("configuration", __name__, url_prefix="/api")
|
|
8
14
|
|
|
9
15
|
|
|
10
16
|
@bp.route("/configuration")
|
|
11
17
|
def configuration():
|
|
12
|
-
"""Return instance configuration
|
|
18
|
+
"""Return instance configuration information
|
|
13
19
|
---
|
|
14
20
|
tags:
|
|
15
21
|
- Metadata
|
|
@@ -36,6 +42,8 @@ def configuration():
|
|
|
36
42
|
"license": _license_configuration(),
|
|
37
43
|
"version": get_api_version(),
|
|
38
44
|
"pages": _get_pages(),
|
|
45
|
+
"defaults": _get_default_values(),
|
|
46
|
+
"visibility": {"possible_values": _get_possible_visibility_values()},
|
|
39
47
|
}
|
|
40
48
|
)
|
|
41
49
|
|
|
@@ -66,10 +74,34 @@ def _license_configuration():
|
|
|
66
74
|
return l
|
|
67
75
|
|
|
68
76
|
|
|
77
|
+
def _get_possible_visibility_values():
|
|
78
|
+
val = ["anyone", "owner-only"]
|
|
79
|
+
if not flask.current_app.config["API_REGISTRATION_IS_OPEN"]:
|
|
80
|
+
val.insert(1, "logged-only")
|
|
81
|
+
return val
|
|
82
|
+
|
|
83
|
+
|
|
69
84
|
def _get_pages():
|
|
70
|
-
from geovisio.utils import db
|
|
71
|
-
from flask import current_app
|
|
72
85
|
|
|
73
86
|
pages = db.fetchall(current_app, "SELECT distinct(name) FROM pages")
|
|
74
87
|
|
|
75
88
|
return [p[0] for p in pages]
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class Config(BaseModel):
|
|
92
|
+
collaborative_metadata: Optional[bool]
|
|
93
|
+
split_distance: Optional[int] = Field(validation_alias="default_split_distance")
|
|
94
|
+
split_time: Optional[datetime.timedelta] = Field(validation_alias="default_split_time")
|
|
95
|
+
duplicate_distance: Optional[float] = Field(validation_alias="default_duplicate_distance")
|
|
96
|
+
duplicate_rotation: Optional[int] = Field(validation_alias="default_duplicate_rotation")
|
|
97
|
+
default_visibility: Visibility
|
|
98
|
+
|
|
99
|
+
@field_serializer("split_time")
|
|
100
|
+
def split_time_to_s(self, s: datetime.timedelta, _):
|
|
101
|
+
return s.total_seconds()
|
|
102
|
+
|
|
103
|
+
model_config = ConfigDict(use_enum_values=True)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _get_default_values():
|
|
107
|
+
return db.fetchone(current_app, "SELECT * FROM configurations", row_factory=class_row(Config)).model_dump()
|