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
geovisio/web/map.py CHANGED
@@ -4,7 +4,8 @@
4
4
  import io
5
5
  from typing import Optional, Dict, Any, Tuple, List, Union
6
6
  from uuid import UUID
7
- from flask import Blueprint, current_app, send_file, request, jsonify, url_for
7
+ from functools import lru_cache
8
+ from flask import Blueprint, current_app, send_file, request, jsonify, url_for, g
8
9
  from flask_babel import gettext as _, get_locale
9
10
  from geovisio.utils import auth, db
10
11
  from geovisio.utils.auth import Account
@@ -13,6 +14,7 @@ from geovisio.web.utils import user_dependant_response
13
14
  from geovisio.web.configuration import _get_translated
14
15
  from geovisio import errors
15
16
  from psycopg import sql
17
+ import psycopg
16
18
 
17
19
  bp = Blueprint("map", __name__, url_prefix="/api")
18
20
 
@@ -139,6 +141,14 @@ def get_style_json(forUser: Optional[Union[UUID, str]] = None):
139
141
  "coef_360_pictures",
140
142
  "coef_flat_pictures",
141
143
  ]
144
+ if _has_non_public_fields():
145
+ style["metadata"]["panoramax:fields"]["grid"].extend(
146
+ [
147
+ "logged_coef",
148
+ "logged_coef_360_pictures",
149
+ "logged_coef_flat_pictures",
150
+ ]
151
+ )
142
152
 
143
153
  return jsonify(style)
144
154
 
@@ -211,7 +221,6 @@ def getStyle():
211
221
 
212
222
 
213
223
  @bp.route("/map/<int:z>/<int:x>/<int:y>.<format>")
214
- @user_dependant_response(False)
215
224
  def getTile(z: int, x: int, y: int, format: str):
