geovisio 2.9.0__py3-none-any.whl → 2.11.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. geovisio/__init__.py +8 -1
  2. geovisio/admin_cli/user.py +7 -2
  3. geovisio/config_app.py +26 -12
  4. geovisio/translations/ar/LC_MESSAGES/messages.mo +0 -0
  5. geovisio/translations/ar/LC_MESSAGES/messages.po +818 -0
  6. geovisio/translations/be/LC_MESSAGES/messages.mo +0 -0
  7. geovisio/translations/be/LC_MESSAGES/messages.po +886 -0
  8. geovisio/translations/br/LC_MESSAGES/messages.po +1 -1
  9. geovisio/translations/da/LC_MESSAGES/messages.mo +0 -0
  10. geovisio/translations/da/LC_MESSAGES/messages.po +96 -4
  11. geovisio/translations/de/LC_MESSAGES/messages.mo +0 -0
  12. geovisio/translations/de/LC_MESSAGES/messages.po +214 -122
  13. geovisio/translations/el/LC_MESSAGES/messages.po +1 -1
  14. geovisio/translations/en/LC_MESSAGES/messages.mo +0 -0
  15. geovisio/translations/en/LC_MESSAGES/messages.po +234 -157
  16. geovisio/translations/eo/LC_MESSAGES/messages.mo +0 -0
  17. geovisio/translations/eo/LC_MESSAGES/messages.po +55 -5
  18. geovisio/translations/es/LC_MESSAGES/messages.po +1 -1
  19. geovisio/translations/fi/LC_MESSAGES/messages.po +1 -1
  20. geovisio/translations/fr/LC_MESSAGES/messages.mo +0 -0
  21. geovisio/translations/fr/LC_MESSAGES/messages.po +92 -3
  22. geovisio/translations/hu/LC_MESSAGES/messages.po +1 -1
  23. geovisio/translations/it/LC_MESSAGES/messages.mo +0 -0
  24. geovisio/translations/it/LC_MESSAGES/messages.po +63 -3
  25. geovisio/translations/ja/LC_MESSAGES/messages.po +1 -1
  26. geovisio/translations/ko/LC_MESSAGES/messages.po +1 -1
  27. geovisio/translations/messages.pot +216 -139
  28. geovisio/translations/nl/LC_MESSAGES/messages.mo +0 -0
  29. geovisio/translations/nl/LC_MESSAGES/messages.po +333 -62
  30. geovisio/translations/oc/LC_MESSAGES/messages.mo +0 -0
  31. geovisio/translations/oc/LC_MESSAGES/messages.po +821 -0
  32. geovisio/translations/pl/LC_MESSAGES/messages.po +1 -1
  33. geovisio/translations/pt/LC_MESSAGES/messages.mo +0 -0
  34. geovisio/translations/pt/LC_MESSAGES/messages.po +944 -0
  35. geovisio/translations/pt_BR/LC_MESSAGES/messages.mo +0 -0
  36. geovisio/translations/pt_BR/LC_MESSAGES/messages.po +942 -0
  37. geovisio/translations/sv/LC_MESSAGES/messages.mo +0 -0
  38. geovisio/translations/sv/LC_MESSAGES/messages.po +4 -3
  39. geovisio/translations/ti/LC_MESSAGES/messages.mo +0 -0
  40. geovisio/translations/ti/LC_MESSAGES/messages.po +762 -0
  41. geovisio/translations/tr/LC_MESSAGES/messages.mo +0 -0
  42. geovisio/translations/tr/LC_MESSAGES/messages.po +927 -0
  43. geovisio/translations/uk/LC_MESSAGES/messages.mo +0 -0
  44. geovisio/translations/uk/LC_MESSAGES/messages.po +920 -0
  45. geovisio/translations/zh_Hant/LC_MESSAGES/messages.po +1 -1
  46. geovisio/utils/annotations.py +21 -21
  47. geovisio/utils/auth.py +47 -13
  48. geovisio/utils/cql2.py +22 -5
  49. geovisio/utils/fields.py +14 -2
  50. geovisio/utils/items.py +44 -0
  51. geovisio/utils/model_query.py +2 -2
  52. geovisio/utils/pic_shape.py +1 -1
  53. geovisio/utils/pictures.py +127 -36
  54. geovisio/utils/semantics.py +32 -3
  55. geovisio/utils/sentry.py +1 -1
  56. geovisio/utils/sequences.py +155 -109
  57. geovisio/utils/upload_set.py +303 -206
  58. geovisio/utils/users.py +18 -0
  59. geovisio/utils/website.py +1 -1
  60. geovisio/web/annotations.py +303 -69
  61. geovisio/web/auth.py +1 -1
  62. geovisio/web/collections.py +194 -97
  63. geovisio/web/configuration.py +36 -4
  64. geovisio/web/docs.py +109 -13
  65. geovisio/web/items.py +319 -186
  66. geovisio/web/map.py +92 -54
  67. geovisio/web/pages.py +48 -4
  68. geovisio/web/params.py +100 -42
  69. geovisio/web/pictures.py +37 -3
  70. geovisio/web/prepare.py +4 -2
  71. geovisio/web/queryables.py +57 -0
  72. geovisio/web/stac.py +8 -2
  73. geovisio/web/tokens.py +49 -1
  74. geovisio/web/upload_set.py +226 -51
  75. geovisio/web/users.py +89 -8
  76. geovisio/web/utils.py +26 -8
  77. geovisio/workers/runner_pictures.py +128 -23
  78. {geovisio-2.9.0.dist-info → geovisio-2.11.0.dist-info}/METADATA +15 -14
  79. geovisio-2.11.0.dist-info/RECORD +117 -0
  80. geovisio-2.9.0.dist-info/RECORD +0 -98
  81. {geovisio-2.9.0.dist-info → geovisio-2.11.0.dist-info}/WHEEL +0 -0
  82. {geovisio-2.9.0.dist-info → geovisio-2.11.0.dist-info}/licenses/LICENSE +0 -0
