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.
Files changed (58) hide show
  1. geovisio/__init__.py +3 -1
  2. geovisio/admin_cli/user.py +7 -2
  3. geovisio/config_app.py +21 -7
  4. geovisio/translations/be/LC_MESSAGES/messages.mo +0 -0
  5. geovisio/translations/be/LC_MESSAGES/messages.po +886 -0
  6. geovisio/translations/da/LC_MESSAGES/messages.mo +0 -0
  7. geovisio/translations/da/LC_MESSAGES/messages.po +96 -5
  8. geovisio/translations/de/LC_MESSAGES/messages.mo +0 -0
  9. geovisio/translations/de/LC_MESSAGES/messages.po +171 -132
  10. geovisio/translations/en/LC_MESSAGES/messages.mo +0 -0
  11. geovisio/translations/en/LC_MESSAGES/messages.po +169 -146
  12. geovisio/translations/eo/LC_MESSAGES/messages.mo +0 -0
  13. geovisio/translations/eo/LC_MESSAGES/messages.po +3 -2
  14. geovisio/translations/fr/LC_MESSAGES/messages.mo +0 -0
  15. geovisio/translations/fr/LC_MESSAGES/messages.po +3 -2
  16. geovisio/translations/it/LC_MESSAGES/messages.mo +0 -0
  17. geovisio/translations/it/LC_MESSAGES/messages.po +1 -1
  18. geovisio/translations/messages.pot +159 -138
  19. geovisio/translations/nl/LC_MESSAGES/messages.mo +0 -0
  20. geovisio/translations/nl/LC_MESSAGES/messages.po +44 -2
  21. geovisio/translations/oc/LC_MESSAGES/messages.mo +0 -0
  22. geovisio/translations/oc/LC_MESSAGES/messages.po +9 -6
  23. geovisio/translations/pt/LC_MESSAGES/messages.mo +0 -0
  24. geovisio/translations/pt/LC_MESSAGES/messages.po +944 -0
  25. geovisio/translations/pt_BR/LC_MESSAGES/messages.mo +0 -0
  26. geovisio/translations/pt_BR/LC_MESSAGES/messages.po +942 -0
  27. geovisio/translations/sv/LC_MESSAGES/messages.mo +0 -0
  28. geovisio/translations/sv/LC_MESSAGES/messages.po +1 -1
  29. geovisio/translations/tr/LC_MESSAGES/messages.mo +0 -0
  30. geovisio/translations/tr/LC_MESSAGES/messages.po +927 -0
  31. geovisio/translations/uk/LC_MESSAGES/messages.mo +0 -0
  32. geovisio/translations/uk/LC_MESSAGES/messages.po +920 -0
  33. geovisio/utils/annotations.py +7 -4
  34. geovisio/utils/auth.py +33 -0
  35. geovisio/utils/cql2.py +20 -3
  36. geovisio/utils/pictures.py +16 -18
  37. geovisio/utils/sequences.py +104 -75
  38. geovisio/utils/upload_set.py +20 -10
  39. geovisio/utils/users.py +18 -0
  40. geovisio/web/annotations.py +96 -3
  41. geovisio/web/collections.py +169 -76
  42. geovisio/web/configuration.py +12 -0
  43. geovisio/web/docs.py +17 -3
  44. geovisio/web/items.py +129 -72
  45. geovisio/web/map.py +92 -54
  46. geovisio/web/pages.py +48 -4
  47. geovisio/web/params.py +56 -11
  48. geovisio/web/pictures.py +3 -3
  49. geovisio/web/prepare.py +4 -2
  50. geovisio/web/queryables.py +57 -0
  51. geovisio/web/stac.py +8 -2
  52. geovisio/web/upload_set.py +83 -26
  53. geovisio/web/users.py +85 -4
  54. geovisio/web/utils.py +24 -6
  55. {geovisio-2.10.0.dist-info → geovisio-2.11.0.dist-info}/METADATA +3 -2
  56. {geovisio-2.10.0.dist-info → geovisio-2.11.0.dist-info}/RECORD +58 -46
  57. {geovisio-2.10.0.dist-info → geovisio-2.11.0.dist-info}/WHEEL +0 -0
  58. {geovisio-2.10.0.dist-info → geovisio-2.11.0.dist-info}/licenses/LICENSE +0 -0
@@ -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 = UUID(accountIdOrDefault(account))
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
@@ -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
- accountIdOrDefault,
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
- dbSeq.get("status") if dbSeq.get("status") != "ready" else None
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
- collection_request.user_filter = parse_collection_filter(request.args.get("filter"))
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
- datasetBounds = get_dataset_bounds(conn, collection_request.sort_by, additional_filters=collection_request.user_filter)
320
- if datasetBounds is None:
321
- return ({"collections": [], "links": links}, 200, {"Content-Type": "application/json"})
322
- creation_date_index = collection_request.sort_by.get_field_index("created")
323
- if collection_request.created_after and collection_request.created_after > datasetBounds.last[creation_date_index]:
324
- raise errors.InvalidAPIUsage(_("There is no collection created after %(d)s", d=collection_request.created_after))
325
- if collection_request.created_before and collection_request.created_before < datasetBounds.first[creation_date_index]:
326
- raise errors.InvalidAPIUsage(_("There is no collection created before %(d)s", d=collection_request.created_before))
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
- pagination_links = []
336
-
337
- additional_filters = request.args.get("filter")
393
+ if datasetBounds is not None:
394
+ pagination_links = []
338
395
 
339
- pagination_links = sequences.get_pagination_links(
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
- # Compute paginated links
349
- links.extend(pagination_links)
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
- """SELECT
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 (s.status != 'hidden' OR s.account_id = %(account)s)
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": account.id if account is not None else None,
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
- accountId = accountIdOrDefault(account)
562
- seqId = sequences.createSequence(metadata, accountId, request.user_agent.string)
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 status, metadata, account_id, current_sort FROM sequences WHERE id = %s AND status != 'deleted'", [collectionId]
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.id != str(seq["account_id"]):
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
- oldStatus = seq["status"]
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.visible is not None:
737
- newStatus = "ready" if metadata.visible is True else "hidden"
738
- if newStatus != oldStatus:
739
- sqlUpdates.append(SQL("status = %(status)s"))
740
- sqlParams["status"] = newStatus
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 (status != 'hidden' OR account_id = %(account)s)-- show deleted sequence here"""
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 exists"), status_code=404)
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
- s.id = %(seq_id)s
896
- AND (p IS NULL OR p.status != 'hidden' OR p.account_id = %(account)s)
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
- if collection_request.user_filter is None or "status" not in collection_request.user_filter.as_string(None):
1083
- # if the filter does not contains any `status` condition, we want to show only 'ready' collection to the general users, and non deleted one for the owner
1084
- if not userIdMatchesAccount:
1085
- meta_filter.extend([SQL("s.status = 'ready'")])
1086
- else:
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["status"] if userIdMatchesAccount else None,
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
  )
@@ -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()