216
225
  """Get pictures and sequences as vector tiles
217
226
 
@@ -227,6 +236,9 @@ def getTile(z: int, x: int, y: int, format: str):
227
236
  - coef (value from 0 to 1, relative quantity of available pictures)
228
237
  - coef_360_pictures (value from 0 to 1, relative quantity of available 360° pictures)
229
238
  - coef_flat_pictures (value from 0 to 1, relative quantity of available flat pictures)
239
+ - logged_coef (value from 0 to 1, relative quantity of available pictures for logged users)
240
+ - logged_coef_360_pictures (value from 0 to 1, relative quantity of available 360° pictures for logged users)
241
+ - logged_coef_flat_pictures (value from 0 to 1, relative quantity of available flat pictures for logged users)
230
242
 
231
243
  Layer "sequences":
232
244
  - Available on zoom levels >= 7 (and simplified version on zoom >= 6 and < 7)
@@ -296,6 +308,26 @@ def getTile(z: int, x: int, y: int, format: str):
296
308
  return _getTile(z, x, y, format, onlyForUser=None)
297
309
 
298
310
 
311
+ def _has_non_public_fields():
312
+ """Check if the database has the `nb_non_public_pictures` field."""
313
+ if current_app.config["API_REGISTRATION_IS_OPEN"] is True:
314
+ return False
315
+
316
+ @lru_cache(maxsize=100)
317
+ def check_db():
318
+ """This function can be dropped in the next version (and only use the `API_REGISTRATION_IS_OPEN` config).
319
+ We do it because the pictures_grid materialized view is expensive to compute and we want the API to work during the schema migration.
320
+ We also cache it to not slow down every query, and eventually (after the limit is reached or the API is restarted), the API will start returning the non-public fields.
321
+ """
322
+ try:
323
+ db.fetchone(current_app, "SELECT nb_non_public_pictures FROM pictures_grid LIMIT 1")
324
+ except psycopg.errors.UndefinedColumn:
325
+ return False
326
+ return True
327
+
328
+ return check_db()
329
+
330
+
299
331
  def _get_query(z: int, x: int, y: int, onlyForUser: Optional[UUID], additional_filter: Optional[sql.SQL]) -> Tuple[sql.Composed, Dict]:
300
332
  """Returns appropriate SQL query according to given zoom"""
301
333
 
@@ -320,10 +352,10 @@ def _get_query(z: int, x: int, y: int, onlyForUser: Optional[UUID], additional_f
320
352
  if additional_filter:
321
353
  sequences_filter.append(additional_filter)
322
354
  filter_str = additional_filter.as_string(None)
323
- if "status" in filter_str:
355
+ if "visibility" in filter_str:
324
356
  # hack to have a coherent filter between the APIs
325
- # if asked for status='hidden', we want both hidden pics and hidden sequences
326
- pic_additional_filter_str = filter_str.replace("s.status", "p.status")
357
+ # if asked for visibility <> 'anyone' (status='hidden' in API), we want both hidden pics and hidden sequences
358
+ pic_additional_filter_str = filter_str.replace("s.visibility", "p.visibility")
327
359
  pic_additional_filter = sql.SQL(pic_additional_filter_str) # type: ignore
328
360
  pictures_filter.append(sql.SQL("(") + sql.SQL(" OR ").join([pic_additional_filter, additional_filter]) + sql.SQL(")"))
329
361
 
@@ -334,56 +366,65 @@ def _get_query(z: int, x: int, y: int, onlyForUser: Optional[UUID], additional_f
334
366
  params["account"] = onlyForUser
335
367
 
336
368
  # Not logged-in requests -> only show "ready" pics/sequences
337
- account = auth.get_current_account()
338
- accountId = account.id if account is not None else None
339
- if not onlyForUser or accountId != str(onlyForUser):
340
- sequences_filter.append(sql.SQL("s.status = 'ready'"))
341
- pictures_filter.append(sql.SQL("p.status = 'ready'"))
342
- pictures_filter.append(sql.SQL("s.status = 'ready'"))
343
-
369
+ if current_app.config["API_REGISTRATION_IS_OPEN"] is False or onlyForUser:
370
+ # for instances that supports logged-only data, we cannot add the tiles in a public cache (since non authenticated users could access this cache)
371
+ g.user_dependant_response = True
372
+ params["account_to_query"] = auth.get_current_account_id()
373
+ sequences_filter.append(sql.SQL("is_sequence_visible_by_user(s, %(account_to_query)s)"))
374
+ pictures_filter.append(sql.SQL("is_picture_visible_by_user(p, %(account_to_query)s)"))
375
+ pictures_filter.append(sql.SQL("is_sequence_visible_by_user(s, %(account_to_query)s)"))
376
+ else:
377
+ # for public instances, we can add the tiles in a public cache, and we only show the 'anyone' visibility
378
+ g.user_dependant_response = False
379
+ sequences_filter.append(sql.SQL("s.visibility = 'anyone'"))
380
+ pictures_filter.append(sql.SQL("s.visibility = 'anyone'"))
381
+ pictures_filter.append(sql.SQL("p.visibility = 'anyone'"))
382
+
383
+ sequences_filter.append(sql.SQL("s.status = 'ready'"))
384
+ pictures_filter.append(sql.SQL("p.preparing_status = 'prepared'"))
385
+ pictures_filter.append(sql.SQL("s.status = 'ready'"))
344
386
  #############################################################
345
387
  # SQL Result columns/fields
346
388
  #
347
389
 
390
+ grid_coef_field = """((CASE WHEN {count_field} = 0
391
+ THEN 0
392
+ WHEN {count_field} <= (SELECT PERCENTILE_CONT(0.5) WITHIN GROUP(ORDER BY {count_field}) FILTER (WHERE {count_field} > 0) FROM pictures_grid)
393
+ THEN
394
+ {count_field}::float / (SELECT PERCENTILE_CONT(0.5) WITHIN GROUP(ORDER BY {count_field}) FILTER (WHERE {count_field} > 0) FROM pictures_grid) * 0.5
395
+ ELSE
396
+ 0.5 + {count_field}::float / (SELECT MAX({count_field}) FROM pictures_grid) * 0.5
397
+ END) * 10)::int / 10::float AS {coef_field}"""
398
+
348
399
  grid_fields = [
349
400
  sql.SQL("ST_AsMVTGeom(ST_Transform(ST_Centroid(geom), 3857), ST_TileEnvelope(%(z)s, %(x)s, %(y)s)) AS geom"),
350
401
  sql.SQL("id"),
351
402
  sql.SQL("nb_pictures"),
352
403
  sql.SQL("nb_360_pictures"),
353
404
  sql.SQL("nb_pictures - nb_360_pictures AS nb_flat_pictures"),
354
- sql.SQL(
355
- """((CASE WHEN nb_pictures = 0
356
- THEN 0
357
- WHEN nb_pictures <= (SELECT PERCENTILE_CONT(0.5) WITHIN GROUP(ORDER BY nb_pictures) FILTER (WHERE nb_pictures > 0) FROM pictures_grid)
358
- THEN
359
- nb_pictures::float / (SELECT PERCENTILE_CONT(0.5) WITHIN GROUP(ORDER BY nb_pictures) FILTER (WHERE nb_pictures > 0) FROM pictures_grid) * 0.5
360
- ELSE
361
- 0.5 + nb_pictures::float / (SELECT MAX(nb_pictures) FROM pictures_grid) * 0.5
362
- END) * 10)::int / 10::float AS coef"""
363
- ),
364
- sql.SQL(
365
- """((CASE WHEN nb_360_pictures = 0
366
- THEN 0
367
- WHEN (SELECT PERCENTILE_CONT(0.5) WITHIN GROUP(ORDER BY nb_360_pictures) FILTER (WHERE nb_360_pictures > 0) FROM pictures_grid) = 0
368
- THEN 0
369
- WHEN nb_360_pictures <= (SELECT PERCENTILE_CONT(0.5) WITHIN GROUP(ORDER BY nb_360_pictures) FILTER (WHERE nb_360_pictures > 0) FROM pictures_grid)
370
- THEN nb_360_pictures::float / (SELECT PERCENTILE_CONT(0.5) WITHIN GROUP(ORDER BY nb_360_pictures) FILTER (WHERE nb_360_pictures > 0) FROM pictures_grid) * 0.5
371
- ELSE
372
- 0.5 + nb_360_pictures::float / (SELECT MAX(nb_360_pictures) FROM pictures_grid) * 0.5
373
- END) * 10)::int / 10::float AS coef_360_pictures"""
374
- ),
375
- sql.SQL(
376
- """((CASE WHEN (nb_pictures - nb_360_pictures) = 0
377
- THEN 0
378
- WHEN (SELECT PERCENTILE_CONT(0.5) WITHIN GROUP(ORDER BY (nb_pictures - nb_360_pictures)) FILTER (WHERE (nb_pictures - nb_360_pictures) > 0) FROM pictures_grid) = 0
379
- THEN 0
380
- WHEN (nb_pictures - nb_360_pictures) <= (SELECT PERCENTILE_CONT(0.5) WITHIN GROUP(ORDER BY (nb_pictures - nb_360_pictures)) FILTER (WHERE (nb_pictures - nb_360_pictures) > 0) FROM pictures_grid)
381
- THEN (nb_pictures - nb_360_pictures)::float / (SELECT PERCENTILE_CONT(0.5) WITHIN GROUP(ORDER BY (nb_pictures - nb_360_pictures)) FILTER (WHERE (nb_pictures - nb_360_pictures) > 0) FROM pictures_grid) * 0.5
382
- ELSE
383
- 0.5 + (nb_pictures - nb_360_pictures)::float / (SELECT MAX((nb_pictures - nb_360_pictures)) FROM pictures_grid) * 0.5
384
- END) * 10)::int / 10::float AS coef_flat_pictures"""
385
- ),
405
+ sql.SQL(grid_coef_field.format(count_field="nb_pictures", coef_field="coef")),
406
+ sql.SQL(grid_coef_field.format(count_field="nb_360_pictures", coef_field="coef_360_pictures")),
407
+ sql.SQL(grid_coef_field.format(count_field="(nb_pictures - nb_360_pictures)", coef_field="coef_flat_pictures")),
386
408
  ]
409
+ if _has_non_public_fields():
410
+ # we also add non-public pictures
411
+ grid_fields.extend(
412
+ [
413
+ sql.SQL(grid_coef_field.format(count_field="(nb_non_public_pictures + nb_pictures)", coef_field="logged_coef")),
414
+ sql.SQL(
415
+ grid_coef_field.format(
416
+ count_field="(nb_non_public_360_pictures + nb_360_pictures)", coef_field="logged_coef_360_pictures"
417
+ )
418
+ ),
419
+ sql.SQL(
420
+ grid_coef_field.format(
421
+ count_field="((nb_non_public_360_pictures + nb_360_pictures) - (nb_non_public_360_pictures + nb_360_pictures))",
422
+ coef_field="logged_coef_flat_pictures",
423
+ )
424
+ ),
425
+ ]
426
+ )
427
+
387
428
  sequences_fields = [
388
429
  sql.SQL("ST_AsMVTGeom(ST_Transform(geom, 3857), ST_TileEnvelope(%(z)s, %(x)s, %(y)s)) AS geom"),
389
430
  sql.SQL("id"),
@@ -396,7 +437,7 @@ def _get_query(z: int, x: int, y: int, onlyForUser: Optional[UUID], additional_f
396
437
  sequences_fields.extend(
397
438
  [
398
439
  sql.SQL("account_id"),
399
- sql.SQL("NULLIF(status != 'ready', FALSE) AS hidden"),
440
+ sql.SQL("NULLIF(visibility != 'anyone', FALSE) AS hidden"),
400
441
  sql.SQL("computed_model AS model"),
401
442
  sql.SQL("computed_type AS type"),
402
443
  sql.SQL("computed_capture_date AS date"),
@@ -412,8 +453,7 @@ def _get_query(z: int, x: int, y: int, onlyForUser: Optional[UUID], additional_f
412
453
  # Full pictures + sequences (z15+)
413
454
  if z >= ZOOM_PICTURES:
414
455
  query = sql.SQL(
415
- """
416
- SELECT mvtsequences.mvt || mvtpictures.mvt
456
+ """SELECT mvtsequences.mvt || mvtpictures.mvt
417
457
  FROM (
418
458
  SELECT ST_AsMVT(mvtgeomseqs.*, 'sequences') AS mvt
419
459
  FROM (
@@ -430,7 +470,7 @@ def _get_query(z: int, x: int, y: int, onlyForUser: Optional[UUID], additional_f
430
470
  SELECT
431
471
  ST_AsMVTGeom(ST_Transform(p.geom, 3857), ST_TileEnvelope(%(z)s, %(x)s, %(y)s)) AS geom,
432
472
  p.id, p.ts, p.heading, p.account_id,
433
- NULLIF(p.status != 'ready' OR s.status != 'ready', FALSE) AS hidden,
473
+ NULLIF(p.visibility = 'owner-only' OR s.visibility = 'owner-only', FALSE) AS hidden,
434
474
  p.metadata->>'type' AS type,
435
475
  TRIM(CONCAT(p.metadata->>'make', ' ', p.metadata->>'model')) AS model,
436
476
  gps_accuracy_m AS gps_accuracy,
@@ -455,8 +495,7 @@ def _get_query(z: int, x: int, y: int, onlyForUser: Optional[UUID], additional_f
455
495
  # Full sequences (z7-14.9 and z0-14.9 for specific users)
456
496
  elif z >= ZOOM_GRID_SEQUENCES + 1 or onlyForUser:
457
497
  query = sql.SQL(
458
- """
459
- SELECT ST_AsMVT(mvtsequences.*, 'sequences') AS mvt
498
+ """SELECT ST_AsMVT(mvtsequences.*, 'sequences') AS mvt
460
499
  FROM (
461
500
  SELECT
462
501
  {sequences_fields}
@@ -470,8 +509,7 @@ def _get_query(z: int, x: int, y: int, onlyForUser: Optional[UUID], additional_f
470
509
  # Sequences + grid (z6-6.9)
471
510
  elif z >= ZOOM_GRID_SEQUENCES:
472
511
  query = sql.SQL(
473
- """
474
- SELECT mvtsequences.mvt || mvtgrid.mvt
512
+ """SELECT mvtsequences.mvt || mvtgrid.mvt
475
513
  FROM (
476
514
  SELECT ST_AsMVT(mvtgeomseqs.*, 'sequences') AS mvt
477
515
  FROM (
@@ -502,8 +540,7 @@ def _get_query(z: int, x: int, y: int, onlyForUser: Optional[UUID], additional_f
502
540
  # Grid overview (all users + z0-5.9)
503
541
  else:
504
542
  query = sql.SQL(
505
- """
506
- SELECT ST_AsMVT(mvtgrid.*, 'grid') AS mvt
543
+ """SELECT ST_AsMVT(mvtgrid.*, 'grid') AS mvt
507
544
  FROM (
508
545
  SELECT
509
546
  {grid_fields}
@@ -634,6 +671,7 @@ def getUserTile(userId: UUID, z: int, x: int, y: int, format: str):
634
671
 
635
672
  @bp.route("/users/me/map/style.json")
636
673
  @auth.login_required_with_redirect()
674
+ @user_dependant_response(False)
637
675
  def getMyStyle(account: Account):
638
676
  """Get vector tiles style.
