geovisio 2.6.0__py3-none-any.whl → 2.7.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 (57) hide show
  1. geovisio/__init__.py +36 -7
  2. geovisio/admin_cli/db.py +1 -4
  3. geovisio/config_app.py +40 -1
  4. geovisio/db_migrations.py +24 -3
  5. geovisio/templates/main.html +13 -13
  6. geovisio/templates/viewer.html +3 -3
  7. geovisio/translations/de/LC_MESSAGES/messages.mo +0 -0
  8. geovisio/translations/de/LC_MESSAGES/messages.po +667 -0
  9. geovisio/translations/en/LC_MESSAGES/messages.mo +0 -0
  10. geovisio/translations/en/LC_MESSAGES/messages.po +730 -0
  11. geovisio/translations/es/LC_MESSAGES/messages.mo +0 -0
  12. geovisio/translations/es/LC_MESSAGES/messages.po +778 -0
  13. geovisio/translations/fi/LC_MESSAGES/messages.mo +0 -0
  14. geovisio/translations/fi/LC_MESSAGES/messages.po +589 -0
  15. geovisio/translations/fr/LC_MESSAGES/messages.mo +0 -0
  16. geovisio/translations/fr/LC_MESSAGES/messages.po +814 -0
  17. geovisio/translations/ko/LC_MESSAGES/messages.mo +0 -0
  18. geovisio/translations/ko/LC_MESSAGES/messages.po +685 -0
  19. geovisio/translations/messages.pot +686 -0
  20. geovisio/translations/nl/LC_MESSAGES/messages.mo +0 -0
  21. geovisio/translations/nl/LC_MESSAGES/messages.po +594 -0
  22. geovisio/utils/__init__.py +1 -1
  23. geovisio/utils/auth.py +50 -11
  24. geovisio/utils/db.py +65 -0
  25. geovisio/utils/excluded_areas.py +83 -0
  26. geovisio/utils/extent.py +30 -0
  27. geovisio/utils/fields.py +1 -1
  28. geovisio/utils/filesystems.py +0 -1
  29. geovisio/utils/link.py +14 -0
  30. geovisio/utils/params.py +20 -0
  31. geovisio/utils/pictures.py +92 -68
  32. geovisio/utils/reports.py +171 -0
  33. geovisio/utils/sequences.py +264 -126
  34. geovisio/utils/tokens.py +37 -42
  35. geovisio/utils/upload_set.py +654 -0
  36. geovisio/web/auth.py +37 -37
  37. geovisio/web/collections.py +286 -302
  38. geovisio/web/configuration.py +14 -0
  39. geovisio/web/docs.py +241 -14
  40. geovisio/web/excluded_areas.py +377 -0
  41. geovisio/web/items.py +156 -108
  42. geovisio/web/map.py +20 -20
  43. geovisio/web/params.py +69 -26
  44. geovisio/web/pictures.py +14 -31
  45. geovisio/web/reports.py +399 -0
  46. geovisio/web/rss.py +13 -7
  47. geovisio/web/stac.py +129 -134
  48. geovisio/web/tokens.py +98 -109
  49. geovisio/web/upload_set.py +768 -0
  50. geovisio/web/users.py +100 -73
  51. geovisio/web/utils.py +28 -9
  52. geovisio/workers/runner_pictures.py +252 -204
  53. {geovisio-2.6.0.dist-info → geovisio-2.7.0.dist-info}/METADATA +16 -13
  54. geovisio-2.7.0.dist-info/RECORD +66 -0
  55. geovisio-2.6.0.dist-info/RECORD +0 -41
  56. {geovisio-2.6.0.dist-info → geovisio-2.7.0.dist-info}/LICENSE +0 -0
  57. {geovisio-2.6.0.dist-info → geovisio-2.7.0.dist-info}/WHEEL +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,10 @@ 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
40
41
 
41
- from geovisio.workers import runner_pictures
42
42
 
43
43
  bp = Blueprint("stac_items", __name__, url_prefix="/api")
44
44
 
