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/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
@@ -15,7 +14,9 @@ from geovisio.utils.cql2 import parse_search_filter
15
14
  from geovisio.utils.params import validation_error
16
15
  from geovisio.utils.pictures import cleanupExif
17
16
  from geovisio.utils.semantics import Entity, EntityType, update_tags
17
+ from geovisio.utils.items import SortableItemField, SortBy, ItemSortByField
18
18
  from geovisio.utils.tags import SemanticTagUpdate
19
+ from geovisio.utils.auth import Account
19
20
  from geovisio.web.params import (
20
21
  as_latitude,
21
22
  as_longitude,
@@ -23,17 +24,20 @@ from geovisio.web.params import (
23
24
  parse_datetime,
24
25
  parse_datetime_interval,
25
26
  parse_bbox,
27
+ parse_item_sortby,
26
28
  parse_list,
27
29
  parse_lonlat,
28
30
  parse_distance_range,
29
31
  parse_picture_heading,
32
+ Visibility,
33
+ check_visibility,
30
34
  )
31
- from geovisio.utils.fields import Bounds
35
+ from geovisio.utils.fields import Bounds, SQLDirection
32
36
  import hashlib
33
37
  from psycopg.rows import dict_row
34
38
  from psycopg.sql import SQL
35
39
  from geovisio.web.utils import (
36
- accountIdOrDefault,
40
+ accountOrDefault,
37
41
  cleanNoneInList,
38
42
  dbTsToStac,
39
43
  dbTsToStacTZ,
@@ -46,13 +50,19 @@ from flask import current_app, request, url_for, Blueprint
46
50
  from flask_babel import gettext as _, get_locale
47
51
  from geopic_tag_reader.writer import writePictureMetadata, PictureMetadata
48
52
  import sentry_sdk
49
- import math
50
53
 
51
54
 
52
55
  bp = Blueprint("stac_items", __name__, url_prefix="/api")
53
56
 
54
57
 
55
- def dbPictureToStacItem(seqId, dbPic):
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
+
65
+ def dbPictureToStacItem(dbPic):
56
66
  """Transforms a picture extracted from database into a STAC Item
57
67
 
58
68
  Parameters
@@ -70,6 +80,7 @@ def dbPictureToStacItem(seqId, dbPic):
70
80
 
71
81
  sensorDim = None
72
82
  visibleArea = None
83
+ seqId = str(dbPic["seq_id"])
73
84
  if dbPic["metadata"].get("crop") is not None:
74
85
  sensorDim = [dbPic["metadata"]["crop"].get("fullWidth"), dbPic["metadata"]["crop"].get("fullHeight")]
75
86
  visibleArea = [
@@ -115,7 +126,7 @@ def dbPictureToStacItem(seqId, dbPic):
115
126
  "datetime": dbTsToStac(dbPic["ts"]),
116
127
  "datetimetz": dbTsToStacTZ(dbPic["ts"], dbPic["metadata"].get("tz")),
117
128
  "created": dbTsToStac(dbPic["inserted_at"]),
118
- # TODO : add "updated" TS for last edit time of metadata
129
+ "updated": dbTsToStac(dbPic["updated_at"]),
119
130
  "license": current_app.config["API_PICTURES_LICENSE_SPDX_ID"],
120
131
  "view:azimuth": dbPic["heading"],
121
132
  "pers:interior_orientation": (
@@ -139,18 +150,20 @@ def dbPictureToStacItem(seqId, dbPic):
139
150
  ),
140
151
  "pers:pitch": dbPic["metadata"].get("pitch"),
141
152
  "pers:roll": dbPic["metadata"].get("roll"),
142
- "geovisio:status": dbPic.get("status"),
153
+ "geovisio:status": retrocompatible_picture_status(dbPic),
154
+ "geovisio:visibility": dbPic.get("visibility"),
143
155
  "geovisio:producer": dbPic["account_name"],
144
156
  "geovisio:rank_in_collection": dbPic["rank"],
145
157
  "original_file:size": dbPic["metadata"].get("originalFileSize"),
146
158
  "original_file:name": dbPic["metadata"].get("originalFileName"),
147
159
  "panoramax:horizontal_pixel_density": dbPic.get("h_pixel_density"),
148
- "geovisio:image": _getHDJpgPictureURL(dbPic["id"], dbPic.get("status")),
149
- "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")),
150
162
  "exif": removeNoneInDict(cleanupExif(dbPic["exif"])),
151
163
  "quality:horizontal_accuracy": float("{:.1f}".format(dbPic["gps_accuracy_m"])) if dbPic.get("gps_accuracy_m") else None,
152
164
  "semantics": [s for s in dbPic.get("semantics") or [] if s],
153
165
  "annotations": [a for a in dbPic.get("annotations") or [] if a],
166
+ "collection": {"semantics": dbPic["sequence_semantics"]} if "sequence_semantics" in dbPic else None,
154
167
  }
155
168
  ),
156
169
  "links": cleanNoneInList(
@@ -180,21 +193,21 @@ def dbPictureToStacItem(seqId, dbPic):
180
193
  "description": "Highest resolution available of this picture",
181
194
  "roles": ["data"],
182
195
  "type": "image/jpeg",
183
- "href": _getHDJpgPictureURL(dbPic["id"], status=dbPic.get("status")),
196
+ "href": _getHDJpgPictureURL(dbPic["id"], visibility=dbPic.get("visibility")),
184
197
  },
185
198
  "sd": {
186
199
  "title": "SD picture",
187
200
  "description": "Picture in standard definition (fixed width of 2048px)",
188
201
  "roles": ["visual"],
189
202
  "type": "image/jpeg",
190
- "href": _getSDJpgPictureURL(dbPic["id"], status=dbPic.get("status")),
203
+ "href": _getSDJpgPictureURL(dbPic["id"], visibility=dbPic.get("visibility")),
191
204
  },
192
205
  "thumb": {
193
206
  "title": "Thumbnail",
194
207
  "description": "Picture in low definition (fixed width of 500px)",
195
208
  "roles": ["thumbnail"],
196
209
  "type": "image/jpeg",
197
- "href": _getThumbJpgPictureURL(dbPic["id"], status=dbPic.get("status")),
210
+ "href": _getThumbJpgPictureURL(dbPic["id"], visibility=dbPic.get("visibility")),
198
211
  },
199
212
  },
200
213
  "collection": str(seqId),
@@ -272,7 +285,7 @@ def dbPictureToStacItem(seqId, dbPic):
272
285
  "description": "Highest resolution available of this picture, as tiles",
273
286
  "roles": ["data"],
274
287
  "type": "image/jpeg",
275
- "href": _getTilesJpgPictureURL(dbPic["id"], status=dbPic.get("status")),
288
+ "href": _getTilesJpgPictureURL(dbPic["id"], visibility=dbPic.get("visibility")),
276
289
  }
277
290
  }
278
291
 
@@ -361,9 +374,10 @@ def getCollectionItems(collectionId):
361
374
 
362
375
  filters = [
363
376
  SQL("sp.seq_id = %(seq)s"),
364
- SQL("(p.status = 'ready' OR p.account_id = %(account)s)"),
365
- SQL("(is_sequence_visible_by_user(s, %(account)s))"),
377
+ SQL("(p.preparing_status = 'prepared' OR p.account_id = %(account)s)"),
366
378
  ]
379
+ if account is None or not account.can_see_all():
380
+ filters.append(SQL("(is_picture_visible_by_user(p, %(account)s))"))
367
381
 
368
382
  # Check if limit is valid
369
383
  sql_limit = SQL("")
@@ -402,7 +416,7 @@ def getCollectionItems(collectionId):
402
416
  + (", MAX(sp.rank) AS max_rank, MIN(sp.rank) AS min_rank " if paginated else "")
403
417
  + "FROM sequences s "
404
418
  + ("LEFT JOIN sequences_pictures sp ON sp.seq_id = s.id " if paginated else "")
405
- + "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'"
406
420
  + ("GROUP BY s.id" if paginated else ""),
407
421
  params,
408
422
  ).fetchone()
@@ -424,36 +438,43 @@ def getCollectionItems(collectionId):
424
438
  params={"id": withPicture, "seq": collectionId},
425
439
  ).fetchone()
426
440
  if not pic:
427
- raise errors.InvalidAPIUsage(_("Picture with id %(p)s does not exists", p=withPicture))
441
+ raise errors.InvalidAPIUsage(_("Picture with id %(p)s does not exist", p=withPicture))
428
442
  rank = get_first_rank_of_page(pic["rank"], limit)
429
443
 
430
444
  filters.append(SQL("rank >= %(start_after_rank)s"))
431
445
  params["start_after_rank"] = rank
432
446
 
433
447
  query = SQL(
434
- """
435
- SELECT
436
- p.id, p.ts, p.heading, p.metadata, p.inserted_at, p.status,
448
+ """SELECT
449
+ p.id, p.ts, p.heading, p.metadata, p.inserted_at, p.updated_at, p.status, p.visibility,
437
450
  ST_AsGeoJSON(p.geom)::json AS geojson,
438
451
  a.name AS account_name,
439
452
  p.account_id AS account_id,
440
- sp.rank, p.exif, p.gps_accuracy_m, p.h_pixel_density,
441
- CASE WHEN LAG(p.status) OVER othpics = 'ready' THEN LAG(p.id) OVER othpics END AS prevpic,
442
- CASE WHEN LAG(p.status) OVER othpics = 'ready' THEN ST_AsGeoJSON(LAG(p.geom) OVER othpics)::json END AS prevpicgeojson,
443
- CASE WHEN LEAD(p.status) OVER othpics = 'ready' THEN LEAD(p.id) OVER othpics END AS nextpic,
444
- CASE WHEN LEAD(p.status) OVER othpics = 'ready' THEN ST_AsGeoJSON(LEAD(p.geom) OVER othpics)::json END AS nextpicgeojson,
453
+ sp.seq_id, sp.rank, p.exif, p.gps_accuracy_m, p.h_pixel_density,
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,
445
458
  get_picture_semantics(p.id) as semantics,
446
- get_picture_annotations(p.id) as annotations
459
+ get_picture_annotations(p.id) as annotations,
460
+ COALESCE(seq_sem.semantics, '[]'::json) AS sequence_semantics
447
461
  FROM sequences_pictures sp
448
462
  JOIN pictures p ON sp.pic_id = p.id
449
463
  JOIN accounts a ON a.id = p.account_id
450
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
451
473
  WHERE
452
474
  {filter}
453
475
  WINDOW othpics AS (PARTITION BY sp.seq_id ORDER BY sp.rank)
454
476
  ORDER BY rank
455
- {limit}
456
- """
477
+ {limit}"""
457
478
  ).format(filter=SQL(" AND ").join(filters), limit=sql_limit)
458
479
 
459
480
  records = cursor.execute(query, params)
@@ -464,7 +485,7 @@ def getCollectionItems(collectionId):
464
485
  if first_rank is None:
465
486
  first_rank = dbPic["rank"]
466
487
  last_rank = dbPic["rank"]
467
- items.append(dbPictureToStacItem(collectionId, dbPic))
488
+ items.append(dbPictureToStacItem(dbPic))
468
489
  bounds = Bounds(first=first_rank, last=last_rank) if records else None
469
490
 
470
491
  links = [
@@ -570,44 +591,39 @@ def getCollectionItems(collectionId):
570
591
  )
571
592
 
572
593
 
573
- def _getPictureItemById(collectionId, itemId):
574
- """Get a picture metadata by its ID and collection ID
575
-
576
- ---
577
- tags:
578
- - Pictures
579
- parameters:
580
- - name: collectionId
581
- in: path
582
- description: ID of collection to retrieve
583
- required: true
584
- schema:
585
- type: string
586
- - name: itemId
587
- in: path
588
- description: ID of item to retrieve
589
- required: true
590
- schema:
591
- type: string
592
- """
594
+ def _getPictureItemById(itemId: UUID, account: Optional[Account]):
595
+ """Get a picture metadata by its ID"""
593
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
+
594
609
  with conn.cursor(row_factory=dict_row) as cursor:
595
- # Check if there is a logged user
596
- account = auth.get_current_account()
597
- accountId = account.id if account else None
598
610
 
599
611
  # Get rank + position of wanted picture
600
612
  record = cursor.execute(
601
- """
613
+ SQL(
614
+ """WITH seq AS (
615
+ SELECT seq_id FROM sequences_pictures WHERE pic_id = %(pic)s LIMIT 1
616
+ )
602
617
  SELECT
603
- p.id, sp.rank, ST_AsGeoJSON(p.geom)::json AS geojson, p.heading, p.ts, p.metadata,
604
- p.inserted_at, p.status, accounts.name AS account_name,
605
- p.account_id AS account_id,
618
+ p.id, sp.seq_id, sp.rank, ST_AsGeoJSON(p.geom)::json AS geojson, p.heading, p.ts, p.metadata,
619
+ p.inserted_at, p.updated_at, p.status,
620
+ accounts.name AS account_name, p.account_id AS account_id,
621
+ p.visibility,
606
622
  spl.prevpic, spl.prevpicgeojson, spl.nextpic, spl.nextpicgeojson, p.exif,
607
623
  relp.related_pics, p.gps_accuracy_m, p.h_pixel_density,
608
-
609
624
  get_picture_semantics(p.id) as semantics,
610
- get_picture_annotations(p.id) as annotations
625
+ get_picture_annotations(p.id) as annotations,
626
+ COALESCE(seq_sem.semantics, '[]'::json) AS sequence_semantics
611
627
  FROM pictures p
612
628
  JOIN sequences_pictures sp ON sp.pic_id = p.id
613
629
  JOIN accounts ON p.account_id = accounts.id
@@ -622,8 +638,8 @@ def _getPictureItemById(collectionId, itemId):
622
638
  FROM pictures p
623
639
  JOIN sequences_pictures sp ON p.id = sp.pic_id
624
640
  WHERE
625
- sp.seq_id = %(seq)s
626
- AND (p.account_id = %(acc)s OR p.status != 'hidden')
641
+ sp.seq_id IN (SELECT seq_id FROM seq)
642
+ AND (is_picture_visible_by_user(p, %(acc)s) AND p.preparing_status = 'prepared')
627
643
  WINDOW othpics AS (PARTITION BY sp.seq_id ORDER BY sp.rank)
628
644
  ) spl ON p.id = spl.id
629
645
  LEFT JOIN (
@@ -650,7 +666,7 @@ def _getPictureItemById(collectionId, itemId):
650
666
  AND relp.status != 'waiting-for-delete'
651
667
  AND relp.id != p.id
652
668
  AND relsp.pic_id = relp.id
653
- AND relsp.seq_id != %(seq)s
669
+ AND relsp.seq_id NOT IN (SELECT seq_id FROM seq)
654
670
  AND (
655
671
  p.metadata->>'type' = 'equirectangular'
656
672
  OR (relp.heading IS NULL OR p.heading IS NULL)
@@ -663,19 +679,28 @@ def _getPictureItemById(collectionId, itemId):
663
679
  ORDER BY relsp.seq_id, p.geom <-> relp.geom
664
680
  ) a
665
681
  ) relp ON TRUE
666
- WHERE sp.seq_id = %(seq)s
682
+ LEFT JOIN (
683
+ SELECT sequence_id, json_agg(json_strip_nulls(json_build_object(
684
+ 'key', key,
685
+ 'value', value
686
+ )) ORDER BY key, value) AS semantics
687
+ FROM sequences_semantics
688
+ GROUP BY sequence_id
689
+ ) seq_sem ON seq_sem.sequence_id = s.id
690
+ WHERE sp.seq_id IN (SELECT seq_id FROM seq)
667
691
  AND p.id = %(pic)s
668
- AND (p.account_id = %(acc)s OR p.status != 'hidden')
669
- 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}
670
694
  AND s.status != 'deleted'
671
- """,
672
- {"seq": collectionId, "pic": itemId, "acc": accountId},
695
+ """
696
+ ).format(perm_filter=perm_filter),
697
+ {"pic": itemId, "acc": account.id if account is not None else None},
673
698
  ).fetchone()
674
699
 
675
700
  if record is None:
676
701
  return None
677
702
 
678
- return dbPictureToStacItem(collectionId, record)
703
+ return dbPictureToStacItem(record)
679
704
 
680
705
 
681
706
  @bp.route("/collections/<uuid:collectionId>/items/<uuid:itemId>")
@@ -711,12 +736,12 @@ def getCollectionItem(collectionId, itemId):
711
736
  schema:
712
737
  $ref: '#/components/schemas/GeoVisioItem'
713
738
  """
739
+ account = auth.get_current_account()
714
740
 
715
- stacItem = _getPictureItemById(collectionId, itemId)
741
+ stacItem = _getPictureItemById(itemId, account)
716
742
  if stacItem is None:
717
743
  raise errors.InvalidAPIUsage(_("Item doesn't exist"), status_code=404)
718
744
 
719
- account = auth.get_current_account()
720
745
  picStatusToHttpCode = {
721
746
  "waiting-for-process": 102,
722
747
  "ready": 200,
@@ -726,18 +751,6 @@ def getCollectionItem(collectionId, itemId):
726
751
  return stacItem, picStatusToHttpCode[stacItem["properties"]["geovisio:status"]], {"Content-Type": "application/geo+json"}
727
752
 
728
753
 
729
- class SearchParams(BaseModel):
730
- bbox: Optional[str] = None
731
- limit: int = 10
732
- datetime: Optional[str] = None
733
- place_position: Optional[str] = None
734
- place_distance: Optional[str] = None
735
- place_fov_tolerance: Optional[int] = None
736
- intersects: Optional[str] = None
737
- ids: Optional[str] = None
738
- collections: Optional[str] = None
739
-
740
-
741
754
  @bp.route("/search", methods=["GET", "POST"])
742
755
  def searchItems():
743
756
  """Search through all available items
@@ -759,6 +772,7 @@ def searchItems():
759
772
  - $ref: '#/components/parameters/GeoVisio_place_distance'
760
773
  - $ref: '#/components/parameters/GeoVisio_place_fov_tolerance'
761
774
  - $ref: '#/components/parameters/searchCQL2_filter'
775
+ - $ref: '#/components/parameters/GeoVisioSearchSortedBy'
762
776
  post:
763
777
  requestBody:
764
778
  required: true
@@ -768,15 +782,21 @@ def searchItems():
768
782
  $ref: '#/components/schemas/GeoVisioItemSearchBody'
769
783
  responses:
770
784
  200:
771
- $ref: '#/components/responses/STAC_search'
785
+ description: The search results
786
+ content:
787
+ application/geo+json:
788
+ schema:
789
+ $ref: '#/components/schemas/GeoVisioCollectionItems'
772
790
  """
773
791
 
774
792
  account = auth.get_current_account()
775
793
  accountId = account.id if account is not None else None
776
- 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
+ ]
777
798
  sqlParams: Dict[str, Any] = {"account": accountId}
778
- sqlSubQueryWhere = [SQL("(p.status = 'ready' OR p.account_id = %(account)s)")]
779
- order_by = SQL("")
799
+ sqlSubQueryWhere = [SQL("(p.status = 'ready' AND is_picture_visible_by_user(p, %(account)s))")]
780
800
 
781
801
  #
782
802
  # Parameters parsing and verification
@@ -801,6 +821,8 @@ def searchItems():
801
821
  raise errors.InvalidAPIUsage(_("Parameter limit must be either empty or a number between 1 and 10000"), status_code=400)
802
822
  sqlParams["limit"] = limit
803
823
 
824
+ sort_by = parse_item_sortby(args.get("sortby"))
825
+
804
826
  # Bounding box
805
827
  bboxarg = parse_bbox(args.getlist("bbox"))
806
828
  if bboxarg is not None:
@@ -810,7 +832,16 @@ def searchItems():
810
832
  sqlParams["maxx"] = bboxarg[2]
811
833
  sqlParams["maxy"] = bboxarg[3]
812
834
  # if we search by bbox, we'll give first the items near the center of the bounding box
813
- order_by = SQL("ORDER BY p.geom <-> ST_Centroid(ST_MakeEnvelope(%(minx)s, %(miny)s, %(maxx)s, %(maxy)s, 4326))")
835
+ if not sort_by:
836
+ sort_by = SortBy(
837
+ fields=[
838
+ ItemSortByField(
839
+ field=SortableItemField.distance_to,
840
+ direction=SQLDirection.ASC,
841
+ obj_to_compare=SQL("ST_Centroid(ST_MakeEnvelope(%(minx)s, %(miny)s, %(maxx)s, %(maxy)s, 4326))"),
842
+ ),
843
+ ]
844
+ )
814
845
 
815
846
  # Datetime
816
847
  min_dt, max_dt = parse_datetime_interval(args.get("datetime"))
@@ -866,7 +897,16 @@ def searchItems():
866
897
  )
867
898
 
868
899
  # Sort pictures by nearest to POI
869
- order_by = SQL("ORDER BY p.geom <-> ST_Point(%(placex)s, %(placey)s, 4326)")
900
+ if not sort_by:
901
+ sort_by = SortBy(
902
+ fields=[
903
+ ItemSortByField(
904
+ field=SortableItemField.distance_to,
905
+ direction=SQLDirection.ASC,
906
+ obj_to_compare=SQL("ST_Point(%(placex)s, %(placey)s, 4326)"),
907
+ ),
908
+ ]
909
+ )
870
910
 
871
911
  # Intersects
872
912
  if args.get("intersects") is not None:
@@ -881,7 +921,16 @@ def searchItems():
881
921
  sqlWhere.append(SQL("ST_Intersects(p.geom, ST_GeomFromGeoJSON(%(geom)s))"))
882
922
  sqlParams["geom"] = Jsonb(intersects)
883
923
  # if we search by bbox, we'll give first the items near the center of the bounding box
884
- order_by = SQL("ORDER BY p.geom <-> ST_Centroid(ST_GeomFromGeoJSON(%(geom)s))")
924
+ if not sort_by:
925
+ sort_by = SortBy(
926
+ fields=[
927
+ ItemSortByField(
928
+ field=SortableItemField.distance_to,
929
+ direction=SQLDirection.ASC,
930
+ obj_to_compare=SQL("ST_Centroid(ST_GeomFromGeoJSON(%(geom)s))"),
931
+ ),
932
+ ]
933
+ )
885
934
 
886
935
  # Ids
887
936
  if args.get("ids") is not None:
@@ -910,11 +959,7 @@ def searchItems():
910
959
  picture_id = ids[0]
911
960
 
912
961
  with current_app.pool.connection() as conn, conn.cursor() as cursor:
913
- seq = cursor.execute("SELECT seq_id FROM sequences_pictures WHERE pic_id = %s", [picture_id]).fetchone()
914
- if not seq:
915
- raise errors.InvalidAPIUsage(_("Picture doesn't exist"), status_code=404)
916
-
917
- item = _getPictureItemById(seq[0], UUID(picture_id))
962
+ item = _getPictureItemById(UUID(picture_id), account)
918
963
  features = [item] if item else []
919
964
  return (
920
965
  {"type": "FeatureCollection", "features": features, "links": [get_root_link()]},
@@ -926,6 +971,18 @@ def searchItems():
926
971
  cql_filter = parse_search_filter(filter_param)
927
972
  if cql_filter is not None:
928
973
  sqlWhere.append(cql_filter)
974
+
975
+ if not sort_by:
976
+ # by default we sort by last updated (and id in case of equalities)
977
+ sort_by = SortBy(
978
+ fields=[
979
+ ItemSortByField(field=SortableItemField.updated, direction=SQLDirection.DESC),
980
+ ItemSortByField(field=SortableItemField.id, direction=SQLDirection.ASC),
981
+ ]
982
+ )
983
+
984
+ order_by = sort_by.to_sql()
985
+
929
986
  #
930
987
  # Database query
931
988
  #
@@ -934,18 +991,27 @@ def searchItems():
934
991
  """
935
992
  SELECT * FROM (
936
993
  SELECT
937
- p.id, p.ts, p.heading, p.metadata, p.inserted_at,
994
+ p.id, p.ts, p.heading, p.metadata, p.inserted_at, p.updated_at,
938
995
  ST_AsGeoJSON(p.geom)::json AS geojson,
939
996
  sp.seq_id, sp.rank AS rank,
940
997
  accounts.name AS account_name,
941
998
  p.account_id AS account_id,
942
999
  p.exif, p.gps_accuracy_m, p.h_pixel_density,
943
1000
  get_picture_semantics(p.id) as semantics,
944
- get_picture_annotations(p.id) as annotations
1001
+ get_picture_annotations(p.id) as annotations,
1002
+ COALESCE(seq_sem.semantics, '[]'::json) AS sequence_semantics
945
1003
  FROM pictures p
946
1004
  LEFT JOIN sequences_pictures sp ON p.id = sp.pic_id
947
1005
  LEFT JOIN sequences s ON s.id = sp.seq_id
948
1006
  LEFT JOIN accounts ON p.account_id = accounts.id
1007
+ LEFT JOIN (
1008
+ SELECT sequence_id, json_agg(json_strip_nulls(json_build_object(
1009
+ 'key', key,
1010
+ 'value', value
1011
+ )) ORDER BY key, value) AS semantics
1012
+ FROM sequences_semantics
1013
+ GROUP BY sequence_id
1014
+ ) seq_sem ON seq_sem.sequence_id = s.id
949
1015
  WHERE {sqlWhere}
950
1016
  {orderBy}
951
1017
  LIMIT %(limit)s
@@ -968,13 +1034,14 @@ LEFT JOIN LATERAL (
968
1034
  ORDER BY sp.rank ASC
969
1035
  LIMIT 1
970
1036
  ) next on true
1037
+
971
1038
  ;
972
1039
  """
973
1040
  ).format(sqlWhere=SQL(" AND ").join(sqlWhere), sqlSubQueryWhere=SQL(" AND ").join(sqlSubQueryWhere), orderBy=order_by)
974
1041
 
975
1042
  records = cursor.execute(query, sqlParams)
976
1043
 
977
- items = [dbPictureToStacItem(str(dbPic["seq_id"]), dbPic) for dbPic in records]
1044
+ items = [dbPictureToStacItem(dbPic) for dbPic in records]
978
1045
 
979
1046
  return (
980
1047
  {
@@ -992,7 +1059,9 @@ LEFT JOIN LATERAL (
992
1059
  @bp.route("/collections/<uuid:collectionId>/items", methods=["POST"])
993
1060
  @auth.login_required_by_setting("API_FORCE_AUTH_ON_UPLOAD")
994
1061
  def postCollectionItem(collectionId, account=None):
995
- """Add a new picture in a given sequence
1062
+ """Add a new picture in a given sequence.
1063
+
1064
+ Note that this is the legacy API, upload should be done using the [UploadSet](#UploadSet) endpoints if possible.
996
1065
  ---
997
1066
  tags:
998
1067
  - Upload
@@ -1018,6 +1087,42 @@ def postCollectionItem(collectionId, account=None):
1018
1087
  application/geo+json:
1019
1088
  schema:
1020
1089
  $ref: '#/components/schemas/GeoVisioItem'
1090
+ 400:
1091
+ description: Error if the request is malformed
1092
+ content:
1093
+ application/json:
1094
+ schema:
1095
+ $ref: '#/components/schemas/GeoVisioError'
1096
+ 401:
1097
+ description: Error if you're not logged in
1098
+ content:
1099
+ application/json:
1100
+ schema:
1101
+ $ref: '#/components/schemas/GeoVisioError'
1102
+ 403:
1103
+ description: Error if you're not authorized to add picture to this collection
1104
+ content:
1105
+ application/json:
1106
+ schema:
1107
+ $ref: '#/components/schemas/GeoVisioError'
1108
+ 404:
1109
+ description: Error if the collection doesn't exist
1110
+ content:
1111
+ application/json:
1112
+ schema:
1113
+ $ref: '#/components/schemas/GeoVisioError'
1114
+ 409:
1115
+ description: Error if a picture (named `item` in the API) has already been added in the same index (named `position` in the API) in this collection
1116
+ content:
1117
+ application/json:
1118
+ schema:
1119
+ $ref: '#/components/schemas/GeoVisioError'
1120
+ 415:
1121
+ description: Error if the content type is not multipart/form-data
1122
+ content:
1123
+ application/json:
1124
+ schema:
1125
+ $ref: '#/components/schemas/GeoVisioError'
1021
1126
  """
1022
1127
 
1023
1128
  if not request.headers.get("Content-Type", "").startswith("multipart/form-data"):
@@ -1098,7 +1203,7 @@ def postCollectionItem(collectionId, account=None):
1098
1203
  raise errors.InvalidAPIUsage(_("The collection has been deleted, impossible to add pictures to it"), status_code=404)
1099
1204
 
1100
1205
  # Compute various metadata
1101
- accountId = accountIdOrDefault(account)
1206
+ accountId = accountOrDefault(account).id
1102
1207
  raw_pic = picture.read()
1103
1208
  filesize = len(raw_pic)
1104
1209
 
@@ -1125,7 +1230,7 @@ def postCollectionItem(collectionId, account=None):
1125
1230
  conn, collectionId, position, updated_picture, accountId, additionalMetadata, lang=get_locale().language
1126
1231
  )
1127
1232
  except utils.pictures.PicturePositionConflict:
1128
- raise errors.InvalidAPIUsage(_("Picture at given position already exist"), status_code=409)
1233
+ raise errors.InvalidAPIUsage(_("There is already a picture with the same index in the sequence"), status_code=409)
1129
1234
  except utils.pictures.MetadataReadingError as e:
1130
1235
  raise errors.InvalidAPIUsage(_("Impossible to parse picture metadata"), payload={"details": {"error": e.details}})
1131
1236
  except utils.pictures.InvalidMetadataValue as e:
@@ -1143,7 +1248,7 @@ def postCollectionItem(collectionId, account=None):
1143
1248
 
1144
1249
  # Return picture metadata
1145
1250
  return (
1146
- getCollectionItem(collectionId, picId)[0],
1251
+ _getPictureItemById(picId, account=account),
1147
1252
  202,
1148
1253
  {
1149
1254
  "Content-Type": "application/json",
@@ -1159,7 +1264,21 @@ class PatchItemParameter(BaseModel):
1159
1264
  heading: Optional[int] = None
1160
1265
  """Heading of the picture. The new heading will not be persisted in the picture's exif tags for the moment."""
1161
1266
  visible: Optional[bool] = None
1162
- """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
+ """
1163
1282
 
1164
1283
  capture_time: Optional[datetime] = None
1165
1284
  """Capture time of the picture. The new capture time will not be persisted in the picture's exif tags for the moment."""
@@ -1227,66 +1346,29 @@ class PatchItemParameter(BaseModel):
1227
1346
  raise errors.InvalidAPIUsage(_("Longitude cannot be overridden alone, latitude also needs to be set"))
1228
1347
  if self.longitude is None and self.latitude is not None:
1229
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
1230
1354
  return self
1231
1355
 
1232
1356
  def has_only_semantics_updates(self):
1233
1357
  return self.model_fields_set == {"semantics"}
1234
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
1235
1368
 
1236
- @bp.route("/collections/<uuid:collectionId>/items/<uuid:itemId>", methods=["PATCH"])
1237
- @auth.login_required()
1238
- def patchCollectionItem(collectionId, itemId, account):
1239
- """Edits properties of an existing picture
1240
-
1241
- Note that tags cannot be added as form-data for the moment, only as JSON.
1242
-
1243
- Note that there are rules on the editing of a picture's metadata:
1244
-
1245
- - Only the owner of a picture can change its visibility
1246
- - For core metadata (heading, capture_time, position, longitude, latitude), the owner can restrict their change by other accounts (see `collaborative_metadata` field in `/api/users/me`) and if not explicitly defined by the user, the instance's default value is used.
1247
- - Everyone can add/edit/delete semantics tags.
1248
- ---
1249
- tags:
1250
- - Editing
1251
- - Semantics
1252
- parameters:
1253
- - name: collectionId
1254
- in: path
1255
- description: ID of sequence the picture belongs to
1256
- required: true
1257
- schema:
1258
- type: string
1259
- - name: itemId
1260
- in: path
1261
- description: ID of picture to edit
1262
- required: true
1263
- schema:
1264
- type: string
1265
- requestBody:
1266
- content:
1267
- application/json:
1268
- schema:
1269
- $ref: '#/components/schemas/GeoVisioPatchItem'
1270
- application/x-www-form-urlencoded:
1271
- schema:
1272
- $ref: '#/components/schemas/GeoVisioPatchItem'
1273
- multipart/form-data:
1274
- schema:
1275
- $ref: '#/components/schemas/GeoVisioPatchItem'
1276
- security:
1277
- - bearerToken: []
1278
- - cookieAuth: []
1279
- responses:
1280
- 200:
1281
- description: the wanted item
1282
- content:
1283
- application/geo+json:
1284
- schema:
1285
- $ref: '#/components/schemas/GeoVisioItem'
1286
- """
1287
1369
 
1370
+ def update_picture(itemId: UUID, account: Account):
1288
1371
  # Parse received parameters
1289
-
1290
1372
  metadata = None
1291
1373
  content_type = (request.headers.get("Content-Type") or "").split(";")[0]
1292
1374
 
@@ -1300,21 +1382,28 @@ def patchCollectionItem(collectionId, itemId, account):
1300
1382
 
1301
1383
  # If no parameter is set
1302
1384
  if metadata is None or not metadata.has_override():
1303
- return getCollectionItem(collectionId, itemId)
1385
+ return (_getPictureItemById(itemId, account), 304)
1304
1386
 
1305
1387
  # Check if picture exists and if given account is authorized to edit
1306
1388
  with db.conn(current_app) as conn:
1307
1389
  with conn.transaction(), conn.cursor(row_factory=dict_row) as cursor:
1308
- 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()
1309
1398
 
1310
1399
  # Picture not found
1311
1400
  if not pic:
1312
1401
  raise errors.InvalidAPIUsage(_("Picture %(p)s wasn't found in database", p=itemId), status_code=404)
1313
1402
 
1314
- if account is not None and account.id != str(pic["account_id"]):
1403
+ if not account.can_edit_item(str(pic["account_id"])):
1315
1404
  # Account associated to picture doesn't match current user
1316
1405
  # and we limit the status change to only the owner.
1317
- if metadata.visible is not None:
1406
+ if metadata.visibility is not None:
1318
1407
  raise errors.InvalidAPIUsage(
1319
1408
  _("You're not authorized to edit the visibility of this picture. Only the owner can change this."), status_code=403
1320
1409
  )
@@ -1330,24 +1419,14 @@ def patchCollectionItem(collectionId, itemId, account):
1330
1419
  sqlParams = {"id": itemId, "account": account.id}
1331
1420
 
1332
1421
  # Let's edit this picture
1333
- oldStatus = pic["status"]
1334
- if oldStatus not in ["ready", "hidden"]:
1335
- # Picture is in a preparing/broken/... state so no edit possible
1336
- raise errors.InvalidAPIUsage(
1337
- _(
1338
- "Picture %(p)s is in %(s)s state, its visibility can't be changed for now",
1339
- p=itemId,
1340
- s=oldStatus,
1341
- ),
1342
- status_code=400,
1343
- )
1422
+ oldVisibility = pic["visibility"]
1344
1423
 
1345
- newStatus = None
1346
- if metadata.visible is not None:
1347
- newStatus = "ready" if metadata.visible is True else "hidden"
1348
- if newStatus != oldStatus:
1349
- sqlUpdates.append(SQL("status = %(status)s"))
1350
- 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
1351
1430
 
1352
1431
  if metadata.heading is not None:
1353
1432
  sqlUpdates.extend([SQL("heading = %(heading)s"), SQL("heading_computed = false")])
@@ -1381,12 +1460,66 @@ WHERE id = %(id)s"""
1381
1460
  )
1382
1461
 
1383
1462
  # Redirect response to a classic GET
1384
- return getCollectionItem(collectionId, itemId)
1463
+ return (_getPictureItemById(itemId, account), 200)
1464
+
1465
+
1466
+ @bp.route("/collections/<uuid:collectionId>/items/<uuid:itemId>", methods=["PATCH"])
1467
+ @auth.login_required()
1468
+ def patchCollectionItem(collectionId, itemId, account):
1469
+ """Edits properties of an existing picture
1470
+
1471
+ Note that tags cannot be added as form-data for the moment, only as JSON.
1472
+
1473
+ Note that there are rules on the editing of a picture's metadata:
1474
+
1475
+ - Only the owner of a picture can change its visibility
1476
+ - For core metadata (heading, capture_time, position, longitude, latitude), the owner can restrict their change by other accounts (see `collaborative_metadata` field in `/api/users/me`) and if not explicitly defined by the user, the instance's default value is used.
1477
+ - Everyone can add/edit/delete semantics tags.
1478
+ ---
1479
+ tags:
1480
+ - Editing
1481
+ - Semantics
1482
+ parameters:
1483
+ - name: collectionId
1484
+ in: path
1485
+ description: ID of sequence the picture belongs to
1486
+ required: true
1487
+ schema:
1488
+ type: string
1489
+ - name: itemId
1490
+ in: path
1491
+ description: ID of picture to edit
1492
+ required: true
1493
+ schema:
1494
+ type: string
1495
+ requestBody:
1496
+ content:
1497
+ application/json:
1498
+ schema:
1499
+ $ref: '#/components/schemas/GeoVisioPatchItem'
1500
+ application/x-www-form-urlencoded:
1501
+ schema:
1502
+ $ref: '#/components/schemas/GeoVisioPatchItem'
1503
+ multipart/form-data:
1504
+ schema:
1505
+ $ref: '#/components/schemas/GeoVisioPatchItem'
1506
+ security:
1507
+ - bearerToken: []
1508
+ - cookieAuth: []
1509
+ responses:
1510
+ 200:
1511
+ description: the wanted item
1512
+ content:
1513
+ application/geo+json:
1514
+ schema:
1515
+ $ref: '#/components/schemas/GeoVisioItem'
1516
+ """
1517
+ return update_picture(itemId, account=account)
1385
1518
 
1386
1519
 
1387
1520
  @bp.route("/collections/<uuid:collectionId>/items/<uuid:itemId>", methods=["DELETE"])
1388
1521
  @auth.login_required()
1389
- def deleteCollectionItem(collectionId, itemId, account):
1522
+ def deleteCollectionItem(collectionId: UUID, itemId: UUID, account: Account):
1390
1523
  """Delete an existing picture
1391
1524
  ---
1392
1525
  tags:
@@ -1422,7 +1555,7 @@ def deleteCollectionItem(collectionId, itemId, account):
1422
1555
  raise errors.InvalidAPIUsage(_("Picture %(p)s wasn't found in database", p=itemId), status_code=404)
1423
1556
 
1424
1557
  # Account associated to picture doesn't match current user
1425
- if account is not None and account.id != str(pic[1]):
1558
+ if not account.can_edit_item(str(pic[1])):
1426
1559
  raise errors.InvalidAPIUsage(_("You're not authorized to edit this picture"), status_code=403)
1427
1560
 
1428
1561
  cursor.execute("DELETE FROM pictures WHERE id = %s", [itemId])
@@ -1433,29 +1566,29 @@ def deleteCollectionItem(collectionId, itemId, account):
1433
1566
  return "", 204
1434
1567
 
1435
1568
 
1436
- def _getHDJpgPictureURL(picId: str, status: Optional[str]):
1569
+ def _getHDJpgPictureURL(picId: str, visibility: Optional[str]):
1437
1570
  external_url = utils.pictures.getPublicHDPictureExternalUrl(picId, format="jpg")
1438
- 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:
1439
1572
  return external_url
1440
1573
  return url_for("pictures.getPictureHD", _external=True, pictureId=picId, format="jpg")
1441
1574
 
1442
1575
 
1443
- def _getSDJpgPictureURL(picId: str, status: Optional[str]):
1576
+ def _getSDJpgPictureURL(picId: str, visibility: Optional[str]):
1444
1577
  external_url = utils.pictures.getPublicDerivatePictureExternalUrl(picId, format="jpg", derivateFileName="sd.jpg")
1445
- 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:
1446
1579
  return external_url
1447
1580
  return url_for("pictures.getPictureSD", _external=True, pictureId=picId, format="jpg")
1448
1581
 
1449
1582
 
1450
- def _getThumbJpgPictureURL(picId: str, status: Optional[str]):
1583
+ def _getThumbJpgPictureURL(picId: str, visibility: Optional[str]):
1451
1584
  external_url = utils.pictures.getPublicDerivatePictureExternalUrl(picId, format="jpg", derivateFileName="thumb.jpg")
1452
- 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
1453
1586
  return external_url
1454
1587
  return url_for("pictures.getPictureThumb", _external=True, pictureId=picId, format="jpg")
1455
1588
 
1456
1589
 
1457
- def _getTilesJpgPictureURL(picId: str, status: Optional[str]):
1590
+ def _getTilesJpgPictureURL(picId: str, visibility: Optional[str]):
1458
1591
  external_url = utils.pictures.getPublicDerivatePictureExternalUrl(picId, format="jpg", derivateFileName="tiles/{TileCol}_{TileRow}.jpg")
1459
- 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:
1460
1593
  return external_url
1461
1594
  return unquote(url_for("pictures.getPictureTile", _external=True, pictureId=picId, format="jpg", col="{TileCol}", row="{TileRow}"))