639
677
 
geovisio/web/pages.py CHANGED
@@ -180,11 +180,9 @@ def postPage(page, lang, account):
180
180
  with db.execute(
181
181
  current_app,
182
182
  SQL(
183
- """
184
- INSERT INTO pages (name, lang, content)
183
+ """INSERT INTO pages (name, lang, content)
185
184
  VALUES (%(name)s, %(lang)s, %(content)s)
186
- ON CONFLICT (name, lang) DO UPDATE SET content=EXCLUDED.content
187
- """
185
+ ON CONFLICT (name, lang) DO UPDATE SET content=EXCLUDED.content"""
188
186
  ),
189
187
  {"name": name.value, "lang": lang, "content": request.get_data(as_text=True)},
190
188
  ) as res:
@@ -238,3 +236,49 @@ def deletePage(page, lang, account):
238
236
  raise InvalidAPIUsage(_("Could not delete page content"), 500)
239
237
 
240
238
  return "", 200
239
+
240
+
241
+ @bp.route("/pages/<page>/publish-change", methods=["POST", "PUT"])
242
+ @auth.login_required()
243
+ def postPageUpdate(page, account):
244
+ """Act that there is a new version of a page on major changes.
245
+ For pages that needs acceptance, the users can thus be notified of the changes.
246
+ ---
247
+ tags:
248
+ - Configuration
249
+ parameters:
250
+ - name: page
251
+ in: path
252
+ description: Page name
253
+ required: true
254
+ schema:
255
+ $ref: '#/components/schemas/GeoVisioPageName'
256
+ security:
257
+ - bearerToken: []
258
+ - cookieAuth: []
259
+ requestBody:
260
+ content:
261
+ application/json: {}
262
+ responses:
263
+ 200:
264
+ description: Successfully saved
265
+ """
266
+
267
+ name = check_page_name(page)
268
+
269
+ if not account.can_edit_pages():
270
+ raise InvalidAPIUsage(_("You must be logged-in as admin to edit pages"), 403)
271
+
272
+ with db.execute(
273
+ current_app,
274
+ SQL(
275
+ """UPDATE pages
276
+ SET updated_at = NOW()
277
+ WHERE name = %(name)s"""
278
+ ),
279
+ {"name": name.value},
280
+ ) as res:
281
+ if not res.rowcount:
282
+ raise InvalidAPIUsage(_("Could not publish page changes"), 500)
283
+
284
+ return "", 200
geovisio/web/params.py CHANGED
@@ -1,21 +1,21 @@
1
1
  from uuid import UUID