@@ -59,6 +59,25 @@ def dbPictureToStacItem(seqId, dbPic):
59
59
  The equivalent in STAC Item format
60
60
  """
61
61
 
62
+ sensorDim = None
63
+ visibleArea = None
64
+ if dbPic["metadata"].get("crop") is not None:
65
+ sensorDim = [dbPic["metadata"]["crop"].get("fullWidth"), dbPic["metadata"]["crop"].get("fullHeight")]
66
+ visibleArea = [
67
+ dbPic["metadata"]["crop"].get("left"),
68
+ dbPic["metadata"]["crop"].get("top"),
69
+ int(dbPic["metadata"]["crop"].get("fullWidth", "0"))
70
+ - int(dbPic["metadata"]["crop"].get("width", "0"))
71
+ - int(dbPic["metadata"]["crop"].get("left", "0")),
72
+ int(dbPic["metadata"]["crop"].get("fullHeight", "0"))
73
+ - int(dbPic["metadata"]["crop"].get("height", "0"))
74
+ - int(dbPic["metadata"]["crop"].get("top", "0")),
75
+ ]
76
+ if None in sensorDim:
77
+ sensorDim = None
78
+ if None in visibleArea or visibleArea == [0, 0, 0, 0]:
79
+ visibleArea = None
80
+
62
81
  item = removeNoneInDict(
63
82
  {
64
83
  "type": "Feature",
@@ -72,7 +91,7 @@ def dbPictureToStacItem(seqId, dbPic):
72
91
  "bbox": dbPic["geojson"]["coordinates"] + dbPic["geojson"]["coordinates"],
73
92
  "providers": cleanNoneInList(
74
93
  [
75
- {"name": dbPic["account_name"], "roles": ["producer"]},
94
+ {"name": dbPic["account_name"], "roles": ["producer"], "id": str(dbPic["account_id"])},
76
95
  (
77
96
  {"name": dbPic["exif"]["Exif.Image.Artist"], "roles": ["producer"]}
78
97
  if dbPic["exif"].get("Exif.Image.Artist") is not None
@@ -95,10 +114,12 @@ def dbPictureToStacItem(seqId, dbPic):
95
114
  "camera_model": dbPic["metadata"].get("model"),
96
115
  "focal_length": dbPic["metadata"].get("focal_length"),
97
116
  "field_of_view": dbPic["metadata"].get("field_of_view"),
117
+ "sensor_array_dimensions": sensorDim,
118
+ "visible_area": visibleArea,
98
119
  }
99
120
  )
100
121
  if "metadata" in dbPic
101
- and any(True for f in dbPic["metadata"] if f in ["make", "model", "focal_length", "field_of_view"])
122
+ and any(True for f in dbPic["metadata"] if f in ["make", "model", "focal_length", "field_of_view", "crop"])
102
123
  else {}
103
124
  ),
104
125
  "pers:pitch": dbPic["metadata"].get("pitch"),
@@ -330,31 +351,31 @@ def getCollectionItems(collectionId):
330
351
  try:
331
352
  limit = int(limit)
332
353
  if limit < 1 or limit > 10000:
333
- raise errors.InvalidAPIUsage("limit parameter should be an integer between 1 and 10000", status_code=400)
354
+ raise errors.InvalidAPIUsage(_("limit parameter should be an integer between 1 and 10000"), status_code=400)
334
355
  except ValueError:
335
- raise errors.InvalidAPIUsage("limit parameter should be a valid, positive integer (between 1 and 10000)", status_code=400)
356
+ raise errors.InvalidAPIUsage(_("limit parameter should be a valid, positive integer (between 1 and 10000)"), status_code=400)
336
357
  sql_limit = SQL("LIMIT %(limit)s")
337
358
  params["limit"] = limit
338
359
 
339
360
  if withPicture and startAfterRank:
340
- raise errors.InvalidAPIUsage(f"`startAfterRank` and `withPicture` are mutually exclusive parameters")
361
+ raise errors.InvalidAPIUsage(_("`startAfterRank` and `withPicture` are mutually exclusive parameters"))
341
362
 
342
363
  # Check if rank is valid
343
364
  if startAfterRank is not None:
344
365
  try:
345
366
  startAfterRank = int(startAfterRank)
346
367
  if startAfterRank < 1:
347
- raise errors.InvalidAPIUsage("startAfterRank parameter should be a positive integer (starting from 1)", status_code=400)
368
+ raise errors.InvalidAPIUsage(_("startAfterRank parameter should be a positive integer (starting from 1)"), status_code=400)
348
369
  except ValueError:
349
- raise errors.InvalidAPIUsage("startAfterRank parameter should be a valid, positive integer", status_code=400)
370
+ raise errors.InvalidAPIUsage(_("startAfterRank parameter should be a valid, positive integer"), status_code=400)
350
371
 
351
372
  filters.append(SQL("rank > %(start_after_rank)s"))
352
373
  params["start_after_rank"] = startAfterRank
353
374
 
354
375
  paginated = startAfterRank is not None or limit is not None or withPicture is not None
355
376
 
356
- with psycopg.connect(current_app.config["DB_URL"], row_factory=dict_row) as conn:
357
- with conn.cursor() as cursor:
377
+ with current_app.pool.connection() as conn:
378
+ with conn.cursor(row_factory=dict_row) as cursor:
358
379
  # check on sequence
359
380
  seqMeta = cursor.execute(
360
381
  "SELECT s.id "
@@ -367,12 +388,14 @@ def getCollectionItems(collectionId):
367
388
  ).fetchone()
368
389
 
369
390
  if seqMeta is None:
370
- raise errors.InvalidAPIUsage("Collection doesn't exist", status_code=404)
391
+ raise errors.InvalidAPIUsage(_("Collection doesn't exist"), status_code=404)
371
392
 
372
393
  maxRank = seqMeta.get("max_rank")
373
394
 
374
395
  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)
396
+ raise errors.InvalidAPIUsage(
397
+ _("No more items in this collection (last available rank is %(r)s)", r=maxRank), status_code=404
398
+ )
376
399
 
377
400
  if withPicture is not None:
378
401
  withPicture = as_uuid(withPicture, "withPicture should be a valid UUID")
@@ -381,7 +404,7 @@ def getCollectionItems(collectionId):
381
404
  params={"id": withPicture, "seq": collectionId},
382
405
  ).fetchone()
383
406
  if not pic:
384
- raise errors.InvalidAPIUsage(f"Picture with id {withPicture} does not exists")
407
+ raise errors.InvalidAPIUsage(_("Picture with id %(p)s does not exists", p=withPicture))
385
408
  rank = get_first_rank_of_page(pic["rank"], limit)
386
409
 
387
410
  filters.append(SQL("rank >= %(start_after_rank)s"))
@@ -393,6 +416,7 @@ def getCollectionItems(collectionId):
393
416
  p.id, p.ts, p.heading, p.metadata, p.inserted_at, p.status,
394
417
  ST_AsGeoJSON(p.geom)::json AS geojson,
395
418
  a.name AS account_name,
419
+ p.account_id AS account_id,
396
420
  sp.rank, p.exif,
397
421
  CASE WHEN LAG(p.status) OVER othpics = 'ready' THEN LAG(p.id) OVER othpics END AS prevpic,
398
422
  CASE WHEN LAG(p.status) OVER othpics = 'ready' THEN ST_AsGeoJSON(LAG(p.geom) OVER othpics)::json END AS prevpicgeojson,
@@ -545,8 +569,8 @@ def _getPictureItemById(collectionId, itemId):
545
569
  schema:
546
570
  type: string
547
571
  """
