geovisio 2.10.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 +3 -1
- geovisio/admin_cli/user.py +7 -2
- geovisio/config_app.py +21 -7
- geovisio/translations/be/LC_MESSAGES/messages.mo +0 -0
- geovisio/translations/be/LC_MESSAGES/messages.po +886 -0
- geovisio/translations/da/LC_MESSAGES/messages.mo +0 -0
- geovisio/translations/da/LC_MESSAGES/messages.po +96 -5
- geovisio/translations/de/LC_MESSAGES/messages.mo +0 -0
- geovisio/translations/de/LC_MESSAGES/messages.po +171 -132
- geovisio/translations/en/LC_MESSAGES/messages.mo +0 -0
- geovisio/translations/en/LC_MESSAGES/messages.po +169 -146
- geovisio/translations/eo/LC_MESSAGES/messages.mo +0 -0
- geovisio/translations/eo/LC_MESSAGES/messages.po +3 -2
- geovisio/translations/fr/LC_MESSAGES/messages.mo +0 -0
- geovisio/translations/fr/LC_MESSAGES/messages.po +3 -2
- geovisio/translations/it/LC_MESSAGES/messages.mo +0 -0
- geovisio/translations/it/LC_MESSAGES/messages.po +1 -1
- geovisio/translations/messages.pot +159 -138
- geovisio/translations/nl/LC_MESSAGES/messages.mo +0 -0
- geovisio/translations/nl/LC_MESSAGES/messages.po +44 -2
- geovisio/translations/oc/LC_MESSAGES/messages.mo +0 -0
- geovisio/translations/oc/LC_MESSAGES/messages.po +9 -6
- 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 +1 -1
- 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/utils/annotations.py +7 -4
- geovisio/utils/auth.py +33 -0
- geovisio/utils/cql2.py +20 -3
- geovisio/utils/pictures.py +16 -18
- geovisio/utils/sequences.py +104 -75
- geovisio/utils/upload_set.py +20 -10
- geovisio/utils/users.py +18 -0
- geovisio/web/annotations.py +96 -3
- geovisio/web/collections.py +169 -76
- geovisio/web/configuration.py +12 -0
- geovisio/web/docs.py +17 -3
- geovisio/web/items.py +129 -72
- geovisio/web/map.py +92 -54
- geovisio/web/pages.py +48 -4
- geovisio/web/params.py +56 -11
- geovisio/web/pictures.py +3 -3
- geovisio/web/prepare.py +4 -2
- geovisio/web/queryables.py +57 -0
- geovisio/web/stac.py +8 -2
- geovisio/web/upload_set.py +83 -26
- geovisio/web/users.py +85 -4
- geovisio/web/utils.py +24 -6
- {geovisio-2.10.0.dist-info → geovisio-2.11.0.dist-info}/METADATA +3 -2
- {geovisio-2.10.0.dist-info → geovisio-2.11.0.dist-info}/RECORD +58 -46
- {geovisio-2.10.0.dist-info → geovisio-2.11.0.dist-info}/WHEEL +0 -0
- {geovisio-2.10.0.dist-info → geovisio-2.11.0.dist-info}/licenses/LICENSE +0 -0
geovisio/web/annotations.py
CHANGED
|
@@ -6,9 +6,9 @@ from geovisio.utils.annotations import (
|
|
|
6
6
|
get_annotation,
|
|
7
7
|
update_annotation,
|
|
8
8
|
InputAnnotationShape,
|
|
9
|
+
delete_annotation,
|
|
9
10
|
)
|
|
10
11
|
from geovisio.utils.tags import SemanticTagUpdate
|
|
11
|
-
from geovisio.web.utils import accountIdOrDefault
|
|
12
12
|
from geovisio.utils.params import validation_error
|
|
13
13
|
from geovisio import errors
|
|
14
14
|
from pydantic import BaseModel, ValidationError, Field
|
|
@@ -73,8 +73,7 @@ def postAnnotationNonStacAlias(itemId, account):
|
|
|
73
73
|
$ref: '#/components/schemas/GeoVisioAnnotation'
|
|
74
74
|
"""
|
|
75
75
|
|
|
76
|
-
account_id =
|
|
77
|
-
|
|
76
|
+
account_id = account.id
|
|
78
77
|
pic = db.fetchone(
|
|
79
78
|
current_app,
|
|
80
79
|
"SELECT 1 FROM pictures WHERE id = %(pic)s",
|
|
@@ -352,3 +351,97 @@ def patchAnnotation(collectionId, itemId, annotationId, account):
|
|
|
352
351
|
description: The annotation was empty, it has been correctly deleted
|
|
353
352
|
"""
|
|
354
353
|
return patchAnnotationNonStacAlias(annotationId=annotationId, account=account)
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
@bp.route("/collections/<uuid:collectionId>/items/<uuid:itemId>/annotations/<uuid:annotationId>", methods=["DELETE"])
|
|
357
|
+
@auth.login_required()
|
|
358
|
+
def deleteAnnotation(collectionId, itemId, annotationId, account):
|
|
359
|
+
"""Delete an annotation
|
|
360
|
+
|
|
361
|
+
It is mandatory to be authenticated to delete an annotation, but anyone can delete do it. The changes are tracked in the history.
|
|
362
|
+
|
|
363
|
+
Note that this is the same route as `DELETE /api/annotations/<uuid:annotationId>` but you need to know the picture's and collection's IDs.
|
|
364
|
+
---
|
|
365
|
+
tags:
|
|
366
|
+
- Semantics
|
|
367
|
+
parameters:
|
|
368
|
+
- name: collectionId
|
|
369
|
+
in: path
|
|
370
|
+
description: ID of collection
|
|
371
|
+
required: true
|
|
372
|
+
schema:
|
|
373
|
+
type: string
|
|
374
|
+
- name: itemId
|
|
375
|
+
in: path
|
|
376
|
+
description: ID of item
|
|
377
|
+
required: true
|
|
378
|
+
schema:
|
|
379
|
+
type: string
|
|
380
|
+
- name: annotationId
|
|
381
|
+
in: path
|
|
382
|
+
description: ID of annotation
|
|
383
|
+
required: true
|
|
384
|
+
schema:
|
|
385
|
+
type: string
|
|
386
|
+
security:
|
|
387
|
+
- bearerToken: []
|
|
388
|
+
- cookieAuth: []
|
|
389
|
+
responses:
|
|
390
|
+
204:
|
|
391
|
+
description: The Annotation has been correctly deleted
|
|
392
|
+
"""
|
|
393
|
+
with db.conn(current_app) as conn:
|
|
394
|
+
|
|
395
|
+
annotation = get_annotation(conn, annotationId)
|
|
396
|
+
if not annotation or annotation.picture_id != itemId:
|
|
397
|
+
raise errors.InvalidAPIUsage(_("Annotation %(p)s not found", p=annotationId), status_code=404)
|
|
398
|
+
|
|
399
|
+
delete_annotation(conn, annotation)
|
|
400
|
+
|
|
401
|
+
return "", 204
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
@bp.route("/annotations/<uuid:annotationId>", methods=["DELETE"])
|
|
405
|
+
@auth.login_required()
|
|
406
|
+
def deleteAnnotationNonStacAlias(annotationId, account):
|
|
407
|
+
"""Delete an annotation.
|
|
408
|
+
|
|
409
|
+
It is mandatory to be authenticated to delete an annotation, but anyone can delete do it. The changes are tracked in the history.
|
|
410
|
+
|
|
411
|
+
The is an alias to the `DELETE /api/collections/<collectionId>/items/<itemId>/annotations/<annotationId>` endpoint (but you don't need to know the collection/item ID here).
|
|
412
|
+
---
|
|
413
|
+
tags:
|
|
414
|
+
- Semantics
|
|
415
|
+
parameters:
|
|
416
|
+
- name: collectionId
|
|
417
|
+
in: path
|
|
418
|
+
description: ID of collection
|
|
419
|
+
required: true
|
|
420
|
+
schema:
|
|
421
|
+
type: string
|
|
422
|
+
- name: itemId
|
|
423
|
+
in: path
|
|
424
|
+
description: ID of item
|
|
425
|
+
required: true
|
|
426
|
+
schema:
|
|
427
|
+
type: string
|
|
428
|
+
- name: annotationId
|
|
429
|
+
in: path
|
|
430
|
+
description: ID of annotation
|
|
431
|
+
required: true
|
|
432
|
+
schema:
|
|
433
|
+
type: string
|
|
434
|
+
security:
|
|
435
|
+
- bearerToken: []
|
|
436
|
+
- cookieAuth: []
|
|
437
|
+
responses:
|
|
438
|
+
204:
|
|
439
|
+
description: The Annotation has been correctly deleted
|
|
440
|
+
"""
|
|
441
|
+
with db.conn(current_app) as conn:
|
|
442
|
+
annotation = get_annotation(conn, annotationId)
|
|
443
|
+
if not annotation:
|
|
444
|
+
raise errors.InvalidAPIUsage(_("Annotation %(p)s not found", p=annotationId), status_code=404)
|
|
445
|
+
delete_annotation(conn, annotation, account.id)
|
|
446
|
+
|
|
447
|
+
return "", 204
|
geovisio/web/collections.py
CHANGED
|
@@ -11,6 +11,9 @@ from geovisio.web.params import (
|
|
|
11
11
|
parse_collection_filter,
|
|
12
12
|
parse_collection_sortby,
|
|
13
13
|
parse_collections_limit,
|
|
14
|
+
parse_boolean,
|
|
15
|
+
Visibility,
|
|
16
|
+
check_visibility,
|
|
14
17
|
)
|
|
15
18
|
from geovisio.utils.sequences import (
|
|
16
19
|
STAC_FIELD_MAPPINGS,
|
|
@@ -22,12 +25,12 @@ from geovisio.utils.fields import SortBy, SortByField, SQLDirection, BBox, parse
|
|
|
22
25
|
from geovisio.web.rss import dbSequencesToGeoRSS
|
|
23
26
|
from psycopg.rows import dict_row
|
|
24
27
|
from psycopg.sql import SQL
|
|
25
|
-
from pydantic import BaseModel, Field, ValidationError, field_validator
|
|
28
|
+
from pydantic import BaseModel, Field, ValidationError, field_validator, model_validator
|
|
26
29
|
from flask import current_app, request, url_for, Blueprint, stream_with_context
|
|
27
30
|
from flask_babel import gettext as _
|
|
28
31
|
from geovisio.web.utils import (
|
|
29
32
|
STAC_VERSION,
|
|
30
|
-
|
|
33
|
+
accountOrDefault,
|
|
31
34
|
cleanNoneInDict,
|
|
32
35
|
cleanNoneInList,
|
|
33
36
|
dbTsToStac,
|
|
@@ -66,6 +69,18 @@ def userAgentToClient(user_agent: Optional[str] = None) -> UploadClient:
|
|
|
66
69
|
return UploadClient.other
|
|
67
70
|
|
|
68
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
|
+
|
|
69
84
|
def dbSequenceToStacCollection(dbSeq, description="A sequence of geolocated pictures"):
|
|
70
85
|
"""Transforms a sequence extracted from database into a STAC Collection
|
|
71
86
|
|
|
@@ -81,8 +96,15 @@ def dbSequenceToStacCollection(dbSeq, description="A sequence of geolocated pict
|
|
|
81
96
|
object
|
|
82
97
|
The equivalent in STAC Collection format
|
|
83
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"}
|
|
84
102
|
mints, maxts = dbSeq.get("mints"), dbSeq.get("maxts")
|
|
85
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
|
+
|
|
86
108
|
return removeNoneInDict(
|
|
87
109
|
{
|
|
88
110
|
"type": "Collection",
|
|
@@ -99,9 +121,8 @@ def dbSequenceToStacCollection(dbSeq, description="A sequence of geolocated pict
|
|
|
99
121
|
"license": current_app.config["API_PICTURES_LICENSE_SPDX_ID"],
|
|
100
122
|
"created": dbTsToStac(dbSeq["created"]),
|
|
101
123
|
"updated": dbTsToStac(dbSeq.get("updated")),
|
|
102
|
-
"geovisio:status":
|
|
103
|
-
|
|
104
|
-
), # 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"),
|
|
105
126
|
"geovisio:sorted-by": dbSeq.get("current_sort"),
|
|
106
127
|
"geovisio:upload-software": userAgentToClient(dbSeq.get("user_agent")).value,
|
|
107
128
|
"geovisio:length_km": dbSeq.get("length_km"),
|
|
@@ -208,6 +229,17 @@ def getAllCollections():
|
|
|
208
229
|
default: json
|
|
209
230
|
- $ref: '#/components/parameters/STAC_bbox'
|
|
210
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
|
|
211
243
|
- name: datetime
|
|
212
244
|
in: query
|
|
213
245
|
required: false
|
|
@@ -243,7 +275,6 @@ def getAllCollections():
|
|
|
243
275
|
schema:
|
|
244
276
|
$ref: '#/components/schemas/GeoVisioCollectionsRSS'
|
|
245
277
|
"""
|
|
246
|
-
|
|
247
278
|
args = request.args
|
|
248
279
|
|
|
249
280
|
# Expected output format
|
|
@@ -267,11 +298,21 @@ def getAllCollections():
|
|
|
267
298
|
sortBy.fields.append(SortByField(field=STAC_FIELD_MAPPINGS["id"], direction=SQLDirection.ASC))
|
|
268
299
|
|
|
269
300
|
collection_request = CollectionsRequest(sort_by=sortBy)
|
|
301
|
+
collection_request.show_deleted = parse_boolean(request.args.get("show_deleted"))
|
|
270
302
|
|
|
271
303
|
# Filter parameter
|
|
272
|
-
|
|
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
|
+
|
|
273
311
|
collection_request.pagination_filter = parse_collection_filter(request.args.get("page"))
|
|
274
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
|
+
|
|
275
316
|
# Limit parameter
|
|
276
317
|
collection_request.limit = parse_collections_limit(request.args.get("limit"))
|
|
277
318
|
|
|
@@ -313,17 +354,34 @@ def getAllCollections():
|
|
|
313
354
|
created_after=args.get("created_after"),
|
|
314
355
|
),
|
|
315
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
|
+
},
|
|
316
363
|
]
|
|
317
364
|
|
|
318
365
|
with db.conn(current_app) as conn:
|
|
319
|
-
|
|
320
|
-
if
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
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))
|
|
327
385
|
|
|
328
386
|
db_collections = get_collections(collection_request)
|
|
329
387
|
|
|
@@ -332,34 +390,32 @@ def getAllCollections():
|
|
|
332
390
|
return (dbSequencesToGeoRSS(db_collections.collections).rss(), 200, {"Content-Type": "text/xml"})
|
|
333
391
|
|
|
334
392
|
stac_collections = [dbSequenceToStacCollection(c) for c in db_collections.collections]
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
additional_filters = request.args.get("filter")
|
|
393
|
+
if datasetBounds is not None:
|
|
394
|
+
pagination_links = []
|
|
338
395
|
|
|
339
|
-
|
|
340
|
-
route="stac_collections.getAllCollections",
|
|
341
|
-
routeArgs={"limit": collection_request.limit},
|
|
342
|
-
sortBy=sortBy,
|
|
343
|
-
datasetBounds=datasetBounds,
|
|
344
|
-
dataBounds=db_collections.query_bounds,
|
|
345
|
-
additional_filters=additional_filters,
|
|
346
|
-
)
|
|
396
|
+
additional_filters = request.args.get("filter")
|
|
347
397
|
|
|
348
|
-
|
|
349
|
-
|
|
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)
|
|
350
409
|
|
|
351
410
|
return (
|
|
352
|
-
{
|
|
353
|
-
"collections": stac_collections,
|
|
354
|
-
"links": links,
|
|
355
|
-
},
|
|
411
|
+
removeNoneInDict({"collections": stac_collections, "links": links}),
|
|
356
412
|
200,
|
|
357
413
|
{"Content-Type": "application/json"},
|
|
358
414
|
)
|
|
359
415
|
|
|
360
416
|
|
|
361
417
|
@bp.route("/collections/<uuid:collectionId>")
|
|
362
|
-
def getCollection(collectionId):
|
|
418
|
+
def getCollection(collectionId, account=None):
|
|
363
419
|
"""Retrieve metadata of a single collection
|
|
364
420
|
---
|
|
365
421
|
tags:
|
|
@@ -380,17 +436,24 @@ def getCollection(collectionId):
|
|
|
380
436
|
$ref: '#/components/schemas/GeoVisioCollection'
|
|
381
437
|
"""
|
|
382
438
|
|
|
383
|
-
account = auth.get_current_account()
|
|
439
|
+
account = account or auth.get_current_account()
|
|
384
440
|
|
|
385
441
|
params = {
|
|
386
442
|
"id": collectionId,
|
|
387
443
|
# Only the owner of an account can view sequence not 'ready'
|
|
388
444
|
"account": account.id if account is not None else None,
|
|
389
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)")
|
|
390
452
|
|
|
391
453
|
record = db.fetchone(
|
|
392
454
|
current_app,
|
|
393
|
-
|
|
455
|
+
SQL(
|
|
456
|
+
"""SELECT
|
|
394
457
|
s.id,
|
|
395
458
|
s.metadata->>'title' AS name,
|
|
396
459
|
ST_XMin(s.bbox) AS minx,
|
|
@@ -398,6 +461,7 @@ def getCollection(collectionId):
|
|
|
398
461
|
ST_XMax(s.bbox) AS maxx,
|
|
399
462
|
ST_YMax(s.bbox) AS maxy,
|
|
400
463
|
s.status AS status,
|
|
464
|
+
s.visibility,
|
|
401
465
|
accounts.name AS account_name,
|
|
402
466
|
s.account_id AS account_id,
|
|
403
467
|
s.upload_set_id,
|
|
@@ -434,9 +498,10 @@ def getCollection(collectionId):
|
|
|
434
498
|
JOIN sequences_pictures sp ON sp.seq_id = %(id)s AND sp.pic_id = p.id
|
|
435
499
|
) a
|
|
436
500
|
WHERE s.id = %(id)s
|
|
437
|
-
AND
|
|
501
|
+
AND {perm_filter}
|
|
438
502
|
AND s.status != 'deleted'
|
|
439
|
-
"""
|
|
503
|
+
"""
|
|
504
|
+
).format(perm_filter=perm_filter),
|
|
440
505
|
params,
|
|
441
506
|
row_factory=dict_row,
|
|
442
507
|
)
|
|
@@ -475,12 +540,10 @@ def getCollectionThumbnail(collectionId):
|
|
|
475
540
|
type: string
|
|
476
541
|
format: binary
|
|
477
542
|
"""
|
|
478
|
-
account = auth.get_current_account()
|
|
479
|
-
|
|
480
543
|
params = {
|
|
481
544
|
"seq": collectionId,
|
|
482
545
|
# Only the owner of an account can view pictures not 'ready'
|
|
483
|
-
"account":
|
|
546
|
+
"account": auth.get_current_account_id(),
|
|
484
547
|
}
|
|
485
548
|
|
|
486
549
|
records = db.fetchone(
|
|
@@ -493,6 +556,7 @@ def getCollectionThumbnail(collectionId):
|
|
|
493
556
|
WHERE
|
|
494
557
|
sp.seq_id = %(seq)s
|
|
495
558
|
AND (p.status = 'ready' OR p.account_id = %(account)s)
|
|
559
|
+
AND is_picture_visible_by_user(p, %(account)s)
|
|
496
560
|
AND is_sequence_visible_by_user(s, %(account)s)
|
|
497
561
|
ORDER BY RANK ASC
|
|
498
562
|
LIMIT 1""",
|
|
@@ -558,12 +622,12 @@ def postCollection(account=None):
|
|
|
558
622
|
metadata = removeNoneInDict(metadata)
|
|
559
623
|
|
|
560
624
|
# Create sequence folder
|
|
561
|
-
|
|
562
|
-
seqId = sequences.createSequence(metadata,
|
|
625
|
+
account = accountOrDefault(account)
|
|
626
|
+
seqId = sequences.createSequence(metadata, account.id, request.user_agent.string)
|
|
563
627
|
|
|
564
628
|
# Return created sequence
|
|
565
629
|
return (
|
|
566
|
-
getCollection(seqId)[0],
|
|
630
|
+
getCollection(seqId, account=account)[0],
|
|
567
631
|
200,
|
|
568
632
|
{
|
|
569
633
|
"Content-Type": "application/json",
|
|
@@ -579,7 +643,21 @@ class PatchCollectionParameter(BaseModel):
|
|
|
579
643
|
relative_heading: Optional[int] = None
|
|
580
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."""
|
|
581
645
|
visible: Optional[bool] = None
|
|
582
|
-
"""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
|
+
"""
|
|
583
661
|
title: Optional[str] = Field(max_length=250, default=None)
|
|
584
662
|
"""The sequence title (publicly displayed)"""
|
|
585
663
|
sortby: Optional[str] = None
|
|
@@ -624,9 +702,28 @@ If unset, sort order is unchanged."""
|
|
|
624
702
|
def parse_relative_heading(cls, value):
|
|
625
703
|
return parse_relative_heading(value)
|
|
626
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
|
|
713
|
+
|
|
627
714
|
def has_only_semantics_updates(self):
|
|
628
715
|
return self.model_fields_set == {"semantics"}
|
|
629
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
|
+
|
|
630
727
|
|
|
631
728
|
@bp.route("/collections/<uuid:collectionId>", methods=["PATCH"])
|
|
632
729
|
@auth.login_required()
|
|
@@ -692,14 +789,15 @@ def patchCollection(collectionId, account):
|
|
|
692
789
|
with conn.transaction():
|
|
693
790
|
with conn.cursor(row_factory=dict_row) as cursor:
|
|
694
791
|
seq = cursor.execute(
|
|
695
|
-
"SELECT
|
|
792
|
+
"SELECT metadata, account_id, current_sort, visibility FROM sequences WHERE id = %s AND status != 'deleted'",
|
|
793
|
+
[collectionId],
|
|
696
794
|
).fetchone()
|
|
697
795
|
|
|
698
796
|
# Sequence not found
|
|
699
797
|
if not seq:
|
|
700
798
|
raise errors.InvalidAPIUsage(_("Collection %(c)s wasn't found in database", c=collectionId), status_code=404)
|
|
701
799
|
|
|
702
|
-
if account is not None and account.
|
|
800
|
+
if account is not None and not account.can_edit_collection(str(seq["account_id"])):
|
|
703
801
|
# Only owner of the sequence is allower to change its visibility and title
|
|
704
802
|
# tags and headings can be changed by anyone
|
|
705
803
|
if metadata.visible is not None or metadata.title is not None:
|
|
@@ -718,26 +816,18 @@ def patchCollection(collectionId, account):
|
|
|
718
816
|
status_code=403,
|
|
719
817
|
)
|
|
720
818
|
|
|
721
|
-
|
|
819
|
+
oldVisibility = seq["visibility"]
|
|
722
820
|
oldMetadata = seq["metadata"]
|
|
723
821
|
oldTitle = oldMetadata.get("title")
|
|
724
822
|
|
|
725
|
-
# Check if sequence is in a preparing/broken/... state so no edit possible
|
|
726
|
-
if oldStatus not in ["ready", "hidden"]:
|
|
727
|
-
if metadata.visible is not None:
|
|
728
|
-
raise errors.InvalidAPIUsage(
|
|
729
|
-
_("Sequence %(c)s is in %(s)s state, its visibility can't be changed for now", c=collectionId, s=oldStatus),
|
|
730
|
-
status_code=400,
|
|
731
|
-
)
|
|
732
|
-
|
|
733
823
|
sqlUpdates = []
|
|
734
824
|
sqlParams = {"id": collectionId, "account": account.id}
|
|
735
825
|
|
|
736
|
-
if metadata.
|
|
737
|
-
|
|
738
|
-
if
|
|
739
|
-
sqlUpdates.append(SQL("
|
|
740
|
-
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
|
|
741
831
|
|
|
742
832
|
new_metadata = {}
|
|
743
833
|
if metadata.title is not None and oldTitle != metadata.title:
|
|
@@ -845,21 +935,20 @@ def getCollectionImportStatus(collectionId):
|
|
|
845
935
|
schema:
|
|
846
936
|
$ref: '#/components/schemas/GeoVisioCollectionImportStatus'
|
|
847
937
|
"""
|
|
848
|
-
|
|
849
|
-
account = auth.get_current_account()
|
|
850
|
-
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()}
|
|
851
939
|
with db.cursor(current_app, row_factory=dict_row) as cursor:
|
|
852
940
|
sequence_status = cursor.execute(
|
|
853
941
|
SQL(
|
|
854
|
-
"""SELECT status
|
|
942
|
+
"""SELECT status, visibility
|
|
855
943
|
FROM sequences
|
|
856
944
|
WHERE id = %(seq_id)s
|
|
857
|
-
AND (
|
|
945
|
+
AND is_sequence_visible_by_user(sequences, %(account)s)
|
|
946
|
+
AND status != 'deleted'""",
|
|
858
947
|
),
|
|
859
948
|
params,
|
|
860
949
|
).fetchone()
|
|
861
950
|
if sequence_status is None:
|
|
862
|
-
raise errors.InvalidAPIUsage(_("Sequence doesn't
|
|
951
|
+
raise errors.InvalidAPIUsage(_("Sequence doesn't exist"), status_code=404)
|
|
863
952
|
|
|
864
953
|
pics_status = cursor.execute(
|
|
865
954
|
"""WITH
|
|
@@ -892,8 +981,8 @@ pic_jobs_stats AS (
|
|
|
892
981
|
JOIN pictures p ON sp.pic_id = p.id
|
|
893
982
|
LEFT JOIN pic_jobs_stats ON pic_jobs_stats.picture_id = p.id
|
|
894
983
|
WHERE
|
|
895
|
-
|
|
896
|
-
|
|
984
|
+
s.id = %(seq_id)s
|
|
985
|
+
AND (p IS NULL OR is_picture_visible_by_user(p, %(account)s))
|
|
897
986
|
ORDER BY s.id, sp.rank
|
|
898
987
|
)
|
|
899
988
|
SELECT json_strip_nulls(
|
|
@@ -1050,6 +1139,8 @@ def getUserCollection(userId, userIdMatchesAccount=False):
|
|
|
1050
1139
|
sortBy.fields.append(SortByField(field=STAC_FIELD_MAPPINGS["id"], direction=SQLDirection.ASC))
|
|
1051
1140
|
collection_request = CollectionsRequest(sort_by=sortBy, userOwnsAllCollections=userIdMatchesAccount)
|
|
1052
1141
|
|
|
1142
|
+
account_to_query_id = auth.get_current_account_id()
|
|
1143
|
+
|
|
1053
1144
|
# Filter parameter
|
|
1054
1145
|
collection_request.user_filter = parse_collection_filter(request.args.get("filter"))
|
|
1055
1146
|
|
|
@@ -1075,16 +1166,16 @@ def getUserCollection(userId, userIdMatchesAccount=False):
|
|
|
1075
1166
|
meta_filter = [
|
|
1076
1167
|
SQL("{field} IS NOT NULL").format(field=collection_request.sort_by.fields[0].field.sql_filter),
|
|
1077
1168
|
SQL("s.account_id = %(account)s"),
|
|
1169
|
+
SQL("is_sequence_visible_by_user(s, %(account_to_query)s)"),
|
|
1078
1170
|
]
|
|
1079
1171
|
if collection_request.user_filter is not None:
|
|
1080
1172
|
meta_filter.append(collection_request.user_filter)
|
|
1081
1173
|
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
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'"))
|
|
1088
1179
|
|
|
1089
1180
|
# Check user account parameter
|
|
1090
1181
|
with db.cursor(current_app, row_factory=dict_row) as cursor:
|
|
@@ -1118,7 +1209,7 @@ def getUserCollection(userId, userIdMatchesAccount=False):
|
|
|
1118
1209
|
filter=SQL(" AND ").join(meta_filter),
|
|
1119
1210
|
order_column=collection_request.sort_by.fields[0].field.sql_filter,
|
|
1120
1211
|
),
|
|
1121
|
-
params={"account": userId},
|
|
1212
|
+
params={"account": userId, "account_to_query": account_to_query_id},
|
|
1122
1213
|
).fetchone()
|
|
1123
1214
|
|
|
1124
1215
|
if not meta_collection or meta_collection["created"] is None:
|
|
@@ -1133,6 +1224,7 @@ def getUserCollection(userId, userIdMatchesAccount=False):
|
|
|
1133
1224
|
collection_request.sort_by,
|
|
1134
1225
|
additional_filters=SQL(" AND ").join(meta_filter),
|
|
1135
1226
|
additional_filters_params={"account": userId},
|
|
1227
|
+
account_to_query_id=account_to_query_id,
|
|
1136
1228
|
)
|
|
1137
1229
|
|
|
1138
1230
|
collections = get_collections(collection_request)
|
|
@@ -1158,7 +1250,8 @@ def getUserCollection(userId, userIdMatchesAccount=False):
|
|
|
1158
1250
|
},
|
|
1159
1251
|
"spatial": {"bbox": [[s["minx"] or -180.0, s["miny"] or -90.0, s["maxx"] or 180.0, s["maxy"] or 90.0]]},
|
|
1160
1252
|
},
|
|
1161
|
-
"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,
|
|
1162
1255
|
"geovisio:length_km": s.get("length_km"),
|
|
1163
1256
|
}
|
|
1164
1257
|
)
|
geovisio/web/configuration.py
CHANGED
|
@@ -3,6 +3,7 @@ from typing import Dict, Any
|
|
|
3
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
|
|
6
7
|
from geovisio.utils import db
|
|
7
8
|
from psycopg.rows import class_row
|
|
8
9
|
from typing import Optional
|
|
@@ -42,6 +43,7 @@ def configuration():
|
|
|
42
43
|
"version": get_api_version(),
|
|
43
44
|
"pages": _get_pages(),
|
|
44
45
|
"defaults": _get_default_values(),
|
|
46
|
+
"visibility": {"possible_values": _get_possible_visibility_values()},
|
|
45
47
|
}
|
|
46
48
|
)
|
|
47
49
|
|
|
@@ -72,6 +74,13 @@ def _license_configuration():
|
|
|
72
74
|
return l
|
|
73
75
|
|
|
74
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
|
+
|
|
75
84
|
def _get_pages():
|
|
76
85
|
|
|
77
86
|
pages = db.fetchall(current_app, "SELECT distinct(name) FROM pages")
|
|
@@ -85,11 +94,14 @@ class Config(BaseModel):
|
|
|
85
94
|
split_time: Optional[datetime.timedelta] = Field(validation_alias="default_split_time")
|
|
86
95
|
duplicate_distance: Optional[float] = Field(validation_alias="default_duplicate_distance")
|
|
87
96
|
duplicate_rotation: Optional[int] = Field(validation_alias="default_duplicate_rotation")
|
|
97
|
+
default_visibility: Visibility
|
|
88
98
|
|
|
89
99
|
@field_serializer("split_time")
|
|
90
100
|
def split_time_to_s(self, s: datetime.timedelta, _):
|
|
91
101
|
return s.total_seconds()
|
|
92
102
|
|
|
103
|
+
model_config = ConfigDict(use_enum_values=True)
|
|
104
|
+
|
|
93
105
|
|
|
94
106
|
def _get_default_values():
|
|
95
107
|
return db.fetchone(current_app, "SELECT * FROM configurations", row_factory=class_row(Config)).model_dump()
|