2
-
3
- from geovisio import errors
4
2
  import dateutil.parser
5
3
  from dateutil import tz
6
4
  from dateutil.parser import parse as dateparser
7
5
  import datetime
6
+ from enum import Enum
8
7
  import re
9
8
  from werkzeug.datastructures import MultiDict
10
9
  from typing import Optional, Tuple, Any, List
11
10
  from pygeofilter import ast
12
11
  from pygeofilter.backends.evaluator import Evaluator, handle
13
12
  from psycopg import sql
13
+ from flask_babel import gettext as _
14
+ from flask import current_app
15
+ from geovisio import errors
14
16
  from geovisio.utils.sequences import STAC_FIELD_MAPPINGS, STAC_FIELD_TO_SQL_FILTER
15
17
  from geovisio.utils.fields import SortBy, SQLDirection, SortByField
16
- from flask_babel import gettext as _
17
18
  from geovisio.utils import items as utils_items
18
-
19
19
  from geovisio.utils.cql2 import parse_cql2_filter
20
20
 
21
21
 
@@ -329,16 +329,20 @@ def parse_list(value: Optional[Any], tryFallbacks: bool = True, paramName: Optio
329
329
  def parse_collection_filter(value: Optional[str]) -> Optional[sql.SQL]:
330
330
  """Reads STAC filter parameter and sends SQL condition back.
331
331
 
332
+ Note: if more search filters are added, don't forget to add them to the qeryables endpoint (in queryables.py)
333
+
332
334
  >>> parse_collection_filter('')
333
335
 
334
336
  >>> parse_collection_filter("updated >= '2023-12-31'")
335
337
  SQL("(s.updated_at >= '2023-12-31')")
336
338
  >>> parse_collection_filter("updated >= '2023-12-31' AND created < '2023-10-31'")
337
339
  SQL("((s.updated_at >= '2023-12-31') AND (s.inserted_at < '2023-10-31'))")
338
- >>> parse_collection_filter("status IN ('deleted','ready')") # when we ask for deleted, we should also have hidden collections
339
- SQL("s.status IN ('deleted', 'ready', 'hidden')")
340
- >>> parse_collection_filter("status = 'deleted' OR status = 'ready'")
341
- SQL("(((s.status = 'deleted') OR (s.status = 'hidden')) OR (s.status = 'ready'))")
340
+ >>> parse_collection_filter("status IN ('deleted','ready')") # doctest: +IGNORE_EXCEPTION_DETAIL
341
+ Traceback (most recent call last):
342
+ geovisio.errors.InvalidAPIUsage: The status filter is not supported anymore, use the `show_deleted` parameter instead if you need to query deleted collections
343
+ >>> parse_collection_filter("status = 'deleted' OR status = 'ready'") # doctest: +IGNORE_EXCEPTION_DETAIL
344
+ Traceback (most recent call last):
345
+ geovisio.errors.InvalidAPIUsage: The status filter is not supported anymore, use the `show_deleted` parameter instead if you need to query deleted collections
342
346
  >>> parse_collection_filter('invalid = 10') # doctest: +IGNORE_EXCEPTION_DETAIL
343
347
  Traceback (most recent call last):
344
348
  geovisio.errors.InvalidAPIUsage: Unsupported filter parameter
@@ -346,6 +350,18 @@ def parse_collection_filter(value: Optional[str]) -> Optional[sql.SQL]:
346
350
  Traceback (most recent call last):
347
351
  geovisio.errors.InvalidAPIUsage: Unsupported filter parameter
348
352
  """
353
+ if value and "status" in value:
354
+ if "'hidden'" in value:
355
+ raise errors.InvalidAPIUsage(
356
+ _("The status filter is not supported anymore, use the `visibility` filter instead"),
357
+ status_code=400,
358
+ )
359
+ raise errors.InvalidAPIUsage(
360
+ _(
361
+ "The status filter is not supported anymore, use the `show_deleted` parameter instead if you need to query deleted collections"
362
+ ),
363
+ status_code=400,
364
+ )
349
365
  return parse_cql2_filter(value, STAC_FIELD_TO_SQL_FILTER, ast_updater=_alterFilterAst)
350
366
 
351
367
 
@@ -373,12 +389,16 @@ class _FilterAstUpdated(Evaluator):
373
389
  The rational here is that for non-owned pictures/sequences, when a pictures/sequence is 'hidden' it should be advertised as 'deleted'.
374
390
 
375
391
  This is especially important for crawler like the meta-catalog, since they should also delete the sequence/picture when it is hidden
392
+
393
+ We also need to maintain retrocompatibility, since the `hidden` status has been replaced by a `visibility` field.
376
394
  """
377
395
 
378
396
  @handle(ast.Equal)
379
397
  def eq(self, node, lhs, rhs):
380
398
  if lhs == ast.Attribute("status") and rhs == "deleted":
381
- return ast.Or(node, ast.Equal(ast.Attribute("status"), "hidden")) # type: ignore
399
+ return ast.Or(node, ast.NotEqual(ast.Attribute("visibility"), "anyone")) # type: ignore
400
+ if lhs == ast.Attribute("status") and rhs == "hidden":
401
+ return ast.NotEqual(ast.Attribute("visibility"), "anyone") # type: ignore
382
402
  return node
383
403
 
384
404
  @handle(ast.Or)
@@ -387,8 +407,10 @@ class _FilterAstUpdated(Evaluator):
387
407
 
388
408
  @handle(ast.In)
389
409
  def in_(self, node, lhs, *options):
390
- if "deleted" in node.sub_nodes:
391
- node.sub_nodes.append("hidden")
410
+ if "deleted" in node.sub_nodes or "hidden" in node.sub_nodes:
411
+ if "hidden" in node.sub_nodes:
412
+ node.sub_nodes.remove("hidden")
413
+ return ast.Or(node, ast.NotEqual(ast.Attribute("visibility"), "anyone"))
392
414
  return node
393
415
 
394
416
  def adopt(self, node, *sub_args):
@@ -419,6 +441,12 @@ def _parse_sorty_by(value: Optional[str], field_mapping_func, SortByCls):
419
441
  return SortByCls(fields=orders)
420
442
 
421
443
 
444
+ def parse_boolean(value: Optional[str]) -> Optional[bool]:
445
+ if value is None:
446
+ return None
447
+ return value.lower() == "true"
448
+
449
+
422
450
  def parse_collection_sortby(value: Optional[str]) -> Optional[SortBy]:
423
451
  """Reads STAC/OGC sortby parameter, and sends a SQL ORDER BY string.
424
452
 
@@ -526,3 +554,20 @@ def as_uuid(value: str, error: str) -> UUID:
526
554
  return UUID(value)
527
555
  except ValueError:
528
556
  raise errors.InvalidAPIUsage(error)
557
+
558
+
559
+ class Visibility(Enum):
560
+ """Represent the visibility of picture"""
561
+
562
+ anyone = "anyone"
563
+ logged_only = "logged-only"
564
+ owner_only = "owner-only"
565
+
566
+
567
+ def check_visibility(visibility: Visibility | str):
568
+ """Check if the visibility is valid."""
569
+
570
+ if visibility == Visibility.logged_only or visibility == "logged-only":
571
+ if current_app.config["API_REGISTRATION_IS_OPEN"] is True:
572
+ return False
573
+ return True
geovisio/web/pictures.py CHANGED
@@ -44,7 +44,7 @@ def getPictureHD(pictureId, format):
44
44
  metadata = utils.pictures.checkPictureStatus(fses, pictureId)
45
45
 
46
46
  external_url = utils.pictures.getPublicHDPictureExternalUrl(pictureId, format)
47
- if external_url and metadata["status"] == "ready":
47
+ if external_url and metadata["status"] == "ready" and metadata["visibility"] == "anyone":
48
48
  return redirect(external_url)
49
49
 
50
50
  try:
@@ -89,7 +89,7 @@ def getPictureSD(pictureId, format):
89
89
  metadata = utils.pictures.checkPictureStatus(fses, pictureId)
90
90
 
91
91
  external_url = utils.pictures.getPublicDerivatePictureExternalUrl(pictureId, format, "sd.jpg")
92
- if external_url and metadata["status"] == "ready":
92
+ if external_url and metadata["status"] == "ready" and metadata["visibility"] == "anyone":
93
93
  return redirect(external_url)
94
94
 
95
95
  try:
@@ -178,7 +178,7 @@ def getPictureTile(pictureId, col, row, format):
178
178
 
179
179
  metadata = utils.pictures.checkPictureStatus(fses, pictureId)
180
180
  external_url = utils.pictures.getPublicDerivatePictureExternalUrl(pictureId, format, f"tiles/{col}_{row}.jpg")
181
- if external_url and metadata["status"] == "ready":
181
+ if external_url and metadata["status"] == "ready" and metadata["visibility"] == "anyone":
182
182
  return redirect(external_url)
183
183
 
184
184
  picPath = f"{utils.pictures.getPictureFolderPath(pictureId)}/tiles/{col}_{row}.jpg"
geovisio/web/prepare.py CHANGED
@@ -71,10 +71,12 @@ def prepareItem(collectionId, itemId, account=None):
71
71
  """SELECT 1
72
72
  FROM pictures p
73
73
  JOIN sequences_pictures sp ON p.id = sp.pic_id
74
+ JOIN sequences s ON s.id = sp.seq_id
74
75
  WHERE
75
76
  p.id = %(pic)s
76
77
  AND sp.seq_id = %(seq)s
77
- AND (p.account_id = %(acc)s OR p.status != 'hidden')"""
78
+ AND (is_picture_visible_by_user(p, %(acc)s))
79
+ AND (is_sequence_visible_by_user(s, %(acc)s))"""
78
80
  ),
79
81
  {"pic": itemId, "seq": collectionId, "acc": accountId},
80
82
  ).fetchone()
@@ -138,7 +140,7 @@ def prepareCollection(collectionId, account=None):
138
140
  FROM sequences
139
141
  WHERE
140
142
  id = %(seq)s
141
- AND (account_id = %(acc)s OR status != 'hidden')"""
143
+ AND is_sequence_visible_by_user(sequences, %(acc)s)"""
142
144
  ),
143
145
  {"seq": collectionId, "acc": accountId},
144
146
  ).fetchone()
@@ -0,0 +1,57 @@
1
+ import flask
2
+
3
+ bp = flask.Blueprint("queryables", __name__, url_prefix="/api")
4
+
5
+ ITEMS_QUERYABLES = {
6
+ "$schema": "https://json-schema.org/draft/2019-09/schema",
7
+ "$id": "https://stac-api.example.com/queryables",
8
+ "type": "object",
9
+ "title": "Queryables for Panoramax STAC API",
10
+ "description": "Queryable names for Panoramax STAC API Item Search filter.",
11
+ "properties": {
12
+ "semantics": {
13
+ "description": "Tag to represent the presence of semantics. Only support the IS NOT NULL operator for the moment, to search for all items with at least one semantic tag.",
14
+ "type": "string",
15
+ },
16
+ },
17
+ "patternProperties": {
18
+ "^semantics\\.(.+)$": {
19
+ "description": "Specific semantic tag. The semantic tag `key` should be after the prefix `semantics.`",
20
+ "type": "string",
21
+ }
22
+ },
23
+ "additionalProperties": False,
24
+ }
25
+
26
+ COLLECTION_QUERYABLES = {
27
+ "$schema": "https://json-schema.org/draft/2019-09/schema",
28
+ "$id": "https://stac-api.example.com/queryables",
29
+ "type": "object",
30
+ "title": "Queryables for Panoramax STAC API",
31
+ "description": "Queryable names for Panoramax STAC API Item Search filter.",
32
+ "properties": {
33
+ "created": {
34
+ "description": "Created date of the collection. The filter can be either a date or a datetime",
35
+ "type": "string",
36
+ "anyOf": [{"format": "date-time"}, {"format": "date"}],
37
+ },
38
+ "updated": {
39
+ "description": "Update date of the collection. The filter can be either a date or a datetime",
40
+ "type": "string",
41
+ "anyOf": [{"format": "date-time"}, {"format": "date"}],
42
+ },
43
+ },
44
+ "additionalProperties": False,
45
+ }
46
+
47
+
48
+ @bp.route("/queryables")
49
+ def search_queryables():
50
+ """List of queryables for search as defined by https://github.com/stac-api-extensions/filter?tab=readme-ov-file#queryables"""
51
+ return flask.jsonify(ITEMS_QUERYABLES), {"Cache-Control": "public, max-age=3600"}
52
+
53
+
54
+ @bp.route("/collections/queryables")
55
+ def collection_queryables():
56
+ """List of queryables for collection-search as defined by https://github.com/stac-api-extensions/filter?tab=readme-ov-file#queryables"""
57
+ return flask.jsonify(COLLECTION_QUERYABLES), {"Cache-Control": "public, max-age=3600"}
geovisio/web/stac.py CHANGED
@@ -144,6 +144,12 @@ def getLanding():
144
144
  "href": mapUrl,
145
145
  "title": "Pictures and sequences vector tiles",
146
146
  },