548
- with psycopg.connect(current_app.config["DB_URL"], row_factory=dict_row) as conn:
549
- with conn.cursor() as cursor:
572
+ with current_app.pool.connection() as conn:
573
+ with conn.cursor(row_factory=dict_row) as cursor:
550
574
  # Check if there is a logged user
551
575
  account = auth.get_current_account()
552
576
  accountId = account.id if account else None
@@ -557,6 +581,7 @@ def _getPictureItemById(collectionId, itemId):
557
581
  SELECT
558
582
  p.id, sp.rank, ST_AsGeoJSON(p.geom)::json AS geojson, p.heading, p.ts, p.metadata,
559
583
  p.inserted_at, p.status, accounts.name AS account_name,
584
+ p.account_id AS account_id,
560
585
  spl.prevpic, spl.prevpicgeojson, spl.nextpic, spl.nextpicgeojson, p.exif,
561
586
  relp.related_pics
562
587
  FROM pictures p
@@ -665,7 +690,7 @@ def getCollectionItem(collectionId, itemId):
665
690
 
666
691
  stacItem = _getPictureItemById(collectionId, itemId)
667
692
  if stacItem is None:
668
- raise errors.InvalidAPIUsage("Item doesn't exist", status_code=404)
693
+ raise errors.InvalidAPIUsage(_("Item doesn't exist"), status_code=404)
669
694
 