@@ -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
- parse_sortby,
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, Bounds, BBox
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
- accountIdOrDefault,
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["semantics"] if "semantics" in dbSeq else None,
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
- dbSeq.get("status") if dbSeq.get("status") != "ready" else None
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 = parse_sortby(request.args.get("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
- 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
+
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
- datasetBounds = get_dataset_bounds(conn, collection_request.sort_by, additional_filters=collection_request.user_filter)
312
- if datasetBounds is None:
313
- return ({"collections": [], "links": links}, 200, {"Content-Type": "application/json"})
314
- creation_date_index = collection_request.sort_by.get_field_index("created")
315
- if collection_request.created_after and collection_request.created_after > datasetBounds.last[creation_date_index]:
316
- raise errors.InvalidAPIUsage(_("There is no collection created after %(d)s", d=collection_request.created_after))
317
- if collection_request.created_before and collection_request.created_before < datasetBounds.first[creation_date_index]:
318
- 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))
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
- pagination_links = []
393
+ if datasetBounds is not None:
394
+ pagination_links = []
328
395
 
329
- additional_filters = request.args.get("filter")
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
- # Compute paginated links
341
- 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)
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
- t.semantics
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
- ) t ON t.sequence_id = s.id
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 (s.status != 'hidden' OR s.account_id = %(account)s)
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": account.id if account is not None else None,
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 prefered if you create a production-ready tool, formatted like "PanoramaxCLI/1.0"
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
- accountId = accountIdOrDefault(account)
550
- seqId = sequences.createSequence(metadata, accountId, request.user_agent.string)
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
- try:
614
- relHeading = int(value)
615
- if relHeading < -180 or relHeading > 180:
616
- raise ValueError()
617
- return relHeading
618
- except (ValueError, TypeError):
619
- raise errors.InvalidAPIUsage(
620
- _("Relative heading is not valid, should be an integer in degrees from -180 to 180"), status_code=400
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 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],
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.id != str(seq["account_id"]):
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
- oldStatus = seq["status"]
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.visible is not None:
733
- newStatus = "ready" if metadata.visible is True else "hidden"
734
- if newStatus != oldStatus:
735
- sqlUpdates.append(SQL("status = %(status)s"))
736
- 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
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 (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'""",
854
947
  ),
855
948
  params,
856
949
  ).fetchone()
857
950
  if sequence_status is None:
858
- raise errors.InvalidAPIUsage(_("Sequence doesn't exists"), status_code=404)
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
- s.id = %(seq_id)s
892
- 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))
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 = parse_sortby(request.args.get("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
- if collection_request.user_filter is None or "status" not in collection_request.user_filter.as_string(None):
1079
- # 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
1080
- if not userIdMatchesAccount:
1081
- meta_filter.extend([SQL("s.status = 'ready'")])
1082
- else:
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["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,
1158
1255
  "geovisio:length_km": s.get("length_km"),
1159
1256
  }
1160
1257
  )
@@ -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 informations
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()