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
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,20 +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
  from geovisio.utils.cql2 import parse_cql2_filter
19
20
 
20
21
 
@@ -43,11 +44,11 @@ def parse_datetime(value, error, fallback_as_UTC=False):
43
44
 
44
45
  """
45
46
  # Hack to parse a date
46
- # dateutils know how to parse lots of date, but fail to correctly parse date formated by `datetime.isoformat()`
47
+ # dateutils know how to parse lots of date, but fail to correctly parse date formatted by `datetime.isoformat()`
47
48
  # (like all the dates returned by the API).
48
49
  # datetime.isoformat is like: `2023-06-17T21:22:18.406856+02:00`
49
- # dateutils silently fails the parse, and create an incorect date
50
- # so we first try to parse it like an isoformated date, and if this fails we try the flexible dateutils
50
+ # dateutils silently fails the parse, and create an incorrect date
51
+ # so we first try to parse it like an isoformatted date, and if this fails we try the flexible dateutils
51
52
  d = None
52
53
  try:
53
54
  d = datetime.datetime.fromisoformat(value)
@@ -328,16 +329,20 @@ def parse_list(value: Optional[Any], tryFallbacks: bool = True, paramName: Optio
328
329
  def parse_collection_filter(value: Optional[str]) -> Optional[sql.SQL]:
329
330
  """Reads STAC filter parameter and sends SQL condition back.
330
331
 
332
+ Note: if more search filters are added, don't forget to add them to the qeryables endpoint (in queryables.py)
333
+
331
334
  >>> parse_collection_filter('')
332
335
 
333
336
  >>> parse_collection_filter("updated >= '2023-12-31'")
334
337
  SQL("(s.updated_at >= '2023-12-31')")
335
338
  >>> parse_collection_filter("updated >= '2023-12-31' AND created < '2023-10-31'")
336
339
  SQL("((s.updated_at >= '2023-12-31') AND (s.inserted_at < '2023-10-31'))")
337
- >>> parse_collection_filter("status IN ('deleted','ready')") # when we ask for deleted, we should also have hidden collections
338
- SQL("s.status IN ('deleted', 'ready', 'hidden')")
339
- >>> parse_collection_filter("status = 'deleted' OR status = 'ready'")
340
- 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
341
346
  >>> parse_collection_filter('invalid = 10') # doctest: +IGNORE_EXCEPTION_DETAIL
342
347
  Traceback (most recent call last):
343
348
  geovisio.errors.InvalidAPIUsage: Unsupported filter parameter
@@ -345,6 +350,18 @@ def parse_collection_filter(value: Optional[str]) -> Optional[sql.SQL]:
345
350
  Traceback (most recent call last):
346
351
  geovisio.errors.InvalidAPIUsage: Unsupported filter parameter
347
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
+ )
348
365
  return parse_cql2_filter(value, STAC_FIELD_TO_SQL_FILTER, ast_updater=_alterFilterAst)
349
366
 
350
367
 
@@ -372,12 +389,16 @@ class _FilterAstUpdated(Evaluator):
372
389
  The rational here is that for non-owned pictures/sequences, when a pictures/sequence is 'hidden' it should be advertised as 'deleted'.
373
390
 
374
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.
375
394
  """
376
395
 
377
396
  @handle(ast.Equal)
378
397
  def eq(self, node, lhs, rhs):
379
398
  if lhs == ast.Attribute("status") and rhs == "deleted":
380
- 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
381
402
  return node
382
403
 
383
404
  @handle(ast.Or)
@@ -386,8 +407,10 @@ class _FilterAstUpdated(Evaluator):
386
407
 
387
408
  @handle(ast.In)
388
409
  def in_(self, node, lhs, *options):
389
- if "deleted" in node.sub_nodes:
390
- 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"))
391
414
  return node
392
415
 
393
416
  def adopt(self, node, *sub_args):
@@ -400,7 +423,31 @@ def _alterFilterAst(ast: ast.Node):
400
423
  return filtered
401
424
 
402
425
 
403
- def parse_sortby(value: Optional[str]) -> Optional[SortBy]:
426
+ def _parse_sorty_by(value: Optional[str], field_mapping_func, SortByCls):
427
+ if not value:
428
+ return None
429
+ # Check value pattern
430
+ if not RGX_SORTBY.match(value):
431
+ raise errors.InvalidAPIUsage(_("Unsupported sortby parameter: syntax isn't correct"), status_code=400)
432
+ values = value.split(",")
433
+ orders = []
434
+ for v in values:
435
+ direction = SQLDirection.DESC if v.startswith("-") else SQLDirection.ASC
436
+ raw_field = v.lstrip("+-")
437
+ f = field_mapping_func(raw_field, direction)
438
+
439
+ orders.append(f)
440
+
441
+ return SortByCls(fields=orders)
442
+
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
+
450
+ def parse_collection_sortby(value: Optional[str]) -> Optional[SortBy]:
404
451
  """Reads STAC/OGC sortby parameter, and sends a SQL ORDER BY string.
405
452
 
406
453
  Parameters
@@ -416,46 +463,40 @@ def parse_sortby(value: Optional[str]) -> Optional[SortBy]:
416
463
 
417
464
  None if no sort by is found
418
465
 
419
- >>> parse_sortby(None)
420
- >>> parse_sortby("")
421
- >>> parse_sortby('updated')
466
+ >>> parse_collection_sortby(None)
467
+ >>> parse_collection_sortby("")
468
+ >>> parse_collection_sortby('updated')
422
469
  SortBy(fields=[SortByField(field=FieldMapping(sql_column=SQL('updated_at'), stac='updated'), direction=<SQLDirection.ASC: SQL('ASC')>)])
423
- >>> parse_sortby('+created')
470
+ >>> parse_collection_sortby('+created')
424
471
  SortBy(fields=[SortByField(field=FieldMapping(sql_column=SQL('inserted_at'), stac='created'), direction=<SQLDirection.ASC: SQL('ASC')>)])
425
- >>> parse_sortby('-created')
472
+ >>> parse_collection_sortby('-created')
426
473
  SortBy(fields=[SortByField(field=FieldMapping(sql_column=SQL('inserted_at'), stac='created'), direction=<SQLDirection.DESC: SQL('DESC')>)])
427
- >>> parse_sortby('+updated,-created')
474
+ >>> parse_collection_sortby('+updated,-created')
428
475
  SortBy(fields=[SortByField(field=FieldMapping(sql_column=SQL('updated_at'), stac='updated'), direction=<SQLDirection.ASC: SQL('ASC')>), SortByField(field=FieldMapping(sql_column=SQL('inserted_at'), stac='created'), direction=<SQLDirection.DESC: SQL('DESC')>)])
429
- >>> parse_sortby('invalid') # doctest: +IGNORE_EXCEPTION_DETAIL
476
+ >>> parse_collection_sortby('invalid') # doctest: +IGNORE_EXCEPTION_DETAIL
430
477
  Traceback (most recent call last):
431
478
  geovisio.errors.InvalidAPIUsage: Unsupported sortby parameter
432
- >>> parse_sortby('~nb') # doctest: +IGNORE_EXCEPTION_DETAIL
479
+ >>> parse_collection_sortby('~nb') # doctest: +IGNORE_EXCEPTION_DETAIL
433
480
  Traceback (most recent call last):
434
481
  geovisio.errors.InvalidAPIUsage: Unsupported sortby parameter
435
482
  """
436
483
 
437
- if value is not None and len(value) > 0:
438
- # Check value pattern
439
- if RGX_SORTBY.match(value):
440
- values = value.split(",")
441
- orders = []
442
- for v in values:
443
- direction = SQLDirection.DESC if v.startswith("-") else SQLDirection.ASC
444
- vOnly = v.replace("+", "").replace("-", "")
484
+ def mapping(raw_field: str, direction: SQLDirection):
485
+ if raw_field not in STAC_FIELD_MAPPINGS:
486
+ raise errors.InvalidAPIUsage(_("Unsupported sortby parameter: invalid column name"), status_code=400)
487
+ return SortByField(field=STAC_FIELD_MAPPINGS[raw_field], direction=direction)
445
488
 
446
- # Check if in value mapping
447
- if vOnly not in STAC_FIELD_MAPPINGS:
448
- raise errors.InvalidAPIUsage(_("Unsupported sortby parameter: invalid column name"), status_code=400)
449
- field_mapping = STAC_FIELD_MAPPINGS[vOnly]
489
+ return _parse_sorty_by(value, mapping, SortByCls=SortBy)
450
490
 
451
- orders.append(SortByField(field=field_mapping, direction=direction))
452
491
 
453
- # Create definitive ORDER string
454
- return SortBy(fields=orders)
455
- else:
456
- raise errors.InvalidAPIUsage(_("Unsupported sortby parameter: syntax isn't correct"), status_code=400)
457
- else:
458
- return None
492
+ def parse_item_sortby(value: Optional[str]) -> Optional[utils_items.SortBy]:
493
+ def mapping(raw_field: str, direction: SQLDirection):
494
+ if raw_field == "distance_to" or raw_field not in utils_items.SortableItemField.__dict__:
495
+ # distance to is for the moment only an implicit sort when search a point or in a bbox
496
+ raise errors.InvalidAPIUsage(_("Unsupported sortby parameter: invalid field"), status_code=400)
497
+ return utils_items.ItemSortByField(field=utils_items.SortableItemField[raw_field], direction=direction)
498
+
499
+ return _parse_sorty_by(value, mapping, SortByCls=utils_items.SortBy)
459
500
 
460
501
 
461
502
  def parse_collections_limit(limit: Optional[str]) -> int:
@@ -513,3 +554,20 @@ def as_uuid(value: str, error: str) -> UUID:
513
554
  return UUID(value)
514
555
  except ValueError:
515
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"
@@ -208,3 +208,37 @@ def getPictureTile(pictureId, col, row, format):
208
208
  raise errors.InvalidAPIUsage(_("Unable to read picture on filesystem"), status_code=500)
209
209
 
210
210
  return utils.pictures.sendInFormat(picture, "jpeg", format)
211
+
212
+
213
+ @bp.route("/<uuid:pictureId>")
214
+ def getPictureById(pictureId):
215
+ """Get picture's STAC definition.
216
+
217
+ It's the non-stac alias to the `/api/collections/<collectionId>/items/<itemId>` endpoint (but you don't need to know the collection ID here).
218
+ ---
219
+ tags:
220
+ - Pictures
221
+ parameters:
222
+ - name: pictureId
223
+ in: path
224
+ description: ID of the picture (called item in STAC) to retrieve
225
+ required: true
226
+ schema:
227
+ type: string
228
+ responses:
229
+ 102:
230
+ description: the picture (which is still under process)
231
+ content:
232
+ application/geo+json:
233
+ schema:
234
+ $ref: '#/components/schemas/GeoVisioItem'
235
+ 200:
236
+ description: the wanted picture
237
+ content:
238
+ application/geo+json:
239
+ schema:
240
+ $ref: '#/components/schemas/GeoVisioItem'
241
+ """
242
+ from geovisio.web.items import getCollectionItem
243
+
244
+ return getCollectionItem(collectionId=None, itemId=pictureId)
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()