670
695
  account = auth.get_current_account()
671
696
  picStatusToHttpCode = {
@@ -724,7 +749,7 @@ def searchItems():
724
749
  args: MultiDict[str, str]
725
750
  if request.method == "POST":
726
751
  if request.headers.get("Content-Type") != "application/json":
727
- raise errors.InvalidAPIUsage("Search using POST method should have a JSON body", status_code=400)
752
+ raise errors.InvalidAPIUsage(_("Search using POST method should have a JSON body"), status_code=400)
728
753
  args = MultiDict(request.json)
729
754
  else:
730
755
  args = request.args
@@ -733,7 +758,7 @@ def searchItems():
733
758
  if args.get("limit") is not None:
734
759
  limit = args.get("limit", type=int)
735
760
  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)
761
+ raise errors.InvalidAPIUsage(_("Parameter limit must be either empty or a number between 1 and 10000"), status_code=400)
737
762
  else:
738
763
  sqlParams["limit"] = limit
739
764
  else:
@@ -789,7 +814,7 @@ def searchItems():
789
814
  place_fov_tolerance = args.get("place_fov_tolerance", type=int, default=30)
790
815
  if place_fov_tolerance < 2 or place_fov_tolerance > 180:
791
816
  raise errors.InvalidAPIUsage(
792
- "Parameter place_fov_tolerance must be either empty or a number between 2 and 180", status_code=400
817
+ _("Parameter place_fov_tolerance must be either empty or a number between 2 and 180"), status_code=400
793
818
  )
794
819
  else:
795
820
  sqlParams["placefov"] = place_fov_tolerance / 2
@@ -811,7 +836,7 @@ def searchItems():
811
836
  try:
812
837
  intersects = json.loads(args["intersects"])
813
838
  except:
814
- raise errors.InvalidAPIUsage("Parameter intersects should contain a valid GeoJSON Geometry (not a Feature)", status_code=400)
839
+ raise errors.InvalidAPIUsage(_("Parameter intersects should contain a valid GeoJSON Geometry (not a Feature)"), status_code=400)
815
840
  if intersects["type"] == "Point":
816
841
  sqlWhere.append(SQL("p.geom && ST_Expand(ST_GeomFromGeoJSON(%(geom)s), 0.000001)"))
817
842
  else:
@@ -827,7 +852,7 @@ def searchItems():
827
852
  try:
828
853
  sqlParams["ids"] = [UUID(j) for j in parse_list(args.get("ids"), paramName="ids")]
829
854
  except:
830
- raise errors.InvalidAPIUsage("Parameter ids should be a JSON array of strings", status_code=400)
855
+ raise errors.InvalidAPIUsage(_("Parameter ids should be a JSON array of strings"), status_code=400)
831
856
 
832
857
  # Collections
833
858
  if args.get("collections") is not None:
@@ -839,7 +864,7 @@ def searchItems():
839
864
  try:
840
865
  sqlParams["collections"] = [UUID(j) for j in parse_list(args["collections"], paramName="collections")]
841
866
  except:
