geovisio 2.6.0__py3-none-any.whl → 2.7.1__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 (62) hide show
  1. geovisio/__init__.py +36 -7
  2. geovisio/admin_cli/cleanup.py +2 -2
  3. geovisio/admin_cli/db.py +1 -4
  4. geovisio/config_app.py +40 -1
  5. geovisio/db_migrations.py +24 -3
  6. geovisio/templates/main.html +13 -13
  7. geovisio/templates/viewer.html +3 -3
  8. geovisio/translations/de/LC_MESSAGES/messages.mo +0 -0
  9. geovisio/translations/de/LC_MESSAGES/messages.po +804 -0
  10. geovisio/translations/el/LC_MESSAGES/messages.mo +0 -0
  11. geovisio/translations/el/LC_MESSAGES/messages.po +685 -0
  12. geovisio/translations/en/LC_MESSAGES/messages.mo +0 -0
  13. geovisio/translations/en/LC_MESSAGES/messages.po +738 -0
  14. geovisio/translations/es/LC_MESSAGES/messages.mo +0 -0
  15. geovisio/translations/es/LC_MESSAGES/messages.po +778 -0
  16. geovisio/translations/fi/LC_MESSAGES/messages.mo +0 -0
  17. geovisio/translations/fi/LC_MESSAGES/messages.po +589 -0
  18. geovisio/translations/fr/LC_MESSAGES/messages.mo +0 -0
  19. geovisio/translations/fr/LC_MESSAGES/messages.po +814 -0
  20. geovisio/translations/hu/LC_MESSAGES/messages.mo +0 -0
  21. geovisio/translations/hu/LC_MESSAGES/messages.po +773 -0
  22. geovisio/translations/ko/LC_MESSAGES/messages.mo +0 -0
  23. geovisio/translations/ko/LC_MESSAGES/messages.po +685 -0
  24. geovisio/translations/messages.pot +694 -0
  25. geovisio/translations/nl/LC_MESSAGES/messages.mo +0 -0
  26. geovisio/translations/nl/LC_MESSAGES/messages.po +602 -0
  27. geovisio/utils/__init__.py +1 -1
  28. geovisio/utils/auth.py +50 -11
  29. geovisio/utils/db.py +65 -0
  30. geovisio/utils/excluded_areas.py +83 -0
  31. geovisio/utils/extent.py +30 -0
  32. geovisio/utils/fields.py +1 -1
  33. geovisio/utils/filesystems.py +0 -1
  34. geovisio/utils/link.py +14 -0
  35. geovisio/utils/params.py +20 -0
  36. geovisio/utils/pictures.py +110 -88
  37. geovisio/utils/reports.py +171 -0
  38. geovisio/utils/sequences.py +262 -126
  39. geovisio/utils/tokens.py +37 -42
  40. geovisio/utils/upload_set.py +642 -0
  41. geovisio/web/auth.py +37 -37
  42. geovisio/web/collections.py +304 -304
  43. geovisio/web/configuration.py +14 -0
  44. geovisio/web/docs.py +276 -15
  45. geovisio/web/excluded_areas.py +377 -0
  46. geovisio/web/items.py +169 -112
  47. geovisio/web/map.py +104 -36
  48. geovisio/web/params.py +69 -26
  49. geovisio/web/pictures.py +14 -31
  50. geovisio/web/reports.py +399 -0
  51. geovisio/web/rss.py +13 -7
  52. geovisio/web/stac.py +129 -134
  53. geovisio/web/tokens.py +98 -109
  54. geovisio/web/upload_set.py +771 -0
  55. geovisio/web/users.py +100 -73
  56. geovisio/web/utils.py +28 -9
  57. geovisio/workers/runner_pictures.py +241 -207
  58. {geovisio-2.6.0.dist-info → geovisio-2.7.1.dist-info}/METADATA +17 -14
  59. geovisio-2.7.1.dist-info/RECORD +70 -0
  60. {geovisio-2.6.0.dist-info → geovisio-2.7.1.dist-info}/WHEEL +1 -1
  61. geovisio-2.6.0.dist-info/RECORD +0 -41
  62. {geovisio-2.6.0.dist-info → geovisio-2.7.1.dist-info}/LICENSE +0 -0
geovisio/web/items.py CHANGED
@@ -7,7 +7,7 @@ from psycopg.types.json import Jsonb
7
7
  from werkzeug.datastructures import MultiDict
8
8
  from uuid import UUID
9
9
  from geovisio import errors, utils
10
- from geovisio.utils import auth
10
+ from geovisio.utils import auth, db
11
11
  from geovisio.utils.pictures import cleanupExif
