geovisio 2.4.0__py3-none-any.whl → 2.6.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.
geovisio/web/docs.py CHANGED
@@ -120,6 +120,50 @@ API_CONFIG = {
120
120
  "STACItemSearchBody": {
121
121
  "$ref": f"https://api.stacspec.org/v{utils.STAC_VERSION}/item-search/openapi.yaml#/components/schemas/searchBody"
122
122
  },
123
+ "MapLibreStyleJSON": {
124
+ "type": "object",
125
+ "description": """
126
+ MapLibre Style JSON, see https://maplibre.org/maplibre-style-spec/ for reference.
127
+
128
+ Source ID is either \"geovisio\" or \"geovisio_\{userId\}\".
129
+
130
+ Layers ID are \"geovisio_grid\", \"geovisio_sequences\" and \"geovisio_pictures\", or with user UUID included (\"geovisio_\{userId\}_sequences\" and \"geovisio_\{userId\}_pictures\").
131
+
132
+ Note that you may not rely only on these ID that could change through time.
133
+ """,
134
+ "properties": {
135
+ "version": {"type": "integer", "example": 8},
136
+ "name": {"type": "string", "example": "GeoVisio Vector Tiles"},
137
+ "sources": {
138
+ "type": "object",
139
+ "properties": {
140
+ "geovisio": {
141
+ "type": "object",
142
+ "properties": {
143
+ "type": {"type": "string", "example": "vector"},
144
+ "minzoom": {"type": "integer", "example": "0"},
145
+ "maxzoom": {"type": "integer", "example": "15"},
146
+ "tiles": {"type": "array", "items": {"type": "string"}},
147
+ },
148
+ }
149
+ },
150
+ },
151
+ "layers": {
152
+ "type": "array",
153
+ "items": {
154
+ "type": "object",
155
+ "properties": {
156
+ "id": {"type": "string"},
157
+ "source": {"type": "string"},
158
+ "source-layer": {"type": "string"},
159
+ "type": {"type": "string"},
160
+ "paint": {"type": "object"},
161
+ "layout": {"type": "object"},
162
+ },
163
+ },
164
+ },
165
+ },
166
+ },
123
167
  "GeoVisioLanding": {
124
168
  "allOf": [
125
169
  {"$ref": "#/components/schemas/STACLanding"},
@@ -223,7 +267,14 @@ API_CONFIG = {
223
267
  "GeoVisioCollection": {
224
268
  "allOf": [
225
269
  {"$ref": "#/components/schemas/STACCollection"},
226
- {"type": "object", "properties": {"stats:items": {"$ref": "#/components/schemas/STACStatsForItems"}}},
270
+ {
271
+ "type": "object",
272
+ "properties": {
273
+ "stats:items": {"$ref": "#/components/schemas/STACStatsForItems"},
274
+ "geovisio:status": {"$ref": "#/components/schemas/GeoVisioCollectionStatus"},
275
+ "geovisio:sorted-by": {"$ref": "#/components/schemas/GeoVisioCollectionSortedBy"},
276
+ },
277
+ },
227
278
  ]
228
279
  },
229
280
  "GeoVisioCollectionImportStatus": {
@@ -264,6 +315,26 @@ API_CONFIG = {
264
315
  "type": "string",
265
316
  "description": "The sequence title (publicly displayed)",
266
317
  },
318
+ "relative_heading": {
319
+ "type": "number",
320
+ "minimum": -180,
321
+ "maximum": 180,
322
+ "description": "The relative heading (in degrees), offset based on movement path (0° = looking forward, -90° = looking left, 90° = looking right). Headings are unchanged if this parameter is not set.",
323
+ },
324
+ "sortby": {
325
+ "description": """
326
+ Define the pictures sort order based on given property. Sort order is defined based on preceding '+' (asc) or '-' (desc).
327
+
328
+ Available properties are:
329
+ * `gpsdate`: sort by GPS datetime
330
+ * `filedate`: sort by the camera-generated capture date. This is based on EXIF tags `Exif.Image.DateTimeOriginal`, `Exif.Photo.DateTimeOriginal`, `Exif.Image.DateTime` or `Xmp.GPano.SourceImageCreateTime` (in this order).
331
+ * `filename`: sort by the original picture file name
332
+
333
+ If unset, sort order is unchanged.
334
+ """,
335
+ "type": "string",
336
+ "enum": ["+gpsdate", "-gpsdate", "+filedate", "-filedate", "+filename", "-filename"],
337
+ },
267
338
  },
268
339
  },
269
340
  "GeoVisioCollectionItems": {
@@ -285,6 +356,11 @@ API_CONFIG = {
285
356
  "properties": {
286
357
  "type": "object",
287
358
  "properties": {
359
+ "datetimetz": {
360
+ "type": "string",
361
+ "format": "date-time",
362
+ "title": "Date & time with original timezone information",
363
+ },
288
364
  "geovisio:status": {"$ref": "#/components/schemas/GeoVisioItemStatus"},
289
365
  "geovisio:producer": {"type": "string"},
290
366
  "geovisio:image": {"type": "string", "format": "uri"},
@@ -357,6 +433,49 @@ API_CONFIG = {
357
433
  },
358
434
  },
359
435
  },
436
+ "GeoVisioItemSearchBody": {
437
+ "description": "The search criteria",
438
+ "type": "object",
439
+ "allOf": [
440
+ {"$ref": "#/components/schemas/STACItemSearchBody"},
441
+ {
442
+ "type": "object",
443
+ "properties": {
444
+ "place_position": {
445
+ "description": "Geographical coordinates (lon,lat) of a place you'd like to have pictures of. Returned pictures are either 360° or looking in direction of wanted place.",
446
+ "type": "string",
447
+ "pattern": "-?\d+\.\d+,-?\d+\.\d+",
448
+ },
449
+ "place_distance": {
450
+ "description": "Distance range (in meters) to search pictures for a particular place (place_position). Default range is 3-15. Only used if place_position parameter is defined.",
451
+ "type": "string",
452
+ "pattern": "\d+-\d+",
453
+ },
454
+ "place_fov_tolerance": {
455
+ "type": "integer",
456
+ "minimum": 2,
457
+ "maximum": 180,
458
+ "description": """
459
+ Tolerance on how much the place should be centered in nearby pictures:
460
+
461
+ * A lower value means place have to be at the very center of picture
462
+ * A higher value means place could be more in picture sides
463
+
464
+ Value is expressed in degrees (from 2 to 180, defaults to 30°), and represents the acceptable field of view relative to picture heading. Only used if place_position parameter is defined.
465
+
466
+ Example values are:
467
+
468
+ * <= 30° for place to be in the very center of picture
469
+ * 60° for place to be in recognizable human field of view
470
+ * 180° for place to be anywhere in a wide-angle picture
471
+
472
+ Note that this parameter is not taken in account for 360° pictures, as by definition a nearby place would be theorically always visible in it.
473
+ """,
474
+ },
475
+ },
476
+ },
477
+ ],
478
+ },
360
479
  "GeoVisioPatchItem": {
361
480
  "type": "object",
362
481
  "properties": {
@@ -365,10 +484,29 @@ API_CONFIG = {
365
484
  "description": "Should the picture be publicly visible ?",
366
485
  "enum": ["true", "false", "null"],
367
486
  "default": "null",
368
- }
487
+ },
488
+ "heading": {
489
+ "type": "number",
490
+ "minimum": 0,
491
+ "maximum": 360,
492
+ "description": "The picture heading (in degrees). North is 0°, East = 90°, South = 180° and West = 270°.",
493
+ },
369
494
  },
370
495
  },
371
496
  "GeoVisioCollectionStatus": {"type": "string", "enum": ["ready", "broken", "preparing", "waiting-for-process"]},
497
+ "GeoVisioCollectionSortedBy": {
498
+ "description": """
499
+ Define the pictures sort order of the sequence. Null by default, and can be set via the collection PATCH.
500
+ Sort order is defined based on preceding '+' (asc) or '-' (desc).
501
+
502
+ Available properties are:
503
+ * `gpsdate`: sort by GPS datetime
504
+ * `filedate`: sort by the camera-generated capture date. This is based on EXIF tags `Exif.Image.DateTimeOriginal`, `Exif.Photo.DateTimeOriginal`, `Exif.Image.DateTime` or `Xmp.GPano.SourceImageCreateTime` (in this order).
505
+ * `filename`: sort by the original picture file name
506
+ """,
507
+ "type": "string",
508
+ "enum": ["+gpsdate", "-gpsdate", "+filedate", "-filedate", "+filename", "-filename"],
509
+ },
372
510
  "GeoVisioItemStatus": {
373
511
  "type": "string",
374
512
  "enum": ["ready", "broken", "waiting-for-process"],
@@ -469,7 +607,7 @@ API_CONFIG = {
469
607
  },
470
608
  },
471
609
  },
472
- "JWToken": {
610
+ "GeoVisioEncodedToken": {
473
611
  "type": "object",
474
612
  "properties": {
475
613
  "id": {"type": "string"},
@@ -483,7 +621,7 @@ API_CONFIG = {
483
621
  },
484
622
  "JWTokenClaimable": {
485
623
  "allOf": [
486
- {"$ref": "#/components/schemas/JWToken"},
624
+ {"$ref": "#/components/schemas/GeoVisioEncodedToken"},
487
625
  {
488
626
  "type": "object",
489
627
  "properties": {
@@ -561,6 +699,42 @@ Usage doc can be found here: https://docs.geoserver.org/2.23.x/en/user/tutorials
561
699
  "required": False,
562
700
  "schema": {"type": "string"},
563
701
  },
702
+ "GeoVisio_place_position": {
703
+ "name": "place_position",
704
+ "in": "query",
705
+ "required": False,
706
+ "description": "Geographical coordinates (lon,lat) of a place you'd like to have pictures of. Returned pictures are either 360° or looking in direction of wanted place.",
707
+ "schema": {"type": "string", "pattern": "-?\d+\.\d+,-?\d+\.\d+"},
708
+ },
709
+ "GeoVisio_place_distance": {
710
+ "name": "place_distance",
711
+ "in": "query",
712
+ "required": False,
713
+ "description": "Distance range (in meters) to search pictures for a particular place (place_position). Default range is 3-15. Only used if place_position parameter is defined.",
714
+ "schema": {"type": "string", "pattern": "\d+-\d+", "default": "3-15"},
715
+ },
716
+ "GeoVisio_place_fov_tolerance": {
717
+ "name": "place_fov_tolerance",
718
+ "in": "query",
719
+ "description": """
720
+ Tolerance on how much the place should be centered in nearby pictures:
721
+
722
+ * A lower value means place have to be at the very center of picture
723
+ * A higher value means place could be more in picture sides
724
+
725
+ Value is expressed in degrees (from 2 to 180, defaults to 30°), and represents the acceptable field of view relative to picture heading. Only used if place_position parameter is defined.
726
+
727
+ Example values are:
728
+
729
+ * <= 30° for place to be in the very center of picture
730
+ * 60° for place to be in recognizable human field of view
731
+ * 180° for place to be anywhere in a wide-angle picture
732
+
733
+ Note that this parameter is not taken in account for 360° pictures, as by definition a nearby place would be theorically always visible in it.
734
+ """,
735
+ "required": False,
736
+ "schema": {"type": "integer", "minimum": 2, "maximum": 180, "default": 30},
737
+ },
564
738
  "OGC_sortby": {
565
739
  "name": "sortby",
566
740
  "in": "query",
geovisio/web/items.py CHANGED
@@ -1,8 +1,6 @@
1
- import io
2
1
  import json
3
2
  import logging
4
3
  import os
5
- import re
6
4
  from typing import Dict, Optional, Any
7
5
  from urllib.parse import unquote
8
6
  from psycopg.types.json import Jsonb
@@ -10,6 +8,7 @@ from werkzeug.datastructures import MultiDict
10
8
  from uuid import UUID
11
9
  from geovisio import errors, utils
12
10
  from geovisio.utils import auth
11
+ from geovisio.utils.pictures import cleanupExif
13
12
  from geovisio.web.params import (
14
13
  as_latitude,
15
14
  as_longitude,
@@ -18,17 +17,19 @@ from geovisio.web.params import (
18
17
  parse_datetime_interval,
19
18
  parse_bbox,
20
19
  parse_list,
20
+ parse_lonlat,
21
+ parse_distance_range,
21
22
  )
22
23
  from geovisio.utils.fields import Bounds
23
24
 
24
25
  import psycopg
25
- from datetime import datetime
26
26
  from psycopg.rows import dict_row
27
27
  from psycopg.sql import SQL
28
28
  from geovisio.web.utils import (
29
29
  accountIdOrDefault,
30
30
  cleanNoneInList,
31
31
  dbTsToStac,
32
+ dbTsToStacTZ,
32
33
  get_license_link,
33
34
  get_root_link,
34
35
  removeNoneInDict,
@@ -41,9 +42,6 @@ from geovisio.workers import runner_pictures
41
42
 
42
43
  bp = Blueprint("stac_items", __name__, url_prefix="/api")
43
44
 
44
- RGX_BINARY_KEY = re.compile(".+\..+\.0x[0-f]+")
45
- RGX_BINARY_VAL = re.compile("(\d{1,3} ){20,}\d{1,3}") # At least 21 blocks of 1-3 digits values
46
-
47
45
 
48
46
  def dbPictureToStacItem(seqId, dbPic):
49
47
  """Transforms a picture extracted from database into a STAC Item
@@ -52,7 +50,7 @@ def dbPictureToStacItem(seqId, dbPic):
52
50
  ----------
53
51
  seqId : uuid
54
52
  Associated sequence ID
55
- dbSeq : dict
53
+ dbPic : dict
56
54
  A row from pictures table in database (with id, geojson, ts, heading, cols, rows, width, height, prevpic, nextpic, prevpicgeojson, nextpicgeojson, exif fields)
57
55
 
58
56
  Returns
@@ -82,33 +80,38 @@ def dbPictureToStacItem(seqId, dbPic):
82
80
  ),
83
81
  ]
84
82
  ),
85
- "properties": {
86
- "datetime": dbTsToStac(dbPic["ts"]),
87
- "created": dbTsToStac(dbPic["inserted_at"]),
88
- # TODO : add "updated" TS for last edit time of metadata
89
- "license": current_app.config["API_PICTURES_LICENSE_SPDX_ID"],
90
- "view:azimuth": dbPic["heading"],
91
- "pers:interior_orientation": (
92
- removeNoneInDict(
93
- {
94
- "camera_manufacturer": dbPic["metadata"].get("make"),
95
- "camera_model": dbPic["metadata"].get("model"),
96
- "focal_length": dbPic["metadata"].get("focal_length"),
97
- "field_of_view": dbPic["metadata"].get("field_of_view"),
98
- }
99
- )
100
- if "metadata" in dbPic
101
- and any(True for f in dbPic["metadata"] if f in ["make", "model", "focal_length", "field_of_view"])
102
- else {}
103
- ),
104
- "geovisio:status": dbPic.get("status"),
105
- "geovisio:producer": dbPic["account_name"],
106
- "original_file:size": dbPic["metadata"].get("originalFileSize"),
107
- "original_file:name": dbPic["metadata"].get("originalFileName"),
108
- "geovisio:image": _getHDJpgPictureURL(dbPic["id"], dbPic.get("status")),
109
- "geovisio:thumbnail": _getThumbJpgPictureURL(dbPic["id"], dbPic.get("status")),
110
- "exif": removeNoneInDict(cleanupExif(dbPic["exif"])),
111
- },
83
+ "properties": removeNoneInDict(
84
+ {
85
+ "datetime": dbTsToStac(dbPic["ts"]),
86
+ "datetimetz": dbTsToStacTZ(dbPic["ts"], dbPic["metadata"].get("tz")),
87
+ "created": dbTsToStac(dbPic["inserted_at"]),
88
+ # TODO : add "updated" TS for last edit time of metadata
89
+ "license": current_app.config["API_PICTURES_LICENSE_SPDX_ID"],
90
+ "view:azimuth": dbPic["heading"],
91
+ "pers:interior_orientation": (
92
+ removeNoneInDict(
93
+ {
94
+ "camera_manufacturer": dbPic["metadata"].get("make"),
95
+ "camera_model": dbPic["metadata"].get("model"),
96
+ "focal_length": dbPic["metadata"].get("focal_length"),
97
+ "field_of_view": dbPic["metadata"].get("field_of_view"),
98
+ }
99
+ )
100
+ if "metadata" in dbPic
101
+ and any(True for f in dbPic["metadata"] if f in ["make", "model", "focal_length", "field_of_view"])
102
+ else {}
103
+ ),
104
+ "pers:pitch": dbPic["metadata"].get("pitch"),
105
+ "pers:roll": dbPic["metadata"].get("roll"),
106
+ "geovisio:status": dbPic.get("status"),
107
+ "geovisio:producer": dbPic["account_name"],
108
+ "original_file:size": dbPic["metadata"].get("originalFileSize"),
109
+ "original_file:name": dbPic["metadata"].get("originalFileName"),
110
+ "geovisio:image": _getHDJpgPictureURL(dbPic["id"], dbPic.get("status")),
111
+ "geovisio:thumbnail": _getThumbJpgPictureURL(dbPic["id"], dbPic.get("status")),
112
+ "exif": removeNoneInDict(cleanupExif(dbPic["exif"])),
113
+ }
114
+ ),
112
115
  "links": cleanNoneInList(
113
116
  [
114
117
  get_root_link(),
@@ -235,30 +238,6 @@ def dbPictureToStacItem(seqId, dbPic):
235
238
  return item
236
239
 
237
240
 
238
- def cleanupExif(exif: Optional[Dict[str, str]]) -> Optional[Dict[str, str]]:
239
- """Removes things from EXIF dictionnary that should not land in STAC responses
240
- >>> cleanupExif({'A': 'B', 'Exif.Sony.0x0102': 'Blablabla'})
241
- {'A': 'B'}
242
- >>> cleanupExif({'A': 'B', 'Exif.Photo.MakerNote': 'Blablabla'})
243
- {'A': 'B'}
244
- >>> cleanupExif({'A': 'B', 'Exif.Sony.Whatever': '1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21'})
245
- {'A': 'B'}
246
- >>> cleanupExif({'A': 'B', 'Exif.Sony.Whatever': '1 2 3 4 5'})
247
- {'A': 'B', 'Exif.Sony.Whatever': '1 2 3 4 5'}
248
- """
249
-
250
- if exif is None:
251
- return None
252
-
253
- cleanExif = {}
254
-
255
- for k, v in exif.items():
256
- if not (k in ["Exif.Photo.MakerNote"] or RGX_BINARY_KEY.match(k) or RGX_BINARY_VAL.match(v)):
257
- cleanExif[k] = v
258
-
259
- return cleanExif
260
-
261
-
262
241
  def get_first_rank_of_page(rankToHave: int, limit: Optional[int]) -> int:
263
242
  """if there is a limit, we try to emulate a page, so we'll return the nth page that should contain this picture
264
243
  Note: the ranks starts from 1
@@ -715,13 +694,16 @@ def searchItems():
715
694
  - $ref: '#/components/parameters/STAC_limit'
716
695
  - $ref: '#/components/parameters/STAC_ids'
717
696
  - $ref: '#/components/parameters/STAC_collectionsArray'
697
+ - $ref: '#/components/parameters/GeoVisio_place_position'
698
+ - $ref: '#/components/parameters/GeoVisio_place_distance'
699
+ - $ref: '#/components/parameters/GeoVisio_place_fov_tolerance'
718
700
  post:
719
701
  requestBody:
720
702
  required: true
721
703
  content:
722
704
  application/json:
723
705
  schema:
724
- $ref: '#/components/schemas/STACItemSearchBody'
706
+ $ref: '#/components/schemas/GeoVisioItemSearchBody'
725
707
  responses:
726
708
  200:
727
709
  $ref: '#/components/responses/STAC_search'
@@ -732,7 +714,6 @@ def searchItems():
732
714
  sqlWhere = [SQL("(p.status = 'ready' OR p.account_id = %(account)s)"), SQL("(is_sequence_visible_by_user(s, %(account)s))")]
733
715
  sqlParams: Dict[str, Any] = {"account": accountId}
734
716
  sqlSubQueryWhere = [SQL("(p.status = 'ready' OR p.account_id = %(account)s)")]
735
-
736
717
  order_by = SQL("")
737
718
 
738
719
  #
@@ -779,6 +760,52 @@ def searchItems():
779
760
  sqlWhere.append(SQL("p.ts <= %(maxts)s::timestamp with time zone"))
780
761
  sqlParams["maxts"] = max_dt
781
762
 
763
+ # Place position & distance
764
+ place_pos = parse_lonlat(args.getlist("place_position"), "place_position")
765
+ if place_pos is not None:
766
+ sqlParams["placex"] = place_pos[0]
767
+ sqlParams["placey"] = place_pos[1]
768
+
769
+ # Filter to keep pictures in acceptable distance range to POI
770
+ place_dist = parse_distance_range(args.get("place_distance"), "place_distance") or [3, 15]
771
+ sqlParams["placedmin"] = place_dist[0]
772
+ sqlParams["placedmax"] = place_dist[1]
773
+
774
+ sqlWhere.append(
775
+ SQL(
776
+ """
777
+ ST_Intersects(
778
+ p.geom,
779
+ ST_Difference(
780
+ ST_Buffer(ST_Point(%(placex)s, %(placey)s)::geography, %(placedmax)s)::geometry,
781
+ ST_Buffer(ST_Point(%(placex)s, %(placey)s)::geography, %(placedmin)s)::geometry
782
+ )
783
+ )
784
+ """
785
+ )
786
+ )
787
+
788
+ # Compute acceptable field of view
789
+ place_fov_tolerance = args.get("place_fov_tolerance", type=int, default=30)
790
+ if place_fov_tolerance < 2 or place_fov_tolerance > 180:
791
+ raise errors.InvalidAPIUsage(
792
+ "Parameter place_fov_tolerance must be either empty or a number between 2 and 180", status_code=400
793
+ )
794
+ else:
795
+ sqlParams["placefov"] = place_fov_tolerance / 2
796
+
797
+ sqlWhere.append(
798
+ SQL(
799
+ """(
800
+ p.metadata->>'type' = 'equirectangular'
801
+ OR ST_Azimuth(p.geom, ST_Point(%(placex)s, %(placey)s, 4326)) BETWEEN radians(p.heading - %(placefov)s) AND radians(p.heading + %(placefov)s)
802
+ )"""
803
+ )
804
+ )
805
+
806
+ # Sort pictures by nearest to POI
807
+ order_by = SQL("ORDER BY p.geom <-> ST_Point(%(placex)s, %(placey)s, 4326)")
808
+
782
809
  # Intersects
783
810
  if args.get("intersects") is not None:
784
811
  try:
@@ -836,25 +863,24 @@ def searchItems():
836
863
  #
837
864
  # Database query
838
865
  #
839
-
840
866
  with psycopg.connect(current_app.config["DB_URL"], row_factory=dict_row, options="-c statement_timeout=30000") as conn:
841
867
  with conn.cursor() as cursor:
842
868
  query = SQL(
843
869
  """
844
- SELECT * FROM (
845
- SELECT
846
- p.id, p.ts, p.heading, p.metadata, p.inserted_at,
847
- ST_AsGeoJSON(p.geom)::json AS geojson,
848
- sp.seq_id, sp.rank AS rank,
849
- accounts.name AS account_name, p.exif
850
- FROM pictures p
851
- LEFT JOIN sequences_pictures sp ON p.id = sp.pic_id
852
- LEFT JOIN sequences s ON s.id = sp.seq_id
853
- LEFT JOIN accounts ON p.account_id = accounts.id
854
- WHERE {sqlWhere}
855
- {orderBy}
856
- LIMIT %(limit)s
857
- ) pic
870
+ SELECT * FROM (
871
+ SELECT
872
+ p.id, p.ts, p.heading, p.metadata, p.inserted_at,
873
+ ST_AsGeoJSON(p.geom)::json AS geojson,
874
+ sp.seq_id, sp.rank AS rank,
875
+ accounts.name AS account_name, p.exif
876
+ FROM pictures p
877
+ LEFT JOIN sequences_pictures sp ON p.id = sp.pic_id
878
+ LEFT JOIN sequences s ON s.id = sp.seq_id
879
+ LEFT JOIN accounts ON p.account_id = accounts.id
880
+ WHERE {sqlWhere}
881
+ {orderBy}
882
+ LIMIT %(limit)s
883
+ ) pic
858
884
  LEFT JOIN LATERAL (
859
885
  SELECT
860
886
  p.id AS prevpic, ST_AsGeoJSON(p.geom)::json AS prevpicgeojson
@@ -876,6 +902,7 @@ LEFT JOIN LATERAL (
876
902
  ;
877
903
  """
878
904
  ).format(sqlWhere=SQL(" AND ").join(sqlWhere), sqlSubQueryWhere=SQL(" AND ").join(sqlSubQueryWhere), orderBy=order_by)
905
+
879
906
  records = cursor.execute(query, sqlParams)
880
907
 
881
908
  items = [dbPictureToStacItem(str(dbPic["seq_id"]), dbPic) for dbPic in records]
@@ -1085,25 +1112,37 @@ def patchCollectionItem(collectionId, itemId, account):
1085
1112
  # Parse received parameters
1086
1113
  metadata = {}
1087
1114
  content_type = (request.headers.get("Content-Type") or "").split(";")[0]
1088
- if request.is_json:
1089
- metadata["visible"] = request.json.get("visible")
1090
- elif content_type in ["multipart/form-data", "application/x-www-form-urlencoded"]:
1091
- metadata["visible"] = request.form.get("visible")
1092
-
1093
- # Check if visibility param is valid
1094
- if metadata.get("visible") is None:
1095
- # /!\ As visible is the only editable thing for now, we can return if it's null
1096
- # The line below may be removed when other parameters will be available for patching
1097
- # Otherwise, you might want to do: visible = None
1098
- return getCollectionItem(collectionId, itemId)
1115
+ for param in ["visible", "heading"]:
1116
+ if request.is_json and request.json:
1117
+ metadata[param] = request.json.get(param)
1118
+ elif content_type in ["multipart/form-data", "application/x-www-form-urlencoded"]:
1119
+ metadata[param] = request.form.get(param)
1120
+
1121
+ visible = metadata.get("visible")
1122
+ if visible is not None:
1123
+ if visible not in ["true", "false"]:
1124
+ raise errors.InvalidAPIUsage("Picture visibility parameter (visible) should be either unset, true or false", status_code=400)
1125
+ visible = visible == "true"
1126
+
1127
+ # Check if heading is valid
1128
+ heading = metadata.get("heading")
1129
+ if heading is not None:
1130
+ try:
1131
+ heading = int(heading)
1132
+ if heading < 0 or heading > 360:
1133
+ raise ValueError()
1134
+ except ValueError:
1135
+ raise errors.InvalidAPIUsage(
1136
+ "Heading is not valid, should be an integer in degrees from 0° to 360°. North is 0°, East = 90°, South = 180° and West = 270°.",
1137
+ status_code=400,
1138
+ )
1099
1139
 
1100
- elif metadata.get("visible") in ["true", "false"]:
1101
- visible = metadata.get("visible") == "true"
1102
- else:
1103
- raise errors.InvalidAPIUsage("Picture visibility parameter (visible) should be either unset, true or false", status_code=400)
1140
+ # If no parameter is set
1141
+ if {visible, heading} == {None}:
1142
+ return getCollectionItem(collectionId, itemId)
1104
1143
 
1105
1144
  # Check if picture exists and if given account is authorized to edit
1106
- with psycopg.connect(current_app.config["DB_URL"]) as conn:
1145
+ with psycopg.connect(current_app.config["DB_URL"], row_factory=dict_row) as conn:
1107
1146
  with conn.cursor() as cursor:
1108
1147
  pic = cursor.execute("SELECT status, account_id FROM pictures WHERE id = %s", [itemId]).fetchone()
1109
1148
 
@@ -1112,34 +1151,50 @@ def patchCollectionItem(collectionId, itemId, account):
1112
1151
  raise errors.InvalidAPIUsage(f"Picture {itemId} wasn't found in database", status_code=404)
1113
1152
 
1114
1153
  # Account associated to picture doesn't match current user
1115
- if account is not None and account.id != str(pic[1]):
1154
+ if account is not None and account.id != str(pic["account_id"]):
1116
1155
  raise errors.InvalidAPIUsage("You're not authorized to edit this picture", status_code=403)
1117
1156
 
1157
+ sqlUpdates = []
1158
+ sqlParams = {"id": itemId, "account": account.id}
1159
+
1118
1160
  # Let's edit this picture
1119
- oldStatus = pic[0]
1120
- newStatus = None
1161
+ oldStatus = pic["status"]
1162
+ if oldStatus not in ["ready", "hidden"]:
1163
+ # Picture is in a preparing/broken/... state so no edit possible
1164
+ raise errors.InvalidAPIUsage(
1165
+ f"Picture {itemId} is in {oldStatus} state, its visibility can't be changed for now", status_code=400
1166
+ )
1121
1167
 
1168
+ newStatus = None
1122
1169
  if visible is not None:
1123
- if visible is True and oldStatus == "hidden":
1124
- newStatus = "ready"
1125
- elif visible is False and oldStatus == "ready":
1126
- newStatus = "hidden"
1127
- elif (visible is True and oldStatus == "ready") or (visible is False and oldStatus == "hidden"):
1128
- newStatus = oldStatus
1129
-
1130
- # /!\ As visible is the only editable thing for now, we can return if it's unchanged
1131
- # The line below may be removed when other parameters will be available for patching
1132
- return getCollectionItem(collectionId, itemId)
1133
-
1134
- else:
1135
- # Picture is in a preparing/broken/... state so no edit possible
1136
- raise errors.InvalidAPIUsage(
1137
- f"Picture {itemId} is in {oldStatus} state, its visibility can't be changed for now", status_code=400
1138
- )
1139
-
1140
- if newStatus:
1141
- cursor.execute("UPDATE pictures SET status = %s WHERE id = %s", [newStatus, itemId])
1142
- conn.commit()
1170
+ newStatus = "ready" if visible is True else "hidden"
1171
+ if newStatus != oldStatus:
1172
+ sqlUpdates.append(SQL("status = %(status)s"))
1173
+ sqlParams["status"] = newStatus
1174
+
1175
+ if heading is not None:
1176
+ sqlUpdates.extend([SQL("heading = %(heading)s"), SQL("heading_computed = false")])
1177
+ sqlParams["heading"] = heading
1178
+
1179
+ if not sqlUpdates:
1180
+ # Nothing to change, we can return the item
1181
+ return getCollectionItem(collectionId, itemId)
1182
+
1183
+ # Note: we set the field `last_account_to_edit` to track who changed the collection last (later we'll make it possible for everybody to edit some collection fields)
1184
+ # setting this field will trigger the history tracking of the collection (using postgres trigger)
1185
+ sqlUpdates.append(SQL("last_account_to_edit = %(account)s"))
1186
+
1187
+ cursor.execute(
1188
+ SQL(
1189
+ """
1190
+ UPDATE pictures
1191
+ SET {updates}
1192
+ WHERE id = %(id)s
1193
+ """
1194
+ ).format(updates=SQL(", ").join(sqlUpdates)),
1195
+ sqlParams,
1196
+ )
1197
+ conn.commit()
1143
1198
 
1144
1199
  # Redirect response to a classic GET
1145
1200
  return getCollectionItem(collectionId, itemId)