842
- raise errors.InvalidAPIUsage("Parameter collections should be a JSON array of strings", status_code=400)
867
+ raise errors.InvalidAPIUsage(_("Parameter collections should be a JSON array of strings"), status_code=400)
843
868
 
844
869
  # 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
870
  if args.get("ids") is not None and args:
@@ -847,10 +872,10 @@ def searchItems():
847
872
  if ids and len(ids) == 1:
848
873
  picture_id = ids[0]
849
874
 
850
- with psycopg.connect(current_app.config["DB_URL"]) as conn, conn.cursor() as cursor:
875
+ with current_app.pool.connection() as conn, conn.cursor() as cursor:
851
876
  seq = cursor.execute("SELECT seq_id FROM sequences_pictures WHERE pic_id = %s", [picture_id]).fetchone()
852
877
  if not seq:
853
- raise errors.InvalidAPIUsage("Picture doesn't exist", status_code=404)
878
+ raise errors.InvalidAPIUsage(_("Picture doesn't exist"), status_code=404)
854
879
 
855
880
  item = _getPictureItemById(seq[0], UUID(picture_id))
856
881
  features = [item] if item else []
@@ -863,16 +888,17 @@ def searchItems():
863
888
  #
864
889
  # Database query
865
890
  #
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
- """
891
+ with db.cursor(current_app, timeout=30000, row_factory=dict_row) as cursor:
892
+ query = SQL(
893
+ """
870
894
  SELECT * FROM (
871
895
  SELECT
872
896
  p.id, p.ts, p.heading, p.metadata, p.inserted_at,
873
897
  ST_AsGeoJSON(p.geom)::json AS geojson,
874
898
  sp.seq_id, sp.rank AS rank,
875
- accounts.name AS account_name, p.exif
899
+ accounts.name AS account_name,
900
+ p.account_id AS account_id,
901
+ p.exif
876
902
  FROM pictures p
877
903
  LEFT JOIN sequences_pictures sp ON p.id = sp.pic_id
878
904
  LEFT JOIN sequences s ON s.id = sp.seq_id
@@ -900,24 +926,24 @@ LEFT JOIN LATERAL (
900
926
  LIMIT 1
901
927
  ) next on true
902
928
  ;
903
- """
904
- ).format(sqlWhere=SQL(" AND ").join(sqlWhere), sqlSubQueryWhere=SQL(" AND ").join(sqlSubQueryWhere), orderBy=order_by)
929
+ """
930
+ ).format(sqlWhere=SQL(" AND ").join(sqlWhere), sqlSubQueryWhere=SQL(" AND ").join(sqlSubQueryWhere), orderBy=order_by)
905
931
 
906
- records = cursor.execute(query, sqlParams)
932
+ records = cursor.execute(query, sqlParams)
907
933
 
908
- items = [dbPictureToStacItem(str(dbPic["seq_id"]), dbPic) for dbPic in records]
934
+ items = [dbPictureToStacItem(str(dbPic["seq_id"]), dbPic) for dbPic in records]
909
935
 
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
- )
936
+ return (
937
+ {
938
+ "type": "FeatureCollection",
939
+ "features": items,
940
+ "links": [
941
+ get_root_link(),
942
+ ],
943
+ },
944
+ 200,
945
+ {"Content-Type": "application/geo+json"},
946
+ )
921
947
 
922
948
 
923
949
  @bp.route("/collections/<uuid:collectionId>/items", methods=["POST"])
@@ -952,18 +978,18 @@ def postCollectionItem(collectionId, account=None):
952
978
  """
953
979
 
954
980
  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)
981
+ raise errors.InvalidAPIUsage(_("Content type should be multipart/form-data"), status_code=415)
956
982
 
957
983
  # Check if position was given
958
984
  if request.form.get("position") is None:
959
- raise errors.InvalidAPIUsage('Missing "position" parameter', status_code=400)
985
+ raise errors.InvalidAPIUsage(_('Missing "position" parameter'), status_code=400)
960
986
  else:
961
987
  try:
962
988
  position = int(request.form["position"])
963
989
  if position <= 0:
964
990
  raise ValueError()
965
991
  except ValueError:
966
- raise errors.InvalidAPIUsage("Position in sequence should be a positive integer", status_code=400)
992
+ raise errors.InvalidAPIUsage(_("Position in sequence should be a positive integer"), status_code=400)
967
993
 
968
994
  # Check if datetime was given
969
995
  ext_mtd = PictureMetadata()
@@ -977,11 +1003,11 @@ def postCollectionItem(collectionId, account=None):
977
1003
  lon, lat = request.form.get("override_longitude"), request.form.get("override_latitude")
978
1004
  if lon is not None or lat is not None:
979
1005
  if lat is None:
980
- raise errors.InvalidAPIUsage("Longitude cannot be overridden alone, override_latitude also needs to be set")
1006
+ raise errors.InvalidAPIUsage(_("Longitude cannot be overridden alone, override_latitude also needs to be set"))
981
1007
  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")
1008
+ raise errors.InvalidAPIUsage(_("Latitude cannot be overridden alone, override_longitude also needs to be set"))
1009
+ lon = as_longitude(lon, error=_("For parameter `override_longitude`, `%(v)s` is not a valid longitude", v=lon))
1010
+ lat = as_latitude(lat, error=_("For parameter `override_latitude`, `%(v)s` is not a valid latitude", v=lat))
985
1011
  ext_mtd.longitude = lon
986
1012
  ext_mtd.latitude = lat
987
1013
 
@@ -1000,70 +1026,88 @@ def postCollectionItem(collectionId, account=None):
1000
1026
  if request.form.get("isBlurred") is None or request.form.get("isBlurred") in ["true", "false"]:
1001
1027
  isBlurred = request.form.get("isBlurred") == "true"
1002
1028
  else:
1003
- raise errors.InvalidAPIUsage("Picture blur status should be either unset, true or false", status_code=400)
1029
+ raise errors.InvalidAPIUsage(_("Picture blur status should be either unset, true or false"), status_code=400)
1004
1030
 
1005
1031
  # Check if a picture file was given
1006
1032
  if "picture" not in request.files:
1007
- raise errors.InvalidAPIUsage("No picture file was sent", status_code=400)
1033
+ raise errors.InvalidAPIUsage(_("No picture file was sent"), status_code=400)
1008
1034
  else:
1009
1035
  picture = request.files["picture"]
1010
1036
 
1011
1037
  # Check file validity
1012
1038
  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)
1039
+ raise errors.InvalidAPIUsage(_("Picture file is either missing or in an unsupported format (should be jpg)"), status_code=400)
1014
1040
 
1015
- with psycopg.connect(current_app.config["DB_URL"]) as conn:
1016
- with conn.cursor() as cursor:
1041
+ with db.conn(current_app) as conn:
1042
+ with conn.transaction(), conn.cursor() as cursor:
1017
1043
  # 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)
1044
+ seq = cursor.execute("SELECT account_id, status FROM sequences WHERE id = %s", [collectionId]).fetchone()
1045
+ if not seq:
1046
+ raise errors.InvalidAPIUsage(_("Collection %(s)s wasn't found in database", s=collectionId), status_code=404)
1047
+
1048
+ # Account associated to picture doesn't match current user
1049
+ if account is not None and account.id != str(seq[0]):
1050
+ raise errors.InvalidAPIUsage(_("You're not authorized to add picture to this collection"), status_code=403)
1051
+
1052
+ # Check if sequence has not been deleted
1053
+ status = seq[1]
1054
+ if status == "deleted":
1055
+ raise errors.InvalidAPIUsage(_("The collection has been deleted, impossible to add pictures to it"), status_code=404)
1021
1056
 
1022
1057
  # Compute various metadata
1023
1058
  accountId = accountIdOrDefault(account)
1024
1059
  raw_pic = picture.read()
1025
1060
  filesize = len(raw_pic)
1026
1061
 
1062
+ with sentry_sdk.start_span(description="computing md5"):
1063
+ # we save the content hash md5 as uuid since md5 is 128bit and uuid are efficiently handled in postgres
1064
+ md5 = hashlib.md5(raw_pic).digest()
1065
+ md5 = UUID(bytes=md5)
1066
+
1027
1067
  additionalMetadata = {
1028
1068
  "blurredByAuthor": isBlurred,
1029
1069
  "originalFileName": os.path.basename(picture.filename),
1030
1070
  "originalFileSize": filesize,
1071
+ "originalContentMd5": md5,
1031
1072
  }
1032
1073
 
1033
1074
  # Update picture metadata if needed
1034
- updated_picture = writePictureMetadata(raw_pic, ext_mtd)
1075
+ with sentry_sdk.start_span(description="overwriting metadata"):
1076
+ updated_picture = writePictureMetadata(raw_pic, ext_mtd)
1035
1077
 
1036
1078
  # 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}})
1079
+ with sentry_sdk.start_span(description="Insert picture in db"):
1080
+ try:
1081
+ picId = utils.pictures.insertNewPictureInDatabase(
1082
+ conn, collectionId, position, updated_picture, accountId, additionalMetadata, lang=get_locale().language
1083
+ )
1084
+ except utils.pictures.PicturePositionConflict:
1085
+ raise errors.InvalidAPIUsage(_("Picture at given position already exist"), status_code=409)
1086
+ except utils.pictures.MetadataReadingError as e:
1087
+ raise errors.InvalidAPIUsage(_("Impossible to parse picture metadata"), payload={"details": {"error": e.details}})
1088
+ except utils.pictures.InvalidMetadataValue as e:
1089
+ raise errors.InvalidAPIUsage(_("Picture has invalid metadata"), payload={"details": {"error": e.details}})
1045
1090
 
1046
1091
  # 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
- )
1092
+ with sentry_sdk.start_span(description="Saving picture"):
1093
+ try:
1094
+ utils.pictures.saveRawPicture(picId, updated_picture, isBlurred)
1095
+ except:
1096
+ logging.exception("Picture wasn't correctly saved in filesystem")
1097
+ raise errors.InvalidAPIUsage(_("Picture wasn't correctly saved in filesystem"), status_code=500)
1098
+
1099
+ current_app.background_processor.process_pictures()
1100
+
1101
+ # Return picture metadata
1102
+ return (
1103
+ getCollectionItem(collectionId, picId)[0],
1104
+ 202,
1105
+ {
1106
+ "Content-Type": "application/json",
1107
+ "Access-Control-Expose-Headers": "Location", # Needed for allowing web browsers access Location header
1108
+ "Location": url_for("stac_items.getCollectionItem", _external=True, collectionId=collectionId, itemId=picId),
1109
+ },
1110
+ )
1067
1111
 
1068
1112
 
1069
1113
  @bp.route("/collections/<uuid:collectionId>/items/<uuid:itemId>", methods=["PATCH"])
@@ -1121,7 +1165,7 @@ def patchCollectionItem(collectionId, itemId, account):
1121
1165
  visible = metadata.get("visible")
1122
1166
  if visible is not None:
1123
1167
  if visible not in ["true", "false"]:
1124
- raise errors.InvalidAPIUsage("Picture visibility parameter (visible) should be either unset, true or false", status_code=400)
1168
+ raise errors.InvalidAPIUsage(_("Picture visibility parameter (visible) should be either unset, true or false"), status_code=400)
1125
1169
  visible = visible == "true"
1126
1170
 
1127
1171
  # Check if heading is valid
@@ -1133,7 +1177,9 @@ def patchCollectionItem(collectionId, itemId, account):
1133
1177
  raise ValueError()
1134
1178
  except ValueError:
1135
1179
  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°.",
1180
+ _(
1181
+ "Heading is not valid, should be an integer in degrees from 0° to 360°. North is 0°, East = 90°, South = 180° and West = 270°."
1182
+ ),
1137
1183
  status_code=400,
1138
1184
  )
1139
1185
 
@@ -1142,17 +1188,17 @@ def patchCollectionItem(collectionId, itemId, account):
1142
1188
  return getCollectionItem(collectionId, itemId)
1143
1189
 
1144
1190
  # 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:
1191
+ with db.conn(current_app) as conn:
1192
+ with conn.transaction(), conn.cursor(row_factory=dict_row) as cursor:
1147
1193
  pic = cursor.execute("SELECT status, account_id FROM pictures WHERE id = %s", [itemId]).fetchone()
1148
1194
 
1149
1195
  # Picture not found
1150
1196
  if not pic:
1151
- raise errors.InvalidAPIUsage(f"Picture {itemId} wasn't found in database", status_code=404)
1197
+ raise errors.InvalidAPIUsage(_("Picture %(p)s wasn't found in database", p=itemId), status_code=404)
1152
1198
 
1153
1199
  # Account associated to picture doesn't match current user
1154
1200
  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)
1201
+ raise errors.InvalidAPIUsage(_("You're not authorized to edit this picture"), status_code=403)
1156
1202
 
1157
1203
  sqlUpdates = []
1158
1204
  sqlParams = {"id": itemId, "account": account.id}
@@ -1162,7 +1208,12 @@ def patchCollectionItem(collectionId, itemId, account):
1162
1208
  if oldStatus not in ["ready", "hidden"]:
1163
1209
  # Picture is in a preparing/broken/... state so no edit possible
1164
1210
  raise errors.InvalidAPIUsage(
1165
- f"Picture {itemId} is in {oldStatus} state, its visibility can't be changed for now", status_code=400
1211
+ _(
1212
+ "Picture %(p)s is in %(s)s state, its visibility can't be changed for now",
1213
+ p=itemId,
1214
+ s=oldStatus,
1215
+ ),
1216
+ status_code=400,
1166
1217
  )
1167
1218
 
1168
1219
  newStatus = None
@@ -1194,10 +1245,9 @@ def patchCollectionItem(collectionId, itemId, account):
1194
1245
  ).format(updates=SQL(", ").join(sqlUpdates)),
1195
1246
  sqlParams,
1196
1247
  )
1197
- conn.commit()
1198
1248
 
1199
- # Redirect response to a classic GET
1200
- return getCollectionItem(collectionId, itemId)
1249
+ # Redirect response to a classic GET
1250
+ return getCollectionItem(collectionId, itemId)
1201
1251
 
1202
1252
 
1203
1253
  @bp.route("/collections/<uuid:collectionId>/items/<uuid:itemId>", methods=["DELETE"])
@@ -1229,26 +1279,24 @@ def deleteCollectionItem(collectionId, itemId, account):
1229
1279
  """
