geovisio 2.8.1__py3-none-any.whl → 2.10.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 (70) hide show
  1. geovisio/__init__.py +6 -1
  2. geovisio/config_app.py +16 -5
  3. geovisio/translations/ar/LC_MESSAGES/messages.mo +0 -0
  4. geovisio/translations/ar/LC_MESSAGES/messages.po +818 -0
  5. geovisio/translations/br/LC_MESSAGES/messages.po +1 -1
  6. geovisio/translations/da/LC_MESSAGES/messages.mo +0 -0
  7. geovisio/translations/da/LC_MESSAGES/messages.po +4 -3
  8. geovisio/translations/de/LC_MESSAGES/messages.mo +0 -0
  9. geovisio/translations/de/LC_MESSAGES/messages.po +55 -2
  10. geovisio/translations/el/LC_MESSAGES/messages.po +1 -1
  11. geovisio/translations/en/LC_MESSAGES/messages.mo +0 -0
  12. geovisio/translations/en/LC_MESSAGES/messages.po +193 -139
  13. geovisio/translations/eo/LC_MESSAGES/messages.mo +0 -0
  14. geovisio/translations/eo/LC_MESSAGES/messages.po +53 -4
  15. geovisio/translations/es/LC_MESSAGES/messages.po +1 -1
  16. geovisio/translations/fi/LC_MESSAGES/messages.po +1 -1
  17. geovisio/translations/fr/LC_MESSAGES/messages.mo +0 -0
  18. geovisio/translations/fr/LC_MESSAGES/messages.po +101 -6
  19. geovisio/translations/hu/LC_MESSAGES/messages.po +1 -1
  20. geovisio/translations/it/LC_MESSAGES/messages.mo +0 -0
  21. geovisio/translations/it/LC_MESSAGES/messages.po +63 -3
  22. geovisio/translations/ja/LC_MESSAGES/messages.po +1 -1
  23. geovisio/translations/ko/LC_MESSAGES/messages.po +1 -1
  24. geovisio/translations/messages.pot +185 -129
  25. geovisio/translations/nl/LC_MESSAGES/messages.mo +0 -0
  26. geovisio/translations/nl/LC_MESSAGES/messages.po +421 -86
  27. geovisio/translations/oc/LC_MESSAGES/messages.mo +0 -0
  28. geovisio/translations/oc/LC_MESSAGES/messages.po +818 -0
  29. geovisio/translations/pl/LC_MESSAGES/messages.po +1 -1
  30. geovisio/translations/sv/LC_MESSAGES/messages.mo +0 -0
  31. geovisio/translations/sv/LC_MESSAGES/messages.po +823 -0
  32. geovisio/translations/ti/LC_MESSAGES/messages.mo +0 -0
  33. geovisio/translations/ti/LC_MESSAGES/messages.po +762 -0
  34. geovisio/translations/zh_Hant/LC_MESSAGES/messages.po +1 -1
  35. geovisio/utils/annotations.py +183 -0
  36. geovisio/utils/auth.py +14 -13
  37. geovisio/utils/cql2.py +134 -0
  38. geovisio/utils/db.py +7 -0
  39. geovisio/utils/fields.py +38 -9
  40. geovisio/utils/items.py +44 -0
  41. geovisio/utils/model_query.py +4 -4
  42. geovisio/utils/pic_shape.py +63 -0
  43. geovisio/utils/pictures.py +164 -29
  44. geovisio/utils/reports.py +10 -17
  45. geovisio/utils/semantics.py +196 -57
  46. geovisio/utils/sentry.py +1 -2
  47. geovisio/utils/sequences.py +191 -93
  48. geovisio/utils/tags.py +31 -0
  49. geovisio/utils/upload_set.py +287 -209
  50. geovisio/utils/website.py +1 -1
  51. geovisio/web/annotations.py +346 -9
  52. geovisio/web/auth.py +1 -1
  53. geovisio/web/collections.py +73 -54
  54. geovisio/web/configuration.py +26 -5
  55. geovisio/web/docs.py +143 -11
  56. geovisio/web/items.py +232 -155
  57. geovisio/web/map.py +25 -13
  58. geovisio/web/params.py +55 -52
  59. geovisio/web/pictures.py +34 -0
  60. geovisio/web/stac.py +19 -12
  61. geovisio/web/tokens.py +49 -1
  62. geovisio/web/upload_set.py +148 -37
  63. geovisio/web/users.py +4 -4
  64. geovisio/web/utils.py +2 -2
  65. geovisio/workers/runner_pictures.py +190 -24
  66. {geovisio-2.8.1.dist-info → geovisio-2.10.0.dist-info}/METADATA +27 -26
  67. geovisio-2.10.0.dist-info/RECORD +105 -0
  68. {geovisio-2.8.1.dist-info → geovisio-2.10.0.dist-info}/WHEEL +1 -1
  69. geovisio-2.8.1.dist-info/RECORD +0 -92
  70. {geovisio-2.8.1.dist-info → geovisio-2.10.0.dist-info}/licenses/LICENSE +0 -0
geovisio/web/items.py CHANGED
@@ -6,13 +6,18 @@ from typing import Dict, List, Optional, Any
6
6
  from urllib.parse import unquote
7
7
  from psycopg.types.json import Jsonb
8
8
  from pydantic import BaseModel, ConfigDict, ValidationError, field_validator, model_validator
9
+ from shapely import intersects
9
10
  from werkzeug.datastructures import MultiDict
10
11
  from uuid import UUID
11
12
  from geovisio import errors, utils
12
13
  from geovisio.utils import auth, db
14
+ from geovisio.utils.cql2 import parse_search_filter
13
15
  from geovisio.utils.params import validation_error
14
16
  from geovisio.utils.pictures import cleanupExif
15
- from geovisio.utils.semantics import SemanticTagUpdate, Entity, EntityType, update_tags
17
+ from geovisio.utils.semantics import Entity, EntityType, update_tags
18
+ from geovisio.utils.items import SortableItemField, SortBy, ItemSortByField
19
+ from geovisio.utils.tags import SemanticTagUpdate
20
+ from geovisio.utils.auth import Account
16
21
  from geovisio.web.params import (
17
22
  as_latitude,
18
23
  as_longitude,
@@ -20,12 +25,13 @@ from geovisio.web.params import (
20
25
  parse_datetime,
21
26
  parse_datetime_interval,
22
27
  parse_bbox,
28
+ parse_item_sortby,
23
29
  parse_list,
24
30
  parse_lonlat,
25
31
  parse_distance_range,
26
32
  parse_picture_heading,
27
33
  )
28
- from geovisio.utils.fields import Bounds
34
+ from geovisio.utils.fields import Bounds, SQLDirection
29
35
  import hashlib
30
36
  from psycopg.rows import dict_row
31
37
  from psycopg.sql import SQL
@@ -49,7 +55,7 @@ import math
49
55
  bp = Blueprint("stac_items", __name__, url_prefix="/api")
50
56
 
51
57
 
52
- def dbPictureToStacItem(seqId, dbPic):
58
+ def dbPictureToStacItem(dbPic):
53
59
  """Transforms a picture extracted from database into a STAC Item
54
60
 
55
61
  Parameters
@@ -67,6 +73,7 @@ def dbPictureToStacItem(seqId, dbPic):
67
73
 
68
74
  sensorDim = None
69
75
  visibleArea = None
76
+ seqId = str(dbPic["seq_id"])
70
77
  if dbPic["metadata"].get("crop") is not None:
71
78
  sensorDim = [dbPic["metadata"]["crop"].get("fullWidth"), dbPic["metadata"]["crop"].get("fullHeight")]
72
79
  visibleArea = [
@@ -112,7 +119,7 @@ def dbPictureToStacItem(seqId, dbPic):
112
119
  "datetime": dbTsToStac(dbPic["ts"]),
113
120
  "datetimetz": dbTsToStacTZ(dbPic["ts"], dbPic["metadata"].get("tz")),
114
121
  "created": dbTsToStac(dbPic["inserted_at"]),
115
- # TODO : add "updated" TS for last edit time of metadata
122
+ "updated": dbTsToStac(dbPic["updated_at"]),
116
123
  "license": current_app.config["API_PICTURES_LICENSE_SPDX_ID"],
117
124
  "view:azimuth": dbPic["heading"],
118
125
  "pers:interior_orientation": (
@@ -138,6 +145,7 @@ def dbPictureToStacItem(seqId, dbPic):
138
145
  "pers:roll": dbPic["metadata"].get("roll"),
139
146
  "geovisio:status": dbPic.get("status"),
140
147
  "geovisio:producer": dbPic["account_name"],
148
+ "geovisio:rank_in_collection": dbPic["rank"],
141
149
  "original_file:size": dbPic["metadata"].get("originalFileSize"),
142
150
  "original_file:name": dbPic["metadata"].get("originalFileName"),
143
151
  "panoramax:horizontal_pixel_density": dbPic.get("h_pixel_density"),
@@ -145,7 +153,9 @@ def dbPictureToStacItem(seqId, dbPic):
145
153
  "geovisio:thumbnail": _getThumbJpgPictureURL(dbPic["id"], dbPic.get("status")),
146
154
  "exif": removeNoneInDict(cleanupExif(dbPic["exif"])),
147
155
  "quality:horizontal_accuracy": float("{:.1f}".format(dbPic["gps_accuracy_m"])) if dbPic.get("gps_accuracy_m") else None,
148
- "semantics": dbPic["semantics"] if "semantics" in dbPic else None,
156
+ "semantics": [s for s in dbPic.get("semantics") or [] if s],
157
+ "annotations": [a for a in dbPic.get("annotations") or [] if a],
158
+ "collection": {"semantics": dbPic["sequence_semantics"]} if "sequence_semantics" in dbPic else None,
149
159
  }
150
160
  ),
151
161
  "links": cleanNoneInList(
@@ -419,7 +429,7 @@ def getCollectionItems(collectionId):
419
429
  params={"id": withPicture, "seq": collectionId},
420
430
  ).fetchone()
421
431
  if not pic:
422
- raise errors.InvalidAPIUsage(_("Picture with id %(p)s does not exists", p=withPicture))
432
+ raise errors.InvalidAPIUsage(_("Picture with id %(p)s does not exist", p=withPicture))
423
433
  rank = get_first_rank_of_page(pic["rank"], limit)
424
434
 
425
435
  filters.append(SQL("rank >= %(start_after_rank)s"))
@@ -428,28 +438,21 @@ def getCollectionItems(collectionId):
428
438
  query = SQL(
429
439
  """
430
440
  SELECT
431
- p.id, p.ts, p.heading, p.metadata, p.inserted_at, p.status,
441
+ p.id, p.ts, p.heading, p.metadata, p.inserted_at, p.updated_at, p.status,
432
442
  ST_AsGeoJSON(p.geom)::json AS geojson,
433
443
  a.name AS account_name,
434
444
  p.account_id AS account_id,
435
- sp.rank, p.exif, p.gps_accuracy_m, p.h_pixel_density,
445
+ sp.seq_id, sp.rank, p.exif, p.gps_accuracy_m, p.h_pixel_density,
436
446
  CASE WHEN LAG(p.status) OVER othpics = 'ready' THEN LAG(p.id) OVER othpics END AS prevpic,
437
447
  CASE WHEN LAG(p.status) OVER othpics = 'ready' THEN ST_AsGeoJSON(LAG(p.geom) OVER othpics)::json END AS prevpicgeojson,
438
448
  CASE WHEN LEAD(p.status) OVER othpics = 'ready' THEN LEAD(p.id) OVER othpics END AS nextpic,
439
449
  CASE WHEN LEAD(p.status) OVER othpics = 'ready' THEN ST_AsGeoJSON(LEAD(p.geom) OVER othpics)::json END AS nextpicgeojson,
440
- t.semantics
450
+ get_picture_semantics(p.id) as semantics,
451
+ get_picture_annotations(p.id) as annotations
441
452
  FROM sequences_pictures sp
442
453
  JOIN pictures p ON sp.pic_id = p.id
443
454
  JOIN accounts a ON a.id = p.account_id
444
455
  JOIN sequences s ON s.id = sp.seq_id
445
- LEFT JOIN (
446
- SELECT picture_id, json_agg(json_strip_nulls(json_build_object(
447
- 'key', key,
448
- 'value', value
449
- ))) AS semantics
450
- FROM pictures_semantics
451
- GROUP BY picture_id
452
- ) t ON t.picture_id = p.id
453
456
  WHERE
454
457
  {filter}
455
458
  WINDOW othpics AS (PARTITION BY sp.seq_id ORDER BY sp.rank)
@@ -460,15 +463,14 @@ def getCollectionItems(collectionId):
460
463
 
461
464
  records = cursor.execute(query, params)
462
465
 
463
- bounds: Optional[Bounds] = None
464
466
  items = []
467
+ first_rank, last_rank = None, None
465
468
  for dbPic in records:
466
- if not bounds:
467
- bounds = Bounds(min=dbPic["rank"], max=dbPic["rank"])
468
- else:
469
- bounds.update(dbPic["rank"])
470
-
471
- items.append(dbPictureToStacItem(collectionId, dbPic))
469
+ if first_rank is None:
470
+ first_rank = dbPic["rank"]
471
+ last_rank = dbPic["rank"]
472
+ items.append(dbPictureToStacItem(dbPic))
473
+ bounds = Bounds(first=first_rank, last=last_rank) if records else None
472
474
 
473
475
  links = [
474
476
  get_root_link(),
@@ -491,8 +493,8 @@ def getCollectionItems(collectionId):
491
493
  ]
492
494
 
493
495
  if paginated and items and bounds:
494
- if bounds.min:
495
- has_item_before = bounds.min > seqMeta["min_rank"]
496
+ if bounds.first:
497
+ has_item_before = bounds.first > seqMeta["min_rank"]
496
498
  if has_item_before:
497
499
  links.append(
498
500
  {
@@ -504,7 +506,7 @@ def getCollectionItems(collectionId):
504
506
  # Previous page link
505
507
  # - If limit is set, rank is current - limit -1
506
508
  # - If no limit is set, rank is 0 (none)
507
- prevRank = bounds.min - limit - 1 if limit is not None else 0
509
+ prevRank = bounds.first - limit - 1 if limit is not None else 0
508
510
  if prevRank < 1:
509
511
  prevRank = None
510
512
  links.append(
@@ -521,7 +523,7 @@ def getCollectionItems(collectionId):
521
523
  }
522
524
  )
523
525
 
524
- has_item_after = bounds.max < seqMeta["max_rank"]
526
+ has_item_after = bounds.last < seqMeta["max_rank"]
525
527
  if has_item_after:
526
528
  links.append(
527
529
  {
@@ -532,7 +534,7 @@ def getCollectionItems(collectionId):
532
534
  _external=True,
533
535
  collectionId=collectionId,
534
536
  limit=limit,
535
- startAfterRank=bounds.max,
537
+ startAfterRank=bounds.last,
536
538
  ),
537
539
  }
538
540
  )
@@ -543,10 +545,10 @@ def getCollectionItems(collectionId):
543
545
 
544
546
  lastPageRank = startAfterRank
545
547
  if limit is not None:
546
- if seqMeta["max_rank"] > bounds.max:
548
+ if seqMeta["max_rank"] > bounds.last:
547
549
  lastPageRank = seqMeta["max_rank"] - limit
548
- if lastPageRank < bounds.max:
549
- lastPageRank = bounds.max
550
+ if lastPageRank < bounds.last:
551
+ lastPageRank = bounds.last
550
552
 
551
553
  links.append(
552
554
  {
@@ -573,26 +575,8 @@ def getCollectionItems(collectionId):
573
575
  )
574
576
 
575
577
 
576
- def _getPictureItemById(collectionId, itemId):
577
- """Get a picture metadata by its ID and collection ID
578
-
579
- ---
580
- tags:
581
- - Pictures
582
- parameters:
583
- - name: collectionId
584
- in: path
585
- description: ID of collection to retrieve
586
- required: true
587
- schema:
588
- type: string
589
- - name: itemId
590
- in: path
591
- description: ID of item to retrieve
592
- required: true
593
- schema:
594
- type: string
595
- """
578
+ def _getPictureItemById(itemId: UUID, account: Optional[Account]):
579
+ """Get a picture metadata by its ID"""
596
580
  with current_app.pool.connection() as conn:
597
581
  with conn.cursor(row_factory=dict_row) as cursor:
598
582
  # Check if there is a logged user
@@ -601,26 +585,22 @@ def _getPictureItemById(collectionId, itemId):
601
585
 
602
586
  # Get rank + position of wanted picture
603
587
  record = cursor.execute(
604
- """
588
+ """WITH seq AS (
589
+ SELECT seq_id FROM sequences_pictures WHERE pic_id = %(pic)s LIMIT 1
590
+ )
605
591
  SELECT
606
- p.id, sp.rank, ST_AsGeoJSON(p.geom)::json AS geojson, p.heading, p.ts, p.metadata,
607
- p.inserted_at, p.status, accounts.name AS account_name,
592
+ 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,
608
594
  p.account_id AS account_id,
609
595
  spl.prevpic, spl.prevpicgeojson, spl.nextpic, spl.nextpicgeojson, p.exif,
610
596
  relp.related_pics, p.gps_accuracy_m, p.h_pixel_density,
611
- t.semantics
597
+ get_picture_semantics(p.id) as semantics,
598
+ get_picture_annotations(p.id) as annotations,
599
+ COALESCE(seq_sem.semantics, '[]'::json) AS sequence_semantics
612
600
  FROM pictures p
613
601
  JOIN sequences_pictures sp ON sp.pic_id = p.id
614
602
  JOIN accounts ON p.account_id = accounts.id
615
603
  JOIN sequences s ON sp.seq_id = s.id
616
- LEFT JOIN (
617
- SELECT picture_id, json_agg(json_strip_nulls(json_build_object(
618
- 'key', key,
619
- 'value', value
620
- ))) AS semantics
621
- FROM pictures_semantics
622
- GROUP BY picture_id
623
- ) t ON t.picture_id = p.id
624
604
  LEFT JOIN (
625
605
  SELECT
626
606
  p.id,
@@ -631,7 +611,7 @@ def _getPictureItemById(collectionId, itemId):
631
611
  FROM pictures p
632
612
  JOIN sequences_pictures sp ON p.id = sp.pic_id
633
613
  WHERE
634
- sp.seq_id = %(seq)s
614
+ sp.seq_id IN (SELECT seq_id FROM seq)
635
615
  AND (p.account_id = %(acc)s OR p.status != 'hidden')
636
616
  WINDOW othpics AS (PARTITION BY sp.seq_id ORDER BY sp.rank)
637
617
  ) spl ON p.id = spl.id
@@ -659,7 +639,7 @@ def _getPictureItemById(collectionId, itemId):
659
639
  AND relp.status != 'waiting-for-delete'
660
640
  AND relp.id != p.id
661
641
  AND relsp.pic_id = relp.id
662
- AND relsp.seq_id != %(seq)s
642
+ AND relsp.seq_id NOT IN (SELECT seq_id FROM seq)
663
643
  AND (
664
644
  p.metadata->>'type' = 'equirectangular'
665
645
  OR (relp.heading IS NULL OR p.heading IS NULL)
@@ -672,19 +652,27 @@ def _getPictureItemById(collectionId, itemId):
672
652
  ORDER BY relsp.seq_id, p.geom <-> relp.geom
673
653
  ) a
674
654
  ) relp ON TRUE
675
- WHERE sp.seq_id = %(seq)s
655
+ LEFT JOIN (
656
+ SELECT sequence_id, json_agg(json_strip_nulls(json_build_object(
657
+ 'key', key,
658
+ 'value', value
659
+ )) ORDER BY key, value) AS semantics
660
+ FROM sequences_semantics
661
+ GROUP BY sequence_id
662
+ ) seq_sem ON seq_sem.sequence_id = s.id
663
+ WHERE sp.seq_id IN (SELECT seq_id FROM seq)
676
664
  AND p.id = %(pic)s
677
665
  AND (p.account_id = %(acc)s OR p.status != 'hidden')
678
666
  AND (s.status != 'hidden' OR s.account_id = %(acc)s)
679
667
  AND s.status != 'deleted'
680
668
  """,
681
- {"seq": collectionId, "pic": itemId, "acc": accountId},
669
+ {"pic": itemId, "acc": accountId},
682
670
  ).fetchone()
683
671
 
684
672
  if record is None:
685
673
  return None
686
674
 
687
- return dbPictureToStacItem(collectionId, record)
675
+ return dbPictureToStacItem(record)
688
676
 
689
677
 
690
678
  @bp.route("/collections/<uuid:collectionId>/items/<uuid:itemId>")
@@ -720,12 +708,12 @@ def getCollectionItem(collectionId, itemId):
720
708
  schema:
721
709
  $ref: '#/components/schemas/GeoVisioItem'
722
710
  """
711
+ account = auth.get_current_account()
723
712
 
724
- stacItem = _getPictureItemById(collectionId, itemId)
713
+ stacItem = _getPictureItemById(itemId, account)
725
714
  if stacItem is None:
726
715
  raise errors.InvalidAPIUsage(_("Item doesn't exist"), status_code=404)
727
716
 
728
- account = auth.get_current_account()
729
717
  picStatusToHttpCode = {
730
718
  "waiting-for-process": 102,
731
719
  "ready": 200,
@@ -755,6 +743,8 @@ def searchItems():
755
743
  - $ref: '#/components/parameters/GeoVisio_place_position'
756
744
  - $ref: '#/components/parameters/GeoVisio_place_distance'
757
745
  - $ref: '#/components/parameters/GeoVisio_place_fov_tolerance'
746
+ - $ref: '#/components/parameters/searchCQL2_filter'
747
+ - $ref: '#/components/parameters/GeoVisioSearchSortedBy'
758
748
  post:
759
749
  requestBody:
760
750
  required: true
@@ -764,7 +754,11 @@ def searchItems():
764
754
  $ref: '#/components/schemas/GeoVisioItemSearchBody'
765
755
  responses:
766
756
  200:
767
- $ref: '#/components/responses/STAC_search'
757
+ description: The search results
758
+ content:
759
+ application/geo+json:
760
+ schema:
761
+ $ref: '#/components/schemas/GeoVisioCollectionItems'
768
762
  """
769
763
 
770
764
  account = auth.get_current_account()
@@ -772,7 +766,6 @@ def searchItems():
772
766
  sqlWhere = [SQL("(p.status = 'ready' OR p.account_id = %(account)s)"), SQL("(is_sequence_visible_by_user(s, %(account)s))")]
773
767
  sqlParams: Dict[str, Any] = {"account": accountId}
774
768
  sqlSubQueryWhere = [SQL("(p.status = 'ready' OR p.account_id = %(account)s)")]
775
- order_by = SQL("")
776
769
 
777
770
  #
778
771
  # Parameters parsing and verification
@@ -788,14 +781,16 @@ def searchItems():
788
781
  args = request.args
789
782
 
790
783
  # Limit
791
- if args.get("limit") is not None:
792
- limit = args.get("limit", type=int)
793
- if limit is None or limit < 1 or limit > 10000:
794
- raise errors.InvalidAPIUsage(_("Parameter limit must be either empty or a number between 1 and 10000"), status_code=400)
795
- else:
796
- sqlParams["limit"] = limit
797
- else:
798
- sqlParams["limit"] = 10
784
+ limit = args.get("limit") or 10
785
+ try:
786
+ limit = int(limit)
787
+ if limit < 1 or limit > 10000:
788
+ raise ValueError()
789
+ except ValueError:
790
+ raise errors.InvalidAPIUsage(_("Parameter limit must be either empty or a number between 1 and 10000"), status_code=400)
791
+ sqlParams["limit"] = limit
792
+
793
+ sort_by = parse_item_sortby(args.get("sortby"))
799
794
 
800
795
  # Bounding box
801
796
  bboxarg = parse_bbox(args.getlist("bbox"))
@@ -806,7 +801,16 @@ def searchItems():
806
801
  sqlParams["maxx"] = bboxarg[2]
807
802
  sqlParams["maxy"] = bboxarg[3]
808
803
  # if we search by bbox, we'll give first the items near the center of the bounding box
809
- order_by = SQL("ORDER BY p.geom <-> ST_Centroid(ST_MakeEnvelope(%(minx)s, %(miny)s, %(maxx)s, %(maxy)s, 4326))")
804
+ if not sort_by:
805
+ sort_by = SortBy(
806
+ fields=[
807
+ ItemSortByField(
808
+ field=SortableItemField.distance_to,
809
+ direction=SQLDirection.ASC,
810
+ obj_to_compare=SQL("ST_Centroid(ST_MakeEnvelope(%(minx)s, %(miny)s, %(maxx)s, %(maxy)s, 4326))"),
811
+ ),
812
+ ]
813
+ )
810
814
 
811
815
  # Datetime
812
816
  min_dt, max_dt = parse_datetime_interval(args.get("datetime"))
@@ -862,7 +866,16 @@ def searchItems():
862
866
  )
863
867
 
864
868
  # Sort pictures by nearest to POI
865
- order_by = SQL("ORDER BY p.geom <-> ST_Point(%(placex)s, %(placey)s, 4326)")
869
+ if not sort_by:
870
+ sort_by = SortBy(
871
+ fields=[
872
+ ItemSortByField(
873
+ field=SortableItemField.distance_to,
874
+ direction=SQLDirection.ASC,
875
+ obj_to_compare=SQL("ST_Point(%(placex)s, %(placey)s, 4326)"),
876
+ ),
877
+ ]
878
+ )
866
879
 
867
880
  # Intersects
868
881
  if args.get("intersects") is not None:
@@ -877,7 +890,16 @@ def searchItems():
877
890
  sqlWhere.append(SQL("ST_Intersects(p.geom, ST_GeomFromGeoJSON(%(geom)s))"))
878
891
  sqlParams["geom"] = Jsonb(intersects)
879
892
  # if we search by bbox, we'll give first the items near the center of the bounding box
880
- order_by = SQL("ORDER BY p.geom <-> ST_Centroid(ST_GeomFromGeoJSON(%(geom)s))")
893
+ if not sort_by:
894
+ sort_by = SortBy(
895
+ fields=[
896
+ ItemSortByField(
897
+ field=SortableItemField.distance_to,
898
+ direction=SQLDirection.ASC,
899
+ obj_to_compare=SQL("ST_Centroid(ST_GeomFromGeoJSON(%(geom)s))"),
900
+ ),
901
+ ]
902
+ )
881
903
 
882
904
  # Ids
883
905
  if args.get("ids") is not None:
@@ -900,23 +922,35 @@ def searchItems():
900
922
  raise errors.InvalidAPIUsage(_("Parameter collections should be a JSON array of strings"), status_code=400)
901
923
 
902
924
  # To speed up search, if it's a search by id and on only one id, we use the same code as /collections/:cid/items/:id
903
- if args.get("ids") is not None and args:
925
+ if args.get("ids") is not None:
904
926
  ids = parse_list(args.get("ids"), paramName="ids")
905
927
  if ids and len(ids) == 1:
906
928
  picture_id = ids[0]
907
929
 
908
930
  with current_app.pool.connection() as conn, conn.cursor() as cursor:
909
- seq = cursor.execute("SELECT seq_id FROM sequences_pictures WHERE pic_id = %s", [picture_id]).fetchone()
910
- if not seq:
911
- raise errors.InvalidAPIUsage(_("Picture doesn't exist"), status_code=404)
912
-
913
- item = _getPictureItemById(seq[0], UUID(picture_id))
931
+ item = _getPictureItemById(UUID(picture_id), account)
914
932
  features = [item] if item else []
915
933
  return (
916
934
  {"type": "FeatureCollection", "features": features, "links": [get_root_link()]},
917
935
  200,
918
936
  {"Content-Type": "application/geo+json"},
919
937
  )
938
+ filter_param = args.get("filter")
939
+ if filter_param is not None:
940
+ cql_filter = parse_search_filter(filter_param)
941
+ if cql_filter is not None:
942
+ sqlWhere.append(cql_filter)
943
+
944
+ if not sort_by:
945
+ # by default we sort by last updated (and id in case of equalities)
946
+ sort_by = SortBy(
947
+ fields=[
948
+ ItemSortByField(field=SortableItemField.updated, direction=SQLDirection.DESC),
949
+ ItemSortByField(field=SortableItemField.id, direction=SQLDirection.ASC),
950
+ ]
951
+ )
952
+
953
+ order_by = sort_by.to_sql()
920
954
 
921
955
  #
922
956
  # Database query
@@ -926,25 +960,27 @@ def searchItems():
926
960
  """
927
961
  SELECT * FROM (
928
962
  SELECT
929
- p.id, p.ts, p.heading, p.metadata, p.inserted_at,
963
+ p.id, p.ts, p.heading, p.metadata, p.inserted_at, p.updated_at,
930
964
  ST_AsGeoJSON(p.geom)::json AS geojson,
931
965
  sp.seq_id, sp.rank AS rank,
932
966
  accounts.name AS account_name,
933
967
  p.account_id AS account_id,
934
968
  p.exif, p.gps_accuracy_m, p.h_pixel_density,
935
- t.semantics
969
+ get_picture_semantics(p.id) as semantics,
970
+ get_picture_annotations(p.id) as annotations,
971
+ COALESCE(seq_sem.semantics, '[]'::json) AS sequence_semantics
936
972
  FROM pictures p
937
973
  LEFT JOIN sequences_pictures sp ON p.id = sp.pic_id
938
974
  LEFT JOIN sequences s ON s.id = sp.seq_id
939
975
  LEFT JOIN accounts ON p.account_id = accounts.id
940
976
  LEFT JOIN (
941
- SELECT picture_id, json_agg(json_strip_nulls(json_build_object(
977
+ SELECT sequence_id, json_agg(json_strip_nulls(json_build_object(
942
978
  'key', key,
943
979
  'value', value
944
- ))) AS semantics
945
- FROM pictures_semantics
946
- GROUP BY picture_id
947
- ) t ON t.picture_id = p.id
980
+ )) ORDER BY key, value) AS semantics
981
+ FROM sequences_semantics
982
+ GROUP BY sequence_id
983
+ ) seq_sem ON seq_sem.sequence_id = s.id
948
984
  WHERE {sqlWhere}
949
985
  {orderBy}
950
986
  LIMIT %(limit)s
@@ -967,13 +1003,14 @@ LEFT JOIN LATERAL (
967
1003
  ORDER BY sp.rank ASC
968
1004
  LIMIT 1
969
1005
  ) next on true
1006
+
970
1007
  ;
971
1008
  """
972
1009
  ).format(sqlWhere=SQL(" AND ").join(sqlWhere), sqlSubQueryWhere=SQL(" AND ").join(sqlSubQueryWhere), orderBy=order_by)
973
1010
 
974
1011
  records = cursor.execute(query, sqlParams)
975
1012
 
976
- items = [dbPictureToStacItem(str(dbPic["seq_id"]), dbPic) for dbPic in records]
1013
+ items = [dbPictureToStacItem(dbPic) for dbPic in records]
977
1014
 
978
1015
  return (
979
1016
  {
@@ -991,7 +1028,9 @@ LEFT JOIN LATERAL (
991
1028
  @bp.route("/collections/<uuid:collectionId>/items", methods=["POST"])
992
1029
  @auth.login_required_by_setting("API_FORCE_AUTH_ON_UPLOAD")
993
1030
  def postCollectionItem(collectionId, account=None):
994
- """Add a new picture in a given sequence
1031
+ """Add a new picture in a given sequence.
1032
+
1033
+ Note that this is the legacy API, upload should be done using the [UploadSet](#UploadSet) endpoints if possible.
995
1034
  ---
996
1035
  tags:
997
1036
  - Upload
@@ -1017,6 +1056,42 @@ def postCollectionItem(collectionId, account=None):
1017
1056
  application/geo+json:
1018
1057
  schema:
1019
1058
  $ref: '#/components/schemas/GeoVisioItem'
1059
+ 400:
1060
+ description: Error if the request is malformed
1061
+ content:
1062
+ application/json:
1063
+ schema:
1064
+ $ref: '#/components/schemas/GeoVisioError'
1065
+ 401:
1066
+ description: Error if you're not logged in
1067
+ content:
1068
+ application/json:
1069
+ schema:
1070
+ $ref: '#/components/schemas/GeoVisioError'
1071
+ 403:
1072
+ description: Error if you're not authorized to add picture to this collection
1073
+ content:
1074
+ application/json:
1075
+ schema:
1076
+ $ref: '#/components/schemas/GeoVisioError'
1077
+ 404:
1078
+ description: Error if the collection doesn't exist
1079
+ content:
1080
+ application/json:
1081
+ schema:
1082
+ $ref: '#/components/schemas/GeoVisioError'
1083
+ 409:
1084
+ 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
1085
+ content:
1086
+ application/json:
1087
+ schema:
1088
+ $ref: '#/components/schemas/GeoVisioError'
1089
+ 415:
1090
+ description: Error if the content type is not multipart/form-data
1091
+ content:
1092
+ application/json:
1093
+ schema:
1094
+ $ref: '#/components/schemas/GeoVisioError'
1020
1095
  """
1021
1096
 
1022
1097
  if not request.headers.get("Content-Type", "").startswith("multipart/form-data"):
@@ -1124,7 +1199,7 @@ def postCollectionItem(collectionId, account=None):
1124
1199
  conn, collectionId, position, updated_picture, accountId, additionalMetadata, lang=get_locale().language
1125
1200
  )
1126
1201
  except utils.pictures.PicturePositionConflict:
1127
- raise errors.InvalidAPIUsage(_("Picture at given position already exist"), status_code=409)
1202
+ raise errors.InvalidAPIUsage(_("There is already a picture with the same index in the sequence"), status_code=409)
1128
1203
  except utils.pictures.MetadataReadingError as e:
1129
1204
  raise errors.InvalidAPIUsage(_("Impossible to parse picture metadata"), payload={"details": {"error": e.details}})
1130
1205
  except utils.pictures.InvalidMetadataValue as e:
@@ -1232,60 +1307,8 @@ class PatchItemParameter(BaseModel):
1232
1307
  return self.model_fields_set == {"semantics"}
1233
1308
 
1234
1309
 
1235
- @bp.route("/collections/<uuid:collectionId>/items/<uuid:itemId>", methods=["PATCH"])
1236
- @auth.login_required()
1237
- def patchCollectionItem(collectionId, itemId, account):
1238
- """Edits properties of an existing picture
1239
-
1240
- Note that tags cannot be added as form-data for the moment, only as JSON.
1241
-
1242
- Note that there are rules on the editing of a picture's metadata:
1243
-
1244
- - Only the owner of a picture can change its visibility
1245
- - 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.
1246
- - Everyone can add/edit/delete semantics tags.
1247
- ---
1248
- tags:
1249
- - Editing
1250
- - Tags
1251
- parameters:
1252
- - name: collectionId
1253
- in: path
1254
- description: ID of sequence the picture belongs to
1255
- required: true
1256
- schema:
1257
- type: string
1258
- - name: itemId
1259
- in: path
1260
- description: ID of picture to edit
1261
- required: true
1262
- schema:
1263
- type: string
1264
- requestBody:
1265
- content:
1266
- application/json:
1267
- schema:
1268
- $ref: '#/components/schemas/GeoVisioPatchItem'
1269
- application/x-www-form-urlencoded:
1270
- schema:
1271
- $ref: '#/components/schemas/GeoVisioPatchItem'
1272
- multipart/form-data:
1273
- schema:
1274
- $ref: '#/components/schemas/GeoVisioPatchItem'
1275
- security:
1276
- - bearerToken: []
1277
- - cookieAuth: []
1278
- responses:
1279
- 200:
1280
- description: the wanted item
1281
- content:
1282
- application/geo+json:
1283
- schema:
1284
- $ref: '#/components/schemas/GeoVisioItem'
1285
- """
1286
-
1310
+ def update_picture(itemId: UUID, account: Optional[Account]):
1287
1311
  # Parse received parameters
1288
-
1289
1312
  metadata = None
1290
1313
  content_type = (request.headers.get("Content-Type") or "").split(";")[0]
1291
1314
 
@@ -1299,7 +1322,7 @@ def patchCollectionItem(collectionId, itemId, account):
1299
1322
 
1300
1323
  # If no parameter is set
1301
1324
  if metadata is None or not metadata.has_override():
1302
- return getCollectionItem(collectionId, itemId)
1325
+ return (_getPictureItemById(itemId, account), 304)
1303
1326
 
1304
1327
  # Check if picture exists and if given account is authorized to edit
1305
1328
  with db.conn(current_app) as conn:
@@ -1380,7 +1403,61 @@ WHERE id = %(id)s"""
1380
1403
  )
1381
1404
 
1382
1405
  # Redirect response to a classic GET
1383
- return getCollectionItem(collectionId, itemId)
1406
+ return (_getPictureItemById(itemId, account), 200)
1407
+
1408
+
1409
+ @bp.route("/collections/<uuid:collectionId>/items/<uuid:itemId>", methods=["PATCH"])
1410
+ @auth.login_required()
1411
+ def patchCollectionItem(collectionId, itemId, account):
1412
+ """Edits properties of an existing picture
1413
+
1414
+ Note that tags cannot be added as form-data for the moment, only as JSON.
1415
+
1416
+ Note that there are rules on the editing of a picture's metadata:
1417
+
1418
+ - Only the owner of a picture can change its visibility
1419
+ - 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.
1420
+ - Everyone can add/edit/delete semantics tags.
1421
+ ---
1422
+ tags:
1423
+ - Editing
1424
+ - Semantics
1425
+ parameters:
1426
+ - name: collectionId
1427
+ in: path
1428
+ description: ID of sequence the picture belongs to
1429
+ required: true
1430
+ schema:
1431
+ type: string
1432
+ - name: itemId
1433
+ in: path
1434
+ description: ID of picture to edit
1435
+ required: true
1436
+ schema:
1437
+ type: string
1438
+ requestBody:
1439
+ content:
1440
+ application/json:
1441
+ schema:
1442
+ $ref: '#/components/schemas/GeoVisioPatchItem'
1443
+ application/x-www-form-urlencoded:
1444
+ schema:
1445
+ $ref: '#/components/schemas/GeoVisioPatchItem'
1446
+ multipart/form-data:
1447
+ schema:
1448
+ $ref: '#/components/schemas/GeoVisioPatchItem'
1449
+ security:
1450
+ - bearerToken: []
1451
+ - cookieAuth: []
1452
+ responses:
1453
+ 200:
1454
+ description: the wanted item
1455
+ content:
1456
+ application/geo+json:
1457
+ schema:
1458
+ $ref: '#/components/schemas/GeoVisioItem'
1459
+ """
1460
+ return update_picture(itemId, account)
1384
1461
 
1385
1462
 
1386
1463
  @bp.route("/collections/<uuid:collectionId>/items/<uuid:itemId>", methods=["DELETE"])