12
12
  from geovisio.web.params import (
13
13
  as_latitude,
@@ -21,8 +21,7 @@ from geovisio.web.params import (
21
21
  parse_distance_range,
22
22
  )
23
23
  from geovisio.utils.fields import Bounds
24
-
25
- import psycopg
24
+ import hashlib
26
25
  from psycopg.rows import dict_row
27
26
  from psycopg.sql import SQL
28
27
  from geovisio.web.utils import (
@@ -36,9 +35,11 @@ from geovisio.web.utils import (
36
35
  STAC_VERSION,
37
36
  )
38
37
  from flask import current_app, request, url_for, Blueprint
38
+ from flask_babel import gettext as _, get_locale
39
39
  from geopic_tag_reader.writer import writePictureMetadata, PictureMetadata
40
+ import sentry_sdk
41
+ import math
40
42
 
41
- from geovisio.workers import runner_pictures
42
43
 
43
44
  bp = Blueprint("stac_items", __name__, url_prefix="/api")
44
45
 
@@ -59,6 +60,27 @@ def dbPictureToStacItem(seqId, dbPic):
59
60
  The equivalent in STAC Item format
60
61
  """
61
62
 
63
+ sensorDim = None
64
+ visibleArea = None
65
+ if dbPic["metadata"].get("crop") is not None:
66
+ sensorDim = [dbPic["metadata"]["crop"].get("fullWidth"), dbPic["metadata"]["crop"].get("fullHeight")]
67
+ visibleArea = [
68
+ dbPic["metadata"]["crop"].get("left"),
69
+ dbPic["metadata"]["crop"].get("top"),
70
+ int(dbPic["metadata"]["crop"].get("fullWidth", "0"))
71
+ - int(dbPic["metadata"]["crop"].get("width", "0"))
72
+ - int(dbPic["metadata"]["crop"].get("left", "0")),
73
+ int(dbPic["metadata"]["crop"].get("fullHeight", "0"))
74
+ - int(dbPic["metadata"]["crop"].get("height", "0"))
75
+ - int(dbPic["metadata"]["crop"].get("top", "0")),
76
+ ]
77
+ if None in sensorDim:
78
+ sensorDim = None
79
+ if None in visibleArea or visibleArea == [0, 0, 0, 0]:
80
+ visibleArea = None
81
+ elif "height" in dbPic["metadata"] and "width" in dbPic["metadata"]:
82
+ sensorDim = [dbPic["metadata"]["width"], dbPic["metadata"]["height"]]
83
+
62
84
  item = removeNoneInDict(
63
85
  {
64
86
  "type": "Feature",
@@ -72,7 +94,7 @@ def dbPictureToStacItem(seqId, dbPic):
72
94
  "bbox": dbPic["geojson"]["coordinates"] + dbPic["geojson"]["coordinates"],
73
95
  "providers": cleanNoneInList(
74
96
  [
75
- {"name": dbPic["account_name"], "roles": ["producer"]},
97
+ {"name": dbPic["account_name"], "roles": ["producer"], "id": str(dbPic["account_id"])},
76
98
  (
77
99
  {"name": dbPic["exif"]["Exif.Image.Artist"], "roles": ["producer"]}
78
100
  if dbPic["exif"].get("Exif.Image.Artist") is not None
@@ -95,10 +117,16 @@ def dbPictureToStacItem(seqId, dbPic):
95
117
  "camera_model": dbPic["metadata"].get("model"),
96
118
  "focal_length": dbPic["metadata"].get("focal_length"),
97
119
  "field_of_view": dbPic["metadata"].get("field_of_view"),
120
+ "sensor_array_dimensions": sensorDim,
121
+ "visible_area": visibleArea,
98
122
  }
99
123
  )
100
124
  if "metadata" in dbPic
101
- and any(True for f in dbPic["metadata"] if f in ["make", "model", "focal_length", "field_of_view"])
125
+ and any(
126
+ True
127
+ for f in dbPic["metadata"]
128
+ if f in ["make", "model", "focal_length", "field_of_view", "crop", "width", "height"]
129
+ )
102
130
  else {}
103
131
  ),
104
132
  "pers:pitch": dbPic["metadata"].get("pitch"),
@@ -107,9 +135,11 @@ def dbPictureToStacItem(seqId, dbPic):
107
135
  "geovisio:producer": dbPic["account_name"],
108
136
  "original_file:size": dbPic["metadata"].get("originalFileSize"),
109
137
  "original_file:name": dbPic["metadata"].get("originalFileName"),
138
+ "panoramax:horizontal_pixel_density": dbPic.get("h_pixel_density"),
110
139
  "geovisio:image": _getHDJpgPictureURL(dbPic["id"], dbPic.get("status")),
111
140
  "geovisio:thumbnail": _getThumbJpgPictureURL(dbPic["id"], dbPic.get("status")),
112
141
  "exif": removeNoneInDict(cleanupExif(dbPic["exif"])),
142
+ "quality:horizontal_accuracy": float("{:.1f}".format(dbPic["gps_accuracy_m"])) if dbPic.get("gps_accuracy_m") else None,
113
143
  }
114
144
  ),
115
145
  "links": cleanNoneInList(
@@ -330,31 +360,31 @@ def getCollectionItems(collectionId):
330
360
  try:
331
361
  limit = int(limit)
332
362
  if limit < 1 or limit > 10000:
333
- raise errors.InvalidAPIUsage("limit parameter should be an integer between 1 and 10000", status_code=400)
363
+ raise errors.InvalidAPIUsage(_("limit parameter should be an integer between 1 and 10000"), status_code=400)
334
364
  except ValueError:
335
- raise errors.InvalidAPIUsage("limit parameter should be a valid, positive integer (between 1 and 10000)", status_code=400)
365
+ raise errors.InvalidAPIUsage(_("limit parameter should be a valid, positive integer (between 1 and 10000)"), status_code=400)
336
366
  sql_limit = SQL("LIMIT %(limit)s")
337
367
  params["limit"] = limit
338
368
 
339
369
  if withPicture and startAfterRank:
340
- raise errors.InvalidAPIUsage(f"`startAfterRank` and `withPicture` are mutually exclusive parameters")
370
+ raise errors.InvalidAPIUsage(_("`startAfterRank` and `withPicture` are mutually exclusive parameters"))
341
371
 
342
372
  # Check if rank is valid
343
373
  if startAfterRank is not None:
344
374
  try:
345
375
  startAfterRank = int(startAfterRank)
346
376
  if startAfterRank < 1:
347
- raise errors.InvalidAPIUsage("startAfterRank parameter should be a positive integer (starting from 1)", status_code=400)
377
+ raise errors.InvalidAPIUsage(_("startAfterRank parameter should be a positive integer (starting from 1)"), status_code=400)
348
378
  except ValueError:
349
- raise errors.InvalidAPIUsage("startAfterRank parameter should be a valid, positive integer", status_code=400)
379
+ raise errors.InvalidAPIUsage(_("startAfterRank parameter should be a valid, positive integer"), status_code=400)
350
380
 
351
381
  filters.append(SQL("rank > %(start_after_rank)s"))
352
382
  params["start_after_rank"] = startAfterRank
353
383
 
354
384
  paginated = startAfterRank is not None or limit is not None or withPicture is not None
355
385
 
356
- with psycopg.connect(current_app.config["DB_URL"], row_factory=dict_row) as conn:
357
- with conn.cursor() as cursor:
386
+ with current_app.pool.connection() as conn:
387
+ with conn.cursor(row_factory=dict_row) as cursor:
358
388
  # check on sequence
359
389
  seqMeta = cursor.execute(
360
390
  "SELECT s.id "
@@ -367,12 +397,14 @@ def getCollectionItems(collectionId):
367
397
  ).fetchone()
368
398
 
369
399
  if seqMeta is None:
370
- raise errors.InvalidAPIUsage("Collection doesn't exist", status_code=404)
400
+ raise errors.InvalidAPIUsage(_("Collection doesn't exist"), status_code=404)
371
401
 
372
402
  maxRank = seqMeta.get("max_rank")
373
403
 
374
404
  if startAfterRank is not None and startAfterRank >= maxRank:
375
- raise errors.InvalidAPIUsage(f"No more items in this collection (last available rank is {maxRank})", status_code=404)
405
+ raise errors.InvalidAPIUsage(
406
+ _("No more items in this collection (last available rank is %(r)s)", r=maxRank), status_code=404
407
+ )
376
408
 
377
409
  if withPicture is not None:
378
410
  withPicture = as_uuid(withPicture, "withPicture should be a valid UUID")
@@ -381,7 +413,7 @@ def getCollectionItems(collectionId):
381
413
  params={"id": withPicture, "seq": collectionId},
382
414
  ).fetchone()
383
415
  if not pic:
384
- raise errors.InvalidAPIUsage(f"Picture with id {withPicture} does not exists")
416
+ raise errors.InvalidAPIUsage(_("Picture with id %(p)s does not exists", p=withPicture))
385
417
  rank = get_first_rank_of_page(pic["rank"], limit)
386
418
 
387
419
  filters.append(SQL("rank >= %(start_after_rank)s"))
@@ -393,7 +425,8 @@ def getCollectionItems(collectionId):
393
425
  p.id, p.ts, p.heading, p.metadata, p.inserted_at, p.status,
394
426
  ST_AsGeoJSON(p.geom)::json AS geojson,
395
427
  a.name AS account_name,
396
- sp.rank, p.exif,
428
+ p.account_id AS account_id,
429
+ sp.rank, p.exif, p.gps_accuracy_m, p.h_pixel_density,
397
430
  CASE WHEN LAG(p.status) OVER othpics = 'ready' THEN LAG(p.id) OVER othpics END AS prevpic,
398
431
  CASE WHEN LAG(p.status) OVER othpics = 'ready' THEN ST_AsGeoJSON(LAG(p.geom) OVER othpics)::json END AS prevpicgeojson,
399
432
  CASE WHEN LEAD(p.status) OVER othpics = 'ready' THEN LEAD(p.id) OVER othpics END AS nextpic,
@@ -545,8 +578,8 @@ def _getPictureItemById(collectionId, itemId):
545
578
  schema:
546
579
  type: string
547
580
  """
548
- with psycopg.connect(current_app.config["DB_URL"], row_factory=dict_row) as conn:
549
- with conn.cursor() as cursor:
581
+ with current_app.pool.connection() as conn:
582
+ with conn.cursor(row_factory=dict_row) as cursor:
550
583
  # Check if there is a logged user
551
584
  account = auth.get_current_account()
552
585
  accountId = account.id if account else None
@@ -557,8 +590,9 @@ def _getPictureItemById(collectionId, itemId):
557
590
  SELECT
558
591
  p.id, sp.rank, ST_AsGeoJSON(p.geom)::json AS geojson, p.heading, p.ts, p.metadata,
559
592
  p.inserted_at, p.status, accounts.name AS account_name,
593
+ p.account_id AS account_id,
560
594
  spl.prevpic, spl.prevpicgeojson, spl.nextpic, spl.nextpicgeojson, p.exif,
561
- relp.related_pics
595
+ relp.related_pics, p.gps_accuracy_m, p.h_pixel_density
562
596
  FROM pictures p
563
597
  JOIN sequences_pictures sp ON sp.pic_id = p.id
564
598
  JOIN accounts ON p.account_id = accounts.id
@@ -665,7 +699,7 @@ def getCollectionItem(collectionId, itemId):
665
699
 
666
700
  stacItem = _getPictureItemById(collectionId, itemId)
667
701
  if stacItem is None:
668
- raise errors.InvalidAPIUsage("Item doesn't exist", status_code=404)
702
+ raise errors.InvalidAPIUsage(_("Item doesn't exist"), status_code=404)
669
703
 
670
704
  account = auth.get_current_account()
671
705
  picStatusToHttpCode = {
@@ -724,7 +758,7 @@ def searchItems():
724
758
  args: MultiDict[str, str]
725
759
  if request.method == "POST":
726
760
  if request.headers.get("Content-Type") != "application/json":
727
- raise errors.InvalidAPIUsage("Search using POST method should have a JSON body", status_code=400)
761
+ raise errors.InvalidAPIUsage(_("Search using POST method should have a JSON body"), status_code=400)
728
762
  args = MultiDict(request.json)
729
763
  else:
730
764
  args = request.args
@@ -733,7 +767,7 @@ def searchItems():
733
767
  if args.get("limit") is not None:
734
768
  limit = args.get("limit", type=int)
735
769
  if limit is None or limit < 1 or limit > 10000:
736
- raise errors.InvalidAPIUsage("Parameter limit must be either empty or a number between 1 and 10000", status_code=400)
770
+ raise errors.InvalidAPIUsage(_("Parameter limit must be either empty or a number between 1 and 10000"), status_code=400)
737
771
  else:
738
772
  sqlParams["limit"] = limit
739
773
  else:
@@ -789,7 +823,7 @@ def searchItems():
789
823
  place_fov_tolerance = args.get("place_fov_tolerance", type=int, default=30)
790
824
  if place_fov_tolerance < 2 or place_fov_tolerance > 180:
791
825
  raise errors.InvalidAPIUsage(
792
- "Parameter place_fov_tolerance must be either empty or a number between 2 and 180", status_code=400
826
+ _("Parameter place_fov_tolerance must be either empty or a number between 2 and 180"), status_code=400
793
827
  )
794
828
  else:
795
829
  sqlParams["placefov"] = place_fov_tolerance / 2
@@ -811,7 +845,7 @@ def searchItems():
811
845
  try:
812
846
  intersects = json.loads(args["intersects"])
813
847
  except:
814
- raise errors.InvalidAPIUsage("Parameter intersects should contain a valid GeoJSON Geometry (not a Feature)", status_code=400)
848
+ raise errors.InvalidAPIUsage(_("Parameter intersects should contain a valid GeoJSON Geometry (not a Feature)"), status_code=400)
815
849
  if intersects["type"] == "Point":
816
850
  sqlWhere.append(SQL("p.geom && ST_Expand(ST_GeomFromGeoJSON(%(geom)s), 0.000001)"))
817
851
  else:
@@ -827,7 +861,7 @@ def searchItems():
827
861
  try:
828
862
  sqlParams["ids"] = [UUID(j) for j in parse_list(args.get("ids"), paramName="ids")]
829
863
  except:
830
- raise errors.InvalidAPIUsage("Parameter ids should be a JSON array of strings", status_code=400)
864
+ raise errors.InvalidAPIUsage(_("Parameter ids should be a JSON array of strings"), status_code=400)
831
865
 
832
866
  # Collections
833
867
  if args.get("collections") is not None:
@@ -839,7 +873,7 @@ def searchItems():
839
873
  try:
840
874
  sqlParams["collections"] = [UUID(j) for j in parse_list(args["collections"], paramName="collections")]
841
875
  except:
842
- raise errors.InvalidAPIUsage("Parameter collections should be a JSON array of strings", status_code=400)
876
+ raise errors.InvalidAPIUsage(_("Parameter collections should be a JSON array of strings"), status_code=400)
843
877
 
844
878
  # 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
845
879
  if args.get("ids") is not None and args:
@@ -847,10 +881,10 @@ def searchItems():
847
881
  if ids and len(ids) == 1:
848
882
  picture_id = ids[0]
849
883
 
850
- with psycopg.connect(current_app.config["DB_URL"]) as conn, conn.cursor() as cursor:
884
+ with current_app.pool.connection() as conn, conn.cursor() as cursor:
851
885
  seq = cursor.execute("SELECT seq_id FROM sequences_pictures WHERE pic_id = %s", [picture_id]).fetchone()
852
886
  if not seq:
853
- raise errors.InvalidAPIUsage("Picture doesn't exist", status_code=404)
887
+ raise errors.InvalidAPIUsage(_("Picture doesn't exist"), status_code=404)
854
888
 
855
889
  item = _getPictureItemById(seq[0], UUID(picture_id))
856
890
  features = [item] if item else []
@@ -863,16 +897,17 @@ def searchItems():
863
897
  #
864
898
  # Database query
865
899
  #
866
- with psycopg.connect(current_app.config["DB_URL"], row_factory=dict_row, options="-c statement_timeout=30000") as conn:
867
- with conn.cursor() as cursor:
868
- query = SQL(
869
- """
900
+ with db.cursor(current_app, timeout=30000, row_factory=dict_row) as cursor:
901
+ query = SQL(
902
+ """
870
903
  SELECT * FROM (
871
904
  SELECT
872
905
  p.id, p.ts, p.heading, p.metadata, p.inserted_at,
873
906
  ST_AsGeoJSON(p.geom)::json AS geojson,
874
907
  sp.seq_id, sp.rank AS rank,
875
- accounts.name AS account_name, p.exif
908
+ accounts.name AS account_name,
909
+ p.account_id AS account_id,
910
+ p.exif, p.gps_accuracy_m, p.h_pixel_density
876
911
  FROM pictures p
877
912
  LEFT JOIN sequences_pictures sp ON p.id = sp.pic_id
878
913
  LEFT JOIN sequences s ON s.id = sp.seq_id
@@ -900,24 +935,24 @@ LEFT JOIN LATERAL (
900
935
  LIMIT 1
901
936
  ) next on true
902
937
  ;
903
- """
904
- ).format(sqlWhere=SQL(" AND ").join(sqlWhere), sqlSubQueryWhere=SQL(" AND ").join(sqlSubQueryWhere), orderBy=order_by)
938
+ """
939
+ ).format(sqlWhere=SQL(" AND ").join(sqlWhere), sqlSubQueryWhere=SQL(" AND ").join(sqlSubQueryWhere), orderBy=order_by)
905
940
 
906
- records = cursor.execute(query, sqlParams)
941
+ records = cursor.execute(query, sqlParams)
907
942
 
908
- items = [dbPictureToStacItem(str(dbPic["seq_id"]), dbPic) for dbPic in records]
943
+ items = [dbPictureToStacItem(str(dbPic["seq_id"]), dbPic) for dbPic in records]
909
944
 
910
- return (
911
- {
912
- "type": "FeatureCollection",
913
- "features": items,
914
- "links": [
915
- get_root_link(),
916
- ],
917
- },
918
- 200,
919
- {"Content-Type": "application/geo+json"},
920
- )
945
+ return (
946
+ {
947
+ "type": "FeatureCollection",
948
+ "features": items,
949
+ "links": [
950
+ get_root_link(),
951
+ ],
952
+ },
953
+ 200,
954
+ {"Content-Type": "application/geo+json"},
955
+ )
921
956
 
922
957
 
923
958
  @bp.route("/collections/<uuid:collectionId>/items", methods=["POST"])
@@ -952,18 +987,18 @@ def postCollectionItem(collectionId, account=None):
952
987
  """
953
988
 
954
989
  if not request.headers.get("Content-Type", "").startswith("multipart/form-data"):
955
- raise errors.InvalidAPIUsage("Content type should be multipart/form-data", status_code=415)
990
+ raise errors.InvalidAPIUsage(_("Content type should be multipart/form-data"), status_code=415)
956
991
 
957
992
  # Check if position was given
958
993
  if request.form.get("position") is None:
959
- raise errors.InvalidAPIUsage('Missing "position" parameter', status_code=400)
994
+ raise errors.InvalidAPIUsage(_('Missing "position" parameter'), status_code=400)
960
995
  else:
961
996
  try:
962
997
  position = int(request.form["position"])
963
998
  if position <= 0:
964
999
  raise ValueError()
965
1000
  except ValueError:
966
- raise errors.InvalidAPIUsage("Position in sequence should be a positive integer", status_code=400)
1001
+ raise errors.InvalidAPIUsage(_("Position in sequence should be a positive integer"), status_code=400)
967
1002
 
968
1003
  # Check if datetime was given
969
1004
  ext_mtd = PictureMetadata()
@@ -977,11 +1012,11 @@ def postCollectionItem(collectionId, account=None):
977
1012
  lon, lat = request.form.get("override_longitude"), request.form.get("override_latitude")
978
1013
  if lon is not None or lat is not None:
979
1014
  if lat is None:
980
- raise errors.InvalidAPIUsage("Longitude cannot be overridden alone, override_latitude also needs to be set")
1015
+ raise errors.InvalidAPIUsage(_("Longitude cannot be overridden alone, override_latitude also needs to be set"))
981
1016
  if lon is None:
982
- raise errors.InvalidAPIUsage("Latitude cannot be overridden alone, override_longitude also needs to be set")
983
- lon = as_longitude(lon, error=f"For parameter `override_longitude`, `{lon}` is not a valid longitude")
984
- lat = as_latitude(lat, error=f"For parameter `override_latitude`, `{lat}` is not a valid latitude")
1017
+ raise errors.InvalidAPIUsage(_("Latitude cannot be overridden alone, override_longitude also needs to be set"))
1018
+ lon = as_longitude(lon, error=_("For parameter `override_longitude`, `%(v)s` is not a valid longitude", v=lon))
1019
+ lat = as_latitude(lat, error=_("For parameter `override_latitude`, `%(v)s` is not a valid latitude", v=lat))
985
1020
  ext_mtd.longitude = lon
986
1021
  ext_mtd.latitude = lat
987
1022
 
@@ -1000,70 +1035,88 @@ def postCollectionItem(collectionId, account=None):
1000
1035
  if request.form.get("isBlurred") is None or request.form.get("isBlurred") in ["true", "false"]:
1001
1036
  isBlurred = request.form.get("isBlurred") == "true"
1002
1037
  else:
1003
- raise errors.InvalidAPIUsage("Picture blur status should be either unset, true or false", status_code=400)
1038
+ raise errors.InvalidAPIUsage(_("Picture blur status should be either unset, true or false"), status_code=400)
1004
1039
 
1005
1040
  # Check if a picture file was given
1006
1041
  if "picture" not in request.files:
1007
- raise errors.InvalidAPIUsage("No picture file was sent", status_code=400)
1042
+ raise errors.InvalidAPIUsage(_("No picture file was sent"), status_code=400)
1008
1043
  else:
1009
1044
  picture = request.files["picture"]
1010
1045
 
1011
1046
  # Check file validity
1012
1047
  if not (picture.filename != "" and "." in picture.filename and picture.filename.rsplit(".", 1)[1].lower() in ["jpg", "jpeg"]):
1013
- raise errors.InvalidAPIUsage("Picture file is either missing or in an unsupported format (should be jpg)", status_code=400)
1048
+ raise errors.InvalidAPIUsage(_("Picture file is either missing or in an unsupported format (should be jpg)"), status_code=400)
1014
1049
 
1015
- with psycopg.connect(current_app.config["DB_URL"]) as conn:
1016
- with conn.cursor() as cursor:
1050
+ with db.conn(current_app) as conn:
1051
+ with conn.transaction(), conn.cursor() as cursor:
1017
1052
  # Check if sequence exists
1018
- seq = cursor.execute("SELECT id FROM sequences WHERE id = %s", [collectionId]).fetchone()
1019
- if not seq or len(seq) != 1:
1020
- raise errors.InvalidAPIUsage(f"Sequence {collectionId} wasn't found in database", status_code=404)
1053
+ seq = cursor.execute("SELECT account_id, status FROM sequences WHERE id = %s", [collectionId]).fetchone()
1054
+ if not seq:
1055
+ raise errors.InvalidAPIUsage(_("Collection %(s)s wasn't found in database", s=collectionId), status_code=404)
1056
+
1057
+ # Account associated to picture doesn't match current user
1058
+ if account is not None and account.id != str(seq[0]):
1059
+ raise errors.InvalidAPIUsage(_("You're not authorized to add picture to this collection"), status_code=403)
1060
+
1061
+ # Check if sequence has not been deleted
1062
+ status = seq[1]
1063
+ if status == "deleted":
1064
+ raise errors.InvalidAPIUsage(_("The collection has been deleted, impossible to add pictures to it"), status_code=404)
1021
1065
 
1022
1066
  # Compute various metadata
1023
1067
  accountId = accountIdOrDefault(account)
1024
1068
  raw_pic = picture.read()
1025
1069
  filesize = len(raw_pic)
1026
1070
 
1071
+ with sentry_sdk.start_span(description="computing md5"):
1072
+ # we save the content hash md5 as uuid since md5 is 128bit and uuid are efficiently handled in postgres
1073
+ md5 = hashlib.md5(raw_pic).digest()
1074
+ md5 = UUID(bytes=md5)
1075
+
1027
1076
  additionalMetadata = {
1028
1077
  "blurredByAuthor": isBlurred,
1029
1078
  "originalFileName": os.path.basename(picture.filename),
1030
1079
  "originalFileSize": filesize,
1080
+ "originalContentMd5": md5,
1031
1081
  }
1032
1082
 
1033
1083
  # Update picture metadata if needed
1034
- updated_picture = writePictureMetadata(raw_pic, ext_mtd)
1084
+ with sentry_sdk.start_span(description="overwriting metadata"):
1085
+ updated_picture = writePictureMetadata(raw_pic, ext_mtd)
1035
1086
 
1036
1087
  # Insert picture into database
1037
- try:
1038
- picId = utils.pictures.insertNewPictureInDatabase(
1039
- conn, collectionId, position, updated_picture, accountId, additionalMetadata
1040
- )
1041
- except utils.pictures.PicturePositionConflict:
1042
- raise errors.InvalidAPIUsage("Picture at given position already exist", status_code=409)
1043
- except utils.pictures.MetadataReadingError as e:
1044
- raise errors.InvalidAPIUsage("Impossible to parse picture metadata", payload={"details": {"error": e.details}})
1088
+ with sentry_sdk.start_span(description="Insert picture in db"):
1089
+ try:
1090
+ picId = utils.pictures.insertNewPictureInDatabase(
1091
+ conn, collectionId, position, updated_picture, accountId, additionalMetadata, lang=get_locale().language
1092
+ )
1093
+ except utils.pictures.PicturePositionConflict:
1094
+ raise errors.InvalidAPIUsage(_("Picture at given position already exist"), status_code=409)
1095
+ except utils.pictures.MetadataReadingError as e:
1096
+ raise errors.InvalidAPIUsage(_("Impossible to parse picture metadata"), payload={"details": {"error": e.details}})
1097
+ except utils.pictures.InvalidMetadataValue as e:
1098
+ raise errors.InvalidAPIUsage(_("Picture has invalid metadata"), payload={"details": {"error": e.details}})
1045
1099
 
1046
1100
  # Save file into appropriate filesystem
1047
- try:
1048
- utils.pictures.saveRawPicture(picId, updated_picture, isBlurred)
1049
- except:
1050
- logging.exception("Picture wasn't correctly saved in filesystem")
1051
- raise errors.InvalidAPIUsage("Picture wasn't correctly saved in filesystem", status_code=500)
1052
-
1053
- conn.commit()
1054
-
1055
- runner_pictures.background_processor.process_pictures()
1056
-
1057
- # Return picture metadata
1058
- return (
1059
- getCollectionItem(collectionId, picId)[0],
1060
- 202,
1061
- {
1062
- "Content-Type": "application/json",
1063
- "Access-Control-Expose-Headers": "Location", # Needed for allowing web browsers access Location header
1064
- "Location": url_for("stac_items.getCollectionItem", _external=True, collectionId=collectionId, itemId=picId),
1065
- },
1066
- )
1101
+ with sentry_sdk.start_span(description="Saving picture"):
1102
+ try:
1103
+ utils.pictures.saveRawPicture(picId, updated_picture, isBlurred)
1104
+ except:
1105
+ logging.exception("Picture wasn't correctly saved in filesystem")
1106
+ raise errors.InvalidAPIUsage(_("Picture wasn't correctly saved in filesystem"), status_code=500)
1107
+
1108
+ current_app.background_processor.process_pictures()
1109
+
1110
+ # Return picture metadata
1111
+ return (
1112
+ getCollectionItem(collectionId, picId)[0],
1113
+ 202,
1114
+ {
1115
+ "Content-Type": "application/json",
1116
+ "Access-Control-Expose-Headers": "Location", # Needed for allowing web browsers access Location header
1117
+ "Location": url_for("stac_items.getCollectionItem", _external=True, collectionId=collectionId, itemId=picId),
1118
+ },
1119
+ )
1067
1120
 
1068
1121
 
1069
1122
  @bp.route("/collections/<uuid:collectionId>/items/<uuid:itemId>", methods=["PATCH"])
@@ -1121,7 +1174,7 @@ def patchCollectionItem(collectionId, itemId, account):
1121
1174
  visible = metadata.get("visible")
1122
1175
  if visible is not None:
1123
1176
  if visible not in ["true", "false"]:
1124
- raise errors.InvalidAPIUsage("Picture visibility parameter (visible) should be either unset, true or false", status_code=400)
1177
+ raise errors.InvalidAPIUsage(_("Picture visibility parameter (visible) should be either unset, true or false"), status_code=400)
1125
1178
  visible = visible == "true"
1126
1179
 
1127
1180
  # Check if heading is valid
@@ -1133,7 +1186,9 @@ def patchCollectionItem(collectionId, itemId, account):
1133
1186
  raise ValueError()
1134
1187
  except ValueError:
1135
1188
  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°.",
1189
+ _(
1190
+ "Heading is not valid, should be an integer in degrees from 0° to 360°. North is 0°, East = 90°, South = 180° and West = 270°."
1191
+ ),
1137
1192
  status_code=400,
1138
1193
  )
1139
1194
 
@@ -1142,17 +1197,17 @@ def patchCollectionItem(collectionId, itemId, account):
1142
1197
  return getCollectionItem(collectionId, itemId)
1143
1198
 
1144
1199
  # Check if picture exists and if given account is authorized to edit
1145
- with psycopg.connect(current_app.config["DB_URL"], row_factory=dict_row) as conn:
1146
- with conn.cursor() as cursor:
1200
+ with db.conn(current_app) as conn:
1201
+ with conn.transaction(), conn.cursor(row_factory=dict_row) as cursor:
1147
1202
  pic = cursor.execute("SELECT status, account_id FROM pictures WHERE id = %s", [itemId]).fetchone()
1148
1203
 
1149
1204
  # Picture not found
1150
1205
  if not pic:
1151
- raise errors.InvalidAPIUsage(f"Picture {itemId} wasn't found in database", status_code=404)
1206
+ raise errors.InvalidAPIUsage(_("Picture %(p)s wasn't found in database", p=itemId), status_code=404)
1152
1207
 
1153
1208
  # Account associated to picture doesn't match current user
1154
1209
  if account is not None and account.id != str(pic["account_id"]):
1155
- raise errors.InvalidAPIUsage("You're not authorized to edit this picture", status_code=403)
1210
+ raise errors.InvalidAPIUsage(_("You're not authorized to edit this picture"), status_code=403)
1156
1211
 
1157
1212
  sqlUpdates = []
1158
1213
  sqlParams = {"id": itemId, "account": account.id}
@@ -1162,7 +1217,12 @@ def patchCollectionItem(collectionId, itemId, account):
1162
1217
  if oldStatus not in ["ready", "hidden"]:
1163
1218
  # Picture is in a preparing/broken/... state so no edit possible
1164
1219
  raise errors.InvalidAPIUsage(
1165
- f"Picture {itemId} is in {oldStatus} state, its visibility can't be changed for now", status_code=400
1220
+ _(
1221
+ "Picture %(p)s is in %(s)s state, its visibility can't be changed for now",
1222
+ p=itemId,
1223
+ s=oldStatus,
1224
+ ),
1225
+ status_code=400,
1166
1226
  )
1167
1227
 
1168
1228
  newStatus = None
@@ -1194,10 +1254,9 @@ def patchCollectionItem(collectionId, itemId, account):
1194
1254
  ).format(updates=SQL(", ").join(sqlUpdates)),
1195
1255
  sqlParams,
1196
1256
  )
1197
- conn.commit()
1198
1257
 
1199
- # Redirect response to a classic GET
1200
- return getCollectionItem(collectionId, itemId)
1258
+ # Redirect response to a classic GET
1259
+ return getCollectionItem(collectionId, itemId)
1201
1260
 
1202
1261
 
1203
1262
  @bp.route("/collections/<uuid:collectionId>/items/<uuid:itemId>", methods=["DELETE"])
@@ -1229,26 +1288,24 @@ def deleteCollectionItem(collectionId, itemId, account):
1229
1288
  """
1230
1289
 
1231
1290
  # Check if picture exists and if given account is authorized to edit
1232
- with psycopg.connect(current_app.config["DB_URL"]) as conn:
1233
- with conn.cursor() as cursor:
1291
+ with db.conn(current_app) as conn:
1292
+ with conn.transaction(), conn.cursor() as cursor:
1234
1293
  pic = cursor.execute("SELECT status, account_id FROM pictures WHERE id = %s", [itemId]).fetchone()
1235
1294
 
1236
1295
  # Picture not found
1237
1296
  if not pic:
1238
- raise errors.InvalidAPIUsage(f"Picture {itemId} wasn't found in database", status_code=404)
1297
+ raise errors.InvalidAPIUsage(_("Picture %(p)s wasn't found in database", p=itemId), status_code=404)
1239
1298
 
1240
1299
  # Account associated to picture doesn't match current user
1241
1300
  if account is not None and account.id != str(pic[1]):
1242
- raise errors.InvalidAPIUsage("You're not authorized to edit this picture", status_code=403)
1301
+ raise errors.InvalidAPIUsage(_("You're not authorized to edit this picture"), status_code=403)
1243
1302
 
1244
1303
  cursor.execute("DELETE FROM pictures WHERE id = %s", [itemId])
1245
1304
 
1246
- # delete images
1247
- utils.pictures.removeAllFiles(itemId)
1248
-
1249
- conn.commit()
1305
+ # let the picture be removed from the filesystem by the asynchronous workers
1306
+ current_app.background_processor.process_pictures()
1250
1307
 
1251
- return "", 204
1308
+ return "", 204
1252
1309
 
1253
1310
 
1254
1311
  def _getHDJpgPictureURL(picId: str, status: Optional[str]):