1230
1280
 
1231
1281
  # 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:
1282
+ with db.conn(current_app) as conn:
1283
+ with conn.transaction(), conn.cursor() as cursor:
1234
1284
  pic = cursor.execute("SELECT status, account_id FROM pictures WHERE id = %s", [itemId]).fetchone()
1235
1285
 
1236
1286
  # Picture not found
1237
1287
  if not pic:
1238
- raise errors.InvalidAPIUsage(f"Picture {itemId} wasn't found in database", status_code=404)
1288
+ raise errors.InvalidAPIUsage(_("Picture %(p)s wasn't found in database", p=itemId), status_code=404)
1239
1289
 
1240
1290
  # Account associated to picture doesn't match current user
1241
1291
  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)
1292
+ raise errors.InvalidAPIUsage(_("You're not authorized to edit this picture"), status_code=403)
1243
1293
 
1244
1294
  cursor.execute("DELETE FROM pictures WHERE id = %s", [itemId])
1245
1295
 
1246
1296
  # delete images
1247
1297
  utils.pictures.removeAllFiles(itemId)
1248
1298
 
1249
- conn.commit()
1250
-
1251
- return "", 204
1299
+ return "", 204
1252
1300
 
1253
1301
 
1254
1302
  def _getHDJpgPictureURL(picId: str, status: Optional[str]):