147
+ {
148
+ "title": "Queryables",
149
+ "href": url_for("queryables.search_queryables", _external=True),
150
+ "rel": "http://www.opengis.net/def/rel/ogc/1.0/queryables",
151
+ "type": "application/schema+json",
152
+ },
147
153
  {
148
154
  "rel": "xyz-style",
149
155
  "type": "application/json",
@@ -348,7 +354,6 @@ def getUserCatalog(userId, userIdMatchesAccount=False):
348
354
  collection_request.pagination_filter = parse_collection_filter(request.args.get("page"))
349
355
 
350
356
  userName = None
351
- meta_collection = None
352
357
  with db.cursor(current_app, row_factory=dict_row) as cursor:
353
358
  userName = cursor.execute("SELECT name FROM accounts WHERE id = %s", [userId]).fetchone()
354
359
 
@@ -359,8 +364,9 @@ def getUserCatalog(userId, userIdMatchesAccount=False):
359
364
  datasetBounds = get_dataset_bounds(
360
365
  cursor.connection,
361
366
  collection_request.sort_by,
362
- additional_filters=SQL("s.account_id = %(account)s"),
367
+ additional_filters=SQL("s.account_id = %(account)s AND is_sequence_visible_by_user(s, %(account_to_query)s)"),
363
368
  additional_filters_params={"account": userId},
369
+ account_to_query_id=auth.get_current_account_id(),
364
370
  )
365
371
 
366
372
  if datasetBounds is None: