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/docs.py CHANGED
@@ -394,6 +394,7 @@ The CSV headers will be:
394
394
  "geovisio:sorted-by": {"$ref": "#/components/schemas/GeoVisioCollectionSortedBy"},
395
395
  "geovisio:upload-software": {"$ref": "#/components/schemas/GeoVisioCollectionUploadSoftware"},
396
396
  "geovisio:length_km": {"$ref": "#/components/schemas/GeoVisioLengthKm"},
397
+ "geovisio:visibility": {"$ref": "#/components/schemas/GeoVisioVisibility"},
397
398
  "quality:horizontal_accuracy": {"type": "number", "title": "Estimated GPS position precision (in meters)"},
398
399
  "quality:horizontal_accuracy_type": {
399
400
  "type": "string",
@@ -496,6 +497,7 @@ The CSV headers will be:
496
497
  "minimum": 1,
497
498
  "title": "Rank of the picture in its collection.",
498
499
  },
500
+ "geovisio:visibility": {"$ref": "#/components/schemas/GeoVisioVisibility"},
499
501
  "original_file:size": {"type": "integer", "minimum": 0, "title": "Size of the original file, in bytes"},
500
502
  "original_file:name": {"type": "string", "title": "Original file name"},
501
503
  "panoramax:horizontal_pixel_density": {
@@ -655,7 +657,15 @@ Available properties are:
655
657
  },
656
658
  "GeoVisioItemStatus": {
657
659
  "type": "string",
658
- "enum": ["ready", "broken", "waiting-for-process"],
660
+ "enum": ["ready", "broken", "waiting-for-process", "pouet"],
661
+ },
662
+ "GeoVisioVisibility": {
663
+ "type": "string",
664
+ "description": """Visibility of the object. Can be set to:
665
+ * `anyone`: visible to anyone
666
+ * `owner-only`: visible to the owner and administrator only
667
+ * `logged-only`: visible to logged users only. Note that this is not available on all Panoramax instances, only those with restricted account creation. See the possible visibility values for a given instance on /api/configuration (field `visibility`).""",
668
+ "enum": ["anyone", "owner-only", "logged-only"],
659
669
  },
660
670
  "GeoVisioPostReport": reports.ReportCreationParameter.model_json_schema(
661
671
  ref_template="#/components/schemas/GeoVisioPostReport/$defs/{model}", mode="serialization"
@@ -938,7 +948,8 @@ A CQL2 filter expression for filtering sequences.
938
948
  Allowed properties are:
939
949
  * "created": upload date
940
950
  * "updated": last edit date
941
- * "status": status of the sequence. Can either be "ready" (for collections ready to be served) or "deleted" for deleted collection. By default, only the "ready" collections will be shown.
951
+
952
+ Note: the `status` filter is not supported anymore, use the `show_deleted` parameter instead if you need to query deleted collections
942
953
 
943
954
  Usage doc can be found here: https://docs.geoserver.org/2.23.x/en/user/tutorials/cql/cql_tutorial.html
944
955
 
@@ -1009,7 +1020,7 @@ Note that this parameter is not taken in account for 360° pictures, as by defin
1009
1020
  "description": """Define the sort order of the results of a search.
1010
1021
  Sort order is defined based on preceding '+' (asc) or '-' (desc).
1011
1022
 
1012
- By default we sort to get the last updated pictures firstn (-updated).
1023
+ By default we sort to get the last updated pictures first (-updated).
1013
1024
 
1014
1025
  Available properties are:
1015
1026
  * `ts`: capture datetime of the picture
@@ -1037,11 +1048,14 @@ For the moment only equality (`=`) and list (`IN`) filters are supported. We do
1037
1048
 
1038
1049
  To search for any values of a semantic tag, use `semantics.some_key IS NOT NULL` (case matter here).
1039
1050
 
1051
+ To search for items with any semantic tags, use `"semantics" IS NOT NULL`.
1052
+
1040
1053
  Examples:
1041
1054
 
1042
1055
  * "semantics.osm|traffic_sign"='yes'
1043
1056
  * "semantics.osm|traffic_sign" IS NOT NULL'
1044
1057
  * "semantics.osm|amenity" IN ('bench', 'whatever') OR "semantics.osm|traffic_sign"='yes'
1058
+ * "semantics" IS NOT NULL
1045
1059
  """,
1046
1060
  "required": False,
1047
1061
  "schema": {
geovisio/web/items.py CHANGED
@@ -5,8 +5,7 @@ import os
5
5
  from typing import Dict, List, Optional, Any
6
6
  from urllib.parse import unquote
7
7
  from psycopg.types.json import Jsonb
8
- from pydantic import BaseModel, ConfigDict, ValidationError, field_validator, model_validator
9
- from shapely import intersects
8
+ from pydantic import BaseModel, ValidationError, field_validator, model_validator
10
9
  from werkzeug.datastructures import MultiDict
11
10
  from uuid import UUID
12
11
  from geovisio import errors, utils
@@ -30,13 +29,15 @@ from geovisio.web.params import (
30
29
  parse_lonlat,
31
30
  parse_distance_range,
32
31
  parse_picture_heading,
32
+ Visibility,
33
+ check_visibility,
33
34
  )
34
35
  from geovisio.utils.fields import Bounds, SQLDirection
35
36
  import hashlib
36
37
  from psycopg.rows import dict_row
37
38
  from psycopg.sql import SQL
38
39
  from geovisio.web.utils import (
39
- accountIdOrDefault,
40
+ accountOrDefault,
40
41
  cleanNoneInList,
41
42
  dbTsToStac,
42
43
  dbTsToStacTZ,
@@ -49,12 +50,18 @@ from flask import current_app, request, url_for, Blueprint
49
50
  from flask_babel import gettext as _, get_locale
50
51
  from geopic_tag_reader.writer import writePictureMetadata, PictureMetadata
51
52
  import sentry_sdk
52
- import math
53
53
 
54
54
 
55
55
  bp = Blueprint("stac_items", __name__, url_prefix="/api")
56
56
 
57
57
 
58
+ def retrocompatible_picture_status(db_pic):
59
+ """We used to display status='hidden' for hidden picture, now that the status and the visiblity has been split, we still return a 'ready' status for retrocompatibility"""
60
+ if db_pic.get("status") == "hidden":
61
+ return "ready"
62
+ return db_pic.get("status")
63
+
64
+
58
65
  def dbPictureToStacItem(dbPic):
59
66
  """Transforms a picture extracted from database into a STAC Item
60
67
 
@@ -143,14 +150,15 @@ def dbPictureToStacItem(dbPic):
143
150
  ),
144
151
  "pers:pitch": dbPic["metadata"].get("pitch"),
145
152
  "pers:roll": dbPic["metadata"].get("roll"),
146
- "geovisio:status": dbPic.get("status"),
153
+ "geovisio:status": retrocompatible_picture_status(dbPic),
154
+ "geovisio:visibility": dbPic.get("visibility"),
147
155
  "geovisio:producer": dbPic["account_name"],
148
156
  "geovisio:rank_in_collection": dbPic["rank"],
149
157
  "original_file:size": dbPic["metadata"].get("originalFileSize"),
150
158
  "original_file:name": dbPic["metadata"].get("originalFileName"),
151
159
  "panoramax:horizontal_pixel_density": dbPic.get("h_pixel_density"),
152
- "geovisio:image": _getHDJpgPictureURL(dbPic["id"], dbPic.get("status")),
153
- "geovisio:thumbnail": _getThumbJpgPictureURL(dbPic["id"], dbPic.get("status")),
160
+ "geovisio:image": _getHDJpgPictureURL(dbPic["id"], dbPic.get("visibility")),
161
+ "geovisio:thumbnail": _getThumbJpgPictureURL(dbPic["id"], dbPic.get("visibility")),
154
162
  "exif": removeNoneInDict(cleanupExif(dbPic["exif"])),
155
163
  "quality:horizontal_accuracy": float("{:.1f}".format(dbPic["gps_accuracy_m"])) if dbPic.get("gps_accuracy_m") else None,
156
164
  "semantics": [s for s in dbPic.get("semantics") or [] if s],
@@ -185,21 +193,21 @@ def dbPictureToStacItem(dbPic):
185
193
  "description": "Highest resolution available of this picture",
186
194
  "roles": ["data"],
187
195
  "type": "image/jpeg",
188
- "href": _getHDJpgPictureURL(dbPic["id"], status=dbPic.get("status")),
196
+ "href": _getHDJpgPictureURL(dbPic["id"], visibility=dbPic.get("visibility")),
189
197
  },
190
198
  "sd": {
191
199
  "title": "SD picture",
192
200
  "description": "Picture in standard definition (fixed width of 2048px)",
193
201
  "roles": ["visual"],
194
202
  "type": "image/jpeg",
195
- "href": _getSDJpgPictureURL(dbPic["id"], status=dbPic.get("status")),
203
+ "href": _getSDJpgPictureURL(dbPic["id"], visibility=dbPic.get("visibility")),
196
204
  },
197
205
  "thumb": {
198
206
  "title": "Thumbnail",
199
207
  "description": "Picture in low definition (fixed width of 500px)",
200
208
  "roles": ["thumbnail"],
201
209
  "type": "image/jpeg",
202
- "href": _getThumbJpgPictureURL(dbPic["id"], status=dbPic.get("status")),
210
+ "href": _getThumbJpgPictureURL(dbPic["id"], visibility=dbPic.get("visibility")),
203
211
  },
204
212
  },
205
213
  "collection": str(seqId),
@@ -277,7 +285,7 @@ def dbPictureToStacItem(dbPic):
277
285
  "description": "Highest resolution available of this picture, as tiles",
278
286
  "roles": ["data"],
279
287
  "type": "image/jpeg",
280
- "href": _getTilesJpgPictureURL(dbPic["id"], status=dbPic.get("status")),
288
+ "href": _getTilesJpgPictureURL(dbPic["id"], visibility=dbPic.get("visibility")),
281
289
  }
282
290
  }
283
291
 
@@ -366,9 +374,10 @@ def getCollectionItems(collectionId):
366
374
 
367
375
  filters = [
368
376
  SQL("sp.seq_id = %(seq)s"),
369
- SQL("(p.status = 'ready' OR p.account_id = %(account)s)"),
370
- SQL("(is_sequence_visible_by_user(s, %(account)s))"),
377
+ SQL("(p.preparing_status = 'prepared' OR p.account_id = %(account)s)"),
371
378
  ]
379
+ if account is None or not account.can_see_all():
380
+ filters.append(SQL("(is_picture_visible_by_user(p, %(account)s))"))
372
381
 
373
382
  # Check if limit is valid
374
383
  sql_limit = SQL("")
@@ -407,7 +416,7 @@ def getCollectionItems(collectionId):
407
416
  + (", MAX(sp.rank) AS max_rank, MIN(sp.rank) AS min_rank " if paginated else "")
408
417
  + "FROM sequences s "
409
418
  + ("LEFT JOIN sequences_pictures sp ON sp.seq_id = s.id " if paginated else "")
410
- + "WHERE s.id = %(seq)s AND (is_sequence_visible_by_user(s, %(account)s)) "
419
+ + "WHERE s.id = %(seq)s AND (s.status = 'ready' OR s.account_id = %(account)s) AND is_sequence_visible_by_user(s, %(account)s) AND s.status != 'deleted'"
411
420
  + ("GROUP BY s.id" if paginated else ""),
412
421
  params,
413
422
  ).fetchone()
@@ -436,29 +445,36 @@ def getCollectionItems(collectionId):
436
445
  params["start_after_rank"] = rank
437
446
 
438
447
  query = SQL(
439
- """
440
- SELECT
441
- p.id, p.ts, p.heading, p.metadata, p.inserted_at, p.updated_at, p.status,
448
+ """SELECT
449
+ p.id, p.ts, p.heading, p.metadata, p.inserted_at, p.updated_at, p.status, p.visibility,
442
450
  ST_AsGeoJSON(p.geom)::json AS geojson,
443
451
  a.name AS account_name,
444
452
  p.account_id AS account_id,
445
453
  sp.seq_id, sp.rank, p.exif, p.gps_accuracy_m, p.h_pixel_density,
446
- CASE WHEN LAG(p.status) OVER othpics = 'ready' THEN LAG(p.id) OVER othpics END AS prevpic,
447
- CASE WHEN LAG(p.status) OVER othpics = 'ready' THEN ST_AsGeoJSON(LAG(p.geom) OVER othpics)::json END AS prevpicgeojson,
448
- CASE WHEN LEAD(p.status) OVER othpics = 'ready' THEN LEAD(p.id) OVER othpics END AS nextpic,
449
- CASE WHEN LEAD(p.status) OVER othpics = 'ready' THEN ST_AsGeoJSON(LEAD(p.geom) OVER othpics)::json END AS nextpicgeojson,
454
+ CASE WHEN LAG(is_picture_visible_by_user(p, %(account)s)) OVER othpics THEN LAG(p.id) OVER othpics END AS prevpic,
455
+ CASE WHEN LAG(is_picture_visible_by_user(p, %(account)s)) OVER othpics THEN ST_AsGeoJSON(LAG(p.geom) OVER othpics)::json END AS prevpicgeojson,
456
+ CASE WHEN LEAD(is_picture_visible_by_user(p, %(account)s)) OVER othpics THEN LEAD(p.id) OVER othpics END AS nextpic,
457
+ CASE WHEN LEAD(is_picture_visible_by_user(p, %(account)s)) OVER othpics THEN ST_AsGeoJSON(LEAD(p.geom) OVER othpics)::json END AS nextpicgeojson,
450
458
  get_picture_semantics(p.id) as semantics,
451
- get_picture_annotations(p.id) as annotations
459
+ get_picture_annotations(p.id) as annotations,
460
+ COALESCE(seq_sem.semantics, '[]'::json) AS sequence_semantics
452
461
  FROM sequences_pictures sp
453
462
  JOIN pictures p ON sp.pic_id = p.id
454
463
  JOIN accounts a ON a.id = p.account_id
455
464
  JOIN sequences s ON s.id = sp.seq_id
465
+ LEFT JOIN (
466
+ SELECT sequence_id, json_agg(json_strip_nulls(json_build_object(
467
+ 'key', key,
468
+ 'value', value
469
+ )) ORDER BY key, value) AS semantics
470
+ FROM sequences_semantics
471
+ GROUP BY sequence_id
472
+ ) seq_sem ON seq_sem.sequence_id = s.id
456
473
  WHERE
457
474
  {filter}
458
475
  WINDOW othpics AS (PARTITION BY sp.seq_id ORDER BY sp.rank)
459
476
  ORDER BY rank
460
- {limit}
461
- """
477
+ {limit}"""
462
478
  ).format(filter=SQL(" AND ").join(filters), limit=sql_limit)
463
479
 
464
480
  records = cursor.execute(query, params)
@@ -578,20 +594,31 @@ def getCollectionItems(collectionId):
578
594
  def _getPictureItemById(itemId: UUID, account: Optional[Account]):
579
595
  """Get a picture metadata by its ID"""
580
596
  with current_app.pool.connection() as conn:
597
+ perm_filter = SQL("")
598
+ if account is not None and account.can_see_all():
599
+ # admins can see all pictures, regardless of their visibility
600
+ perm_filter = SQL("TRUE")
601
+ else:
602
+ perm_filter = SQL(
603
+ """(p.account_id = %(acc)s OR p.status != 'hidden') -- for retrocompabitilty, we can drop this filter once database have migrated all hidden pictures
604
+ AND (is_picture_visible_by_user(p, %(acc)s))
605
+ AND (s.status != 'hidden' OR s.account_id = %(acc)s) -- same, we can drop this later (and replace it with `s.status = 'ready'`)
606
+ AND is_sequence_visible_by_user(s, %(acc)s)"""
607
+ )
608
+
581
609
  with conn.cursor(row_factory=dict_row) as cursor:
582
- # Check if there is a logged user
583
- account = auth.get_current_account()
584
- accountId = account.id if account else None
585
610
 
586
611
  # Get rank + position of wanted picture
587
612
  record = cursor.execute(
588
- """WITH seq AS (
613
+ SQL(
614
+ """WITH seq AS (
589
615
  SELECT seq_id FROM sequences_pictures WHERE pic_id = %(pic)s LIMIT 1
590
616
  )
591
617
  SELECT
592
618
  p.id, sp.seq_id, sp.rank, ST_AsGeoJSON(p.geom)::json AS geojson, p.heading, p.ts, p.metadata,
593
- p.inserted_at, p.updated_at, p.status, accounts.name AS account_name,
594
- p.account_id AS account_id,
619
+ p.inserted_at, p.updated_at, p.status,
620
+ accounts.name AS account_name, p.account_id AS account_id,
621
+ p.visibility,
595
622
  spl.prevpic, spl.prevpicgeojson, spl.nextpic, spl.nextpicgeojson, p.exif,
596
623
  relp.related_pics, p.gps_accuracy_m, p.h_pixel_density,
597
624
  get_picture_semantics(p.id) as semantics,
@@ -612,7 +639,7 @@ def _getPictureItemById(itemId: UUID, account: Optional[Account]):
612
639
  JOIN sequences_pictures sp ON p.id = sp.pic_id
613
640
  WHERE
614
641
  sp.seq_id IN (SELECT seq_id FROM seq)
615
- AND (p.account_id = %(acc)s OR p.status != 'hidden')
642
+ AND (is_picture_visible_by_user(p, %(acc)s) AND p.preparing_status = 'prepared')
616
643
  WINDOW othpics AS (PARTITION BY sp.seq_id ORDER BY sp.rank)
617
644
  ) spl ON p.id = spl.id
618
645
  LEFT JOIN (
@@ -662,11 +689,12 @@ def _getPictureItemById(itemId: UUID, account: Optional[Account]):
662
689
  ) seq_sem ON seq_sem.sequence_id = s.id
663
690
  WHERE sp.seq_id IN (SELECT seq_id FROM seq)
664
691
  AND p.id = %(pic)s
665
- AND (p.account_id = %(acc)s OR p.status != 'hidden')
666
- AND (s.status != 'hidden' OR s.account_id = %(acc)s)
692
+ -- TODO Should we show non prepared items to all ? AND (p.account_id = %(acc)s OR p.preparing_status = 'prepared')
693
+ AND {perm_filter}
667
694
  AND s.status != 'deleted'
668
- """,
669
- {"pic": itemId, "acc": accountId},
695
+ """
696
+ ).format(perm_filter=perm_filter),
697
+ {"pic": itemId, "acc": account.id if account is not None else None},
670
698
  ).fetchone()
671
699
 
672
700
  if record is None:
@@ -763,9 +791,12 @@ def searchItems():
763
791
 
764
792
  account = auth.get_current_account()
765
793
  accountId = account.id if account is not None else None
766
- sqlWhere = [SQL("(p.status = 'ready' OR p.account_id = %(account)s)"), SQL("(is_sequence_visible_by_user(s, %(account)s))")]
794
+ sqlWhere = [
795
+ SQL("(p.status = 'ready' AND is_picture_visible_by_user(p, %(account)s))"),
796
+ SQL("(s.status = 'ready' AND is_sequence_visible_by_user(s, %(account)s))"),
797
+ ]
767
798
  sqlParams: Dict[str, Any] = {"account": accountId}
768
- sqlSubQueryWhere = [SQL("(p.status = 'ready' OR p.account_id = %(account)s)")]
799
+ sqlSubQueryWhere = [SQL("(p.status = 'ready' AND is_picture_visible_by_user(p, %(account)s))")]
769
800
 
770
801
  #
771
802
  # Parameters parsing and verification
@@ -1172,7 +1203,7 @@ def postCollectionItem(collectionId, account=None):
1172
1203
  raise errors.InvalidAPIUsage(_("The collection has been deleted, impossible to add pictures to it"), status_code=404)
1173
1204
 
1174
1205
  # Compute various metadata
1175
- accountId = accountIdOrDefault(account)
1206
+ accountId = accountOrDefault(account).id
1176
1207
  raw_pic = picture.read()
1177
1208
  filesize = len(raw_pic)
1178
1209
 
@@ -1217,7 +1248,7 @@ def postCollectionItem(collectionId, account=None):
1217
1248
 
1218
1249
  # Return picture metadata
1219
1250
  return (
1220
- getCollectionItem(collectionId, picId)[0],
1251
+ _getPictureItemById(picId, account=account),
1221
1252
  202,
1222
1253
  {
1223
1254
  "Content-Type": "application/json",
@@ -1233,7 +1264,21 @@ class PatchItemParameter(BaseModel):
1233
1264
  heading: Optional[int] = None
1234
1265
  """Heading of the picture. The new heading will not be persisted in the picture's exif tags for the moment."""
1235
1266
  visible: Optional[bool] = None
1236
- """Should the picture be publicly visible ?"""
1267
+ """Should the picture be publicly visible ?
1268
+
1269
+ This parameter is deprecated in favor of the finer grained `visibility` parameter.
1270
+ `visible=true` is equivalent to `visibility=anyone`.
1271
+ `visible=false` is equivalent to `visibility=logged-only`.
1272
+ """
1273
+ visibility: Optional[Visibility] = None
1274
+ """Visibility of the sequence. Can be set to:
1275
+ * `anyone`: the sequence is visible to anyone
1276
+ * `owner-only`: the sequence is visible to the owner and administrator only
1277
+ * `logged-only`: the sequence is visible to logged users only
1278
+
1279
+ This visibility can also be set for each picture individually, using the `visibility` field of the pictures.
1280
+ 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.
1281
+ """
1237
1282
 
1238
1283
  capture_time: Optional[datetime] = None
1239
1284
  """Capture time of the picture. The new capture time will not be persisted in the picture's exif tags for the moment."""
@@ -1301,13 +1346,28 @@ class PatchItemParameter(BaseModel):
1301
1346
  raise errors.InvalidAPIUsage(_("Longitude cannot be overridden alone, latitude also needs to be set"))
1302
1347
  if self.longitude is None and self.latitude is not None:
1303
1348
  raise errors.InvalidAPIUsage(_("Latitude cannot be overridden alone, longitude also needs to be set"))
1349
+ if self.visibility is not None and self.visible is not None:
1350
+ raise errors.InvalidAPIUsage(_("Visibility and visible parameters are mutually exclusive parameters"))
1351
+ # handle retrocompatibility on the visible parameter
1352
+ if self.visible is not None:
1353
+ self.visibility = Visibility.anyone if self.visible is True else Visibility.owner_only
1304
1354
  return self
1305
1355
 
1306
1356
  def has_only_semantics_updates(self):
1307
1357
  return self.model_fields_set == {"semantics"}
1308
1358
 
1359
+ @field_validator("visibility", mode="after")
1360
+ @classmethod
1361
+ def validate_visibility(cls, visibility):
1362
+ if not check_visibility(visibility):
1363
+ raise errors.InvalidAPIUsage(
1364
+ _("The logged-only visibility is not allowed on this instance since anybody can create an account"),
1365
+ status_code=400,
1366
+ )
1367
+ return visibility
1368
+
1309
1369
 
1310
- def update_picture(itemId: UUID, account: Optional[Account]):
1370
+ def update_picture(itemId: UUID, account: Account):
1311
1371
  # Parse received parameters
1312
1372
  metadata = None
1313
1373
  content_type = (request.headers.get("Content-Type") or "").split(";")[0]
@@ -1327,16 +1387,23 @@ def update_picture(itemId: UUID, account: Optional[Account]):
1327
1387
  # Check if picture exists and if given account is authorized to edit
1328
1388
  with db.conn(current_app) as conn:
1329
1389
  with conn.transaction(), conn.cursor(row_factory=dict_row) as cursor:
1330
- pic = cursor.execute("SELECT status, account_id FROM pictures WHERE id = %s", [itemId]).fetchone()
1390
+ pic = cursor.execute(
1391
+ """SELECT p.visibility, p.account_id
1392
+ FROM pictures p
1393
+ JOIN sequences_pictures sp ON sp.pic_id = p.id
1394
+ JOIN sequences s ON s.id = sp.seq_id
1395
+ WHERE p.id = %(id)s AND is_picture_visible_by_user(p, %(account)s) AND is_sequence_visible_by_user(s, %(account)s)""",
1396
+ {"id": itemId, "account": account.id},
1397
+ ).fetchone()
1331
1398
 
1332
1399
  # Picture not found
1333
1400
  if not pic:
1334
1401
  raise errors.InvalidAPIUsage(_("Picture %(p)s wasn't found in database", p=itemId), status_code=404)
1335
1402
 
1336
- if account is not None and account.id != str(pic["account_id"]):
1403
+ if not account.can_edit_item(str(pic["account_id"])):
1337
1404
  # Account associated to picture doesn't match current user
1338
1405
  # and we limit the status change to only the owner.
1339
- if metadata.visible is not None:
1406
+ if metadata.visibility is not None:
1340
1407
  raise errors.InvalidAPIUsage(
1341
1408
  _("You're not authorized to edit the visibility of this picture. Only the owner can change this."), status_code=403
1342
1409
  )
@@ -1352,24 +1419,14 @@ def update_picture(itemId: UUID, account: Optional[Account]):
1352
1419
  sqlParams = {"id": itemId, "account": account.id}
1353
1420
 
1354
1421
  # Let's edit this picture
1355
- oldStatus = pic["status"]
1356
- if oldStatus not in ["ready", "hidden"]:
1357
- # Picture is in a preparing/broken/... state so no edit possible
1358
- raise errors.InvalidAPIUsage(
1359
- _(
1360
- "Picture %(p)s is in %(s)s state, its visibility can't be changed for now",
1361
- p=itemId,
1362
- s=oldStatus,
1363
- ),
1364
- status_code=400,
1365
- )
1422
+ oldVisibility = pic["visibility"]
1366
1423
 
1367
- newStatus = None
1368
- if metadata.visible is not None:
1369
- newStatus = "ready" if metadata.visible is True else "hidden"
1370
- if newStatus != oldStatus:
1371
- sqlUpdates.append(SQL("status = %(status)s"))
1372
- sqlParams["status"] = newStatus
1424
+ newVisibility = None
1425
+ if metadata.visibility is not None:
1426
+ newVisibility = metadata.visibility.value
1427
+ if newVisibility != oldVisibility:
1428
+ sqlUpdates.append(SQL("visibility = %(visibility)s"))
1429
+ sqlParams["visibility"] = newVisibility
1373
1430
 
1374
1431
  if metadata.heading is not None:
1375
1432
  sqlUpdates.extend([SQL("heading = %(heading)s"), SQL("heading_computed = false")])
@@ -1457,12 +1514,12 @@ def patchCollectionItem(collectionId, itemId, account):
1457
1514
  schema:
1458
1515
  $ref: '#/components/schemas/GeoVisioItem'
1459
1516
  """
1460
- return update_picture(itemId, account)
1517
+ return update_picture(itemId, account=account)
1461
1518
 
1462
1519
 
1463
1520
  @bp.route("/collections/<uuid:collectionId>/items/<uuid:itemId>", methods=["DELETE"])
1464
1521
  @auth.login_required()
1465
- def deleteCollectionItem(collectionId, itemId, account):
1522
+ def deleteCollectionItem(collectionId: UUID, itemId: UUID, account: Account):
1466
1523
  """Delete an existing picture
1467
1524
  ---
1468
1525
  tags:
@@ -1498,7 +1555,7 @@ def deleteCollectionItem(collectionId, itemId, account):
1498
1555
  raise errors.InvalidAPIUsage(_("Picture %(p)s wasn't found in database", p=itemId), status_code=404)
1499
1556
 
1500
1557
  # Account associated to picture doesn't match current user
1501
- if account is not None and account.id != str(pic[1]):
1558
+ if not account.can_edit_item(str(pic[1])):
1502
1559
  raise errors.InvalidAPIUsage(_("You're not authorized to edit this picture"), status_code=403)
1503
1560
 
1504
1561
  cursor.execute("DELETE FROM pictures WHERE id = %s", [itemId])
@@ -1509,29 +1566,29 @@ def deleteCollectionItem(collectionId, itemId, account):
1509
1566
  return "", 204
1510
1567
 
1511
1568
 
1512
- def _getHDJpgPictureURL(picId: str, status: Optional[str]):
1569
+ def _getHDJpgPictureURL(picId: str, visibility: Optional[str]):
1513
1570
  external_url = utils.pictures.getPublicHDPictureExternalUrl(picId, format="jpg")
1514
- if external_url and status == "ready": # we always serve non ready pictures through the API to be able to check permission:
1571
+ if external_url and visibility == "anyone": # we always serve non public pictures through the API to be able to check permission:
1515
1572
  return external_url
1516
1573
  return url_for("pictures.getPictureHD", _external=True, pictureId=picId, format="jpg")
1517
1574
 
1518
1575
 
1519
- def _getSDJpgPictureURL(picId: str, status: Optional[str]):
1576
+ def _getSDJpgPictureURL(picId: str, visibility: Optional[str]):
1520
1577
  external_url = utils.pictures.getPublicDerivatePictureExternalUrl(picId, format="jpg", derivateFileName="sd.jpg")
1521
- if external_url and status == "ready": # we always serve non ready pictures through the API to be able to check permission:
1578
+ if external_url and visibility == "anyone": # we always serve non public pictures through the API to be able to check permission:
1522
1579
  return external_url
1523
1580
  return url_for("pictures.getPictureSD", _external=True, pictureId=picId, format="jpg")
1524
1581
 
1525
1582
 
1526
- def _getThumbJpgPictureURL(picId: str, status: Optional[str]):
1583
+ def _getThumbJpgPictureURL(picId: str, visibility: Optional[str]):
1527
1584
  external_url = utils.pictures.getPublicDerivatePictureExternalUrl(picId, format="jpg", derivateFileName="thumb.jpg")
1528
- if external_url and status == "ready": # we always serve non ready pictures through the API to be able to check permission
1585
+ if external_url and visibility == "anyone": # we always serve non public pictures through the API to be able to check permission
1529
1586
  return external_url
1530
1587
  return url_for("pictures.getPictureThumb", _external=True, pictureId=picId, format="jpg")
1531
1588
 
1532
1589
 
1533
- def _getTilesJpgPictureURL(picId: str, status: Optional[str]):
1590
+ def _getTilesJpgPictureURL(picId: str, visibility: Optional[str]):
1534
1591
  external_url = utils.pictures.getPublicDerivatePictureExternalUrl(picId, format="jpg", derivateFileName="tiles/{TileCol}_{TileRow}.jpg")
1535
- if external_url and status == "ready": # we always serve non ready pictures through the API to be able to check permission:
1592
+ if external_url and visibility == "anyone": # we always serve non public pictures through the API to be able to check permission:
1536
1593
  return external_url
1537
1594
  return unquote(url_for("pictures.getPictureTile", _external=True, pictureId=picId, format="jpg", col="{TileCol}", row="{TileRow}"))