geovisio 2.5.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 (59) hide show
  1. geovisio/__init__.py +38 -8
  2. geovisio/admin_cli/__init__.py +2 -2
  3. geovisio/admin_cli/db.py +8 -0
  4. geovisio/config_app.py +64 -0
  5. geovisio/db_migrations.py +24 -3
  6. geovisio/templates/main.html +14 -14
  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 +667 -0
  10. geovisio/translations/en/LC_MESSAGES/messages.mo +0 -0
  11. geovisio/translations/en/LC_MESSAGES/messages.po +730 -0
  12. geovisio/translations/es/LC_MESSAGES/messages.mo +0 -0
  13. geovisio/translations/es/LC_MESSAGES/messages.po +778 -0
  14. geovisio/translations/fi/LC_MESSAGES/messages.mo +0 -0
  15. geovisio/translations/fi/LC_MESSAGES/messages.po +589 -0
  16. geovisio/translations/fr/LC_MESSAGES/messages.mo +0 -0
  17. geovisio/translations/fr/LC_MESSAGES/messages.po +814 -0
  18. geovisio/translations/ko/LC_MESSAGES/messages.mo +0 -0
  19. geovisio/translations/ko/LC_MESSAGES/messages.po +685 -0
  20. geovisio/translations/messages.pot +686 -0
  21. geovisio/translations/nl/LC_MESSAGES/messages.mo +0 -0
  22. geovisio/translations/nl/LC_MESSAGES/messages.po +594 -0
  23. geovisio/utils/__init__.py +1 -1
  24. geovisio/utils/auth.py +50 -11
  25. geovisio/utils/db.py +65 -0
  26. geovisio/utils/excluded_areas.py +83 -0
  27. geovisio/utils/extent.py +30 -0
  28. geovisio/utils/fields.py +1 -1
  29. geovisio/utils/filesystems.py +0 -1
  30. geovisio/utils/link.py +14 -0
  31. geovisio/utils/params.py +20 -0
  32. geovisio/utils/pictures.py +94 -69
  33. geovisio/utils/reports.py +171 -0
  34. geovisio/utils/sequences.py +288 -126
  35. geovisio/utils/tokens.py +37 -42
  36. geovisio/utils/upload_set.py +654 -0
  37. geovisio/web/auth.py +50 -37
  38. geovisio/web/collections.py +305 -319
  39. geovisio/web/configuration.py +14 -0
  40. geovisio/web/docs.py +288 -12
  41. geovisio/web/excluded_areas.py +377 -0
  42. geovisio/web/items.py +203 -151
  43. geovisio/web/map.py +322 -106
  44. geovisio/web/params.py +69 -26
  45. geovisio/web/pictures.py +14 -31
  46. geovisio/web/reports.py +399 -0
  47. geovisio/web/rss.py +13 -7
  48. geovisio/web/stac.py +129 -121
  49. geovisio/web/tokens.py +105 -112
  50. geovisio/web/upload_set.py +768 -0
  51. geovisio/web/users.py +100 -73
  52. geovisio/web/utils.py +38 -9
  53. geovisio/workers/runner_pictures.py +278 -183
  54. geovisio-2.7.0.dist-info/METADATA +95 -0
  55. geovisio-2.7.0.dist-info/RECORD +66 -0
  56. geovisio-2.5.0.dist-info/METADATA +0 -115
  57. geovisio-2.5.0.dist-info/RECORD +0 -41
  58. {geovisio-2.5.0.dist-info → geovisio-2.7.0.dist-info}/LICENSE +0 -0
  59. {geovisio-2.5.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,24 +21,24 @@ from geovisio.web.params import (
21
21
  parse_distance_range,
22
22
  )
23
23
  from geovisio.utils.fields import Bounds
24
-
25
- import psycopg
26
- from datetime import datetime
24
+ import hashlib
27
25
  from psycopg.rows import dict_row
28
26
  from psycopg.sql import SQL
29
27
  from geovisio.web.utils import (
30
28
  accountIdOrDefault,
31
29
  cleanNoneInList,
32
30
  dbTsToStac,
31
+ dbTsToStacTZ,
33
32
  get_license_link,
34
33
  get_root_link,
35
34
  removeNoneInDict,
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
 
@@ -50,7 +50,7 @@ def dbPictureToStacItem(seqId, dbPic):
50
50
  ----------
51
51
  seqId : uuid
52
52
  Associated sequence ID
53
- dbSeq : dict
53
+ dbPic : dict
54
54
  A row from pictures table in database (with id, geojson, ts, heading, cols, rows, width, height, prevpic, nextpic, prevpicgeojson, nextpicgeojson, exif fields)
55
55
 
56
56
  Returns
@@ -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
@@ -80,33 +99,40 @@ def dbPictureToStacItem(seqId, dbPic):
80
99
  ),
81
100
  ]
82
101
  ),
83
- "properties": {
84
- "datetime": dbTsToStac(dbPic["ts"]),
85
- "created": dbTsToStac(dbPic["inserted_at"]),
86
- # TODO : add "updated" TS for last edit time of metadata
87
- "license": current_app.config["API_PICTURES_LICENSE_SPDX_ID"],
88
- "view:azimuth": dbPic["heading"],
89
- "pers:interior_orientation": (
90
- removeNoneInDict(
91
- {
92
- "camera_manufacturer": dbPic["metadata"].get("make"),
93
- "camera_model": dbPic["metadata"].get("model"),
94
- "focal_length": dbPic["metadata"].get("focal_length"),
95
- "field_of_view": dbPic["metadata"].get("field_of_view"),
96
- }
97
- )
98
- if "metadata" in dbPic
99
- and any(True for f in dbPic["metadata"] if f in ["make", "model", "focal_length", "field_of_view"])
100
- else {}
101
- ),
102
- "geovisio:status": dbPic.get("status"),
103
- "geovisio:producer": dbPic["account_name"],
104
- "original_file:size": dbPic["metadata"].get("originalFileSize"),
105
- "original_file:name": dbPic["metadata"].get("originalFileName"),
106
- "geovisio:image": _getHDJpgPictureURL(dbPic["id"], dbPic.get("status")),
107
- "geovisio:thumbnail": _getThumbJpgPictureURL(dbPic["id"], dbPic.get("status")),
108
- "exif": removeNoneInDict(cleanupExif(dbPic["exif"])),
109
- },
102
+ "properties": removeNoneInDict(
103
+ {
104
+ "datetime": dbTsToStac(dbPic["ts"]),
105
+ "datetimetz": dbTsToStacTZ(dbPic["ts"], dbPic["metadata"].get("tz")),
106
+ "created": dbTsToStac(dbPic["inserted_at"]),
107
+ # TODO : add "updated" TS for last edit time of metadata
108
+ "license": current_app.config["API_PICTURES_LICENSE_SPDX_ID"],
109
+ "view:azimuth": dbPic["heading"],
110
+ "pers:interior_orientation": (
111
+ removeNoneInDict(
112
+ {
113
+ "camera_manufacturer": dbPic["metadata"].get("make"),
114
+ "camera_model": dbPic["metadata"].get("model"),
115
+ "focal_length": dbPic["metadata"].get("focal_length"),
116
+ "field_of_view": dbPic["metadata"].get("field_of_view"),
117
+ "sensor_array_dimensions": sensorDim,
118
+ "visible_area": visibleArea,
119
+ }
120
+ )
121
+ if "metadata" in dbPic
122
+ and any(True for f in dbPic["metadata"] if f in ["make", "model", "focal_length", "field_of_view", "crop"])
123
+ else {}
124
+ ),
125
+ "pers:pitch": dbPic["metadata"].get("pitch"),
126
+ "pers:roll": dbPic["metadata"].get("roll"),
127
+ "geovisio:status": dbPic.get("status"),
128
+ "geovisio:producer": dbPic["account_name"],
129
+ "original_file:size": dbPic["metadata"].get("originalFileSize"),
130
+ "original_file:name": dbPic["metadata"].get("originalFileName"),
131
+ "geovisio:image": _getHDJpgPictureURL(dbPic["id"], dbPic.get("status")),
132
+ "geovisio:thumbnail": _getThumbJpgPictureURL(dbPic["id"], dbPic.get("status")),
133
+ "exif": removeNoneInDict(cleanupExif(dbPic["exif"])),
134
+ }
135
+ ),
110
136
  "links": cleanNoneInList(
111
137
  [
112
138
  get_root_link(),
@@ -325,31 +351,31 @@ def getCollectionItems(collectionId):
325
351
  try:
326
352
  limit = int(limit)
327
353
  if limit < 1 or limit > 10000:
328
- 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)
329
355
  except ValueError:
330
- 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)
331
357
  sql_limit = SQL("LIMIT %(limit)s")
332
358
  params["limit"] = limit
333
359
 
334
360
  if withPicture and startAfterRank:
335
- raise errors.InvalidAPIUsage(f"`startAfterRank` and `withPicture` are mutually exclusive parameters")
361
+ raise errors.InvalidAPIUsage(_("`startAfterRank` and `withPicture` are mutually exclusive parameters"))
336
362
 
337
363
  # Check if rank is valid
338
364
  if startAfterRank is not None:
339
365
  try:
340
366
  startAfterRank = int(startAfterRank)
341
367
  if startAfterRank < 1:
342
- 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)
343
369
  except ValueError:
344
- 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)
345
371
 
346
372
  filters.append(SQL("rank > %(start_after_rank)s"))
347
373
  params["start_after_rank"] = startAfterRank
348
374
 
349
375
  paginated = startAfterRank is not None or limit is not None or withPicture is not None
350
376
 
351
- with psycopg.connect(current_app.config["DB_URL"], row_factory=dict_row) as conn:
352
- with conn.cursor() as cursor:
377
+ with current_app.pool.connection() as conn:
378
+ with conn.cursor(row_factory=dict_row) as cursor:
353
379
  # check on sequence
354
380
  seqMeta = cursor.execute(
355
381
  "SELECT s.id "
@@ -362,12 +388,14 @@ def getCollectionItems(collectionId):
362
388
  ).fetchone()
363
389
 
364
390
  if seqMeta is None:
365
- raise errors.InvalidAPIUsage("Collection doesn't exist", status_code=404)
391
+ raise errors.InvalidAPIUsage(_("Collection doesn't exist"), status_code=404)
366
392
 
367
393
  maxRank = seqMeta.get("max_rank")
368
394
 
369
395
  if startAfterRank is not None and startAfterRank >= maxRank:
370
- 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
+ )
371
399
 
372
400
  if withPicture is not None:
373
401
  withPicture = as_uuid(withPicture, "withPicture should be a valid UUID")
@@ -376,7 +404,7 @@ def getCollectionItems(collectionId):
376
404
  params={"id": withPicture, "seq": collectionId},
377
405
  ).fetchone()
378
406
  if not pic:
379
- 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))
380
408
  rank = get_first_rank_of_page(pic["rank"], limit)
381
409
 
382
410
  filters.append(SQL("rank >= %(start_after_rank)s"))
@@ -388,6 +416,7 @@ def getCollectionItems(collectionId):
388
416
  p.id, p.ts, p.heading, p.metadata, p.inserted_at, p.status,
389
417
  ST_AsGeoJSON(p.geom)::json AS geojson,
390
418
  a.name AS account_name,
419
+ p.account_id AS account_id,
391
420
  sp.rank, p.exif,
392
421
  CASE WHEN LAG(p.status) OVER othpics = 'ready' THEN LAG(p.id) OVER othpics END AS prevpic,
393
422
  CASE WHEN LAG(p.status) OVER othpics = 'ready' THEN ST_AsGeoJSON(LAG(p.geom) OVER othpics)::json END AS prevpicgeojson,
@@ -540,8 +569,8 @@ def _getPictureItemById(collectionId, itemId):
540
569
  schema:
541
570
  type: string
542
571
  """
543
- with psycopg.connect(current_app.config["DB_URL"], row_factory=dict_row) as conn:
544
- with conn.cursor() as cursor:
572
+ with current_app.pool.connection() as conn:
573
+ with conn.cursor(row_factory=dict_row) as cursor:
545
574
  # Check if there is a logged user
546
575
  account = auth.get_current_account()
547
576
  accountId = account.id if account else None
@@ -552,6 +581,7 @@ def _getPictureItemById(collectionId, itemId):
552
581
  SELECT
553
582
  p.id, sp.rank, ST_AsGeoJSON(p.geom)::json AS geojson, p.heading, p.ts, p.metadata,
554
583
  p.inserted_at, p.status, accounts.name AS account_name,
584
+ p.account_id AS account_id,
555
585
  spl.prevpic, spl.prevpicgeojson, spl.nextpic, spl.nextpicgeojson, p.exif,
556
586
  relp.related_pics
557
587
  FROM pictures p
@@ -660,7 +690,7 @@ def getCollectionItem(collectionId, itemId):
660
690
 
661
691
  stacItem = _getPictureItemById(collectionId, itemId)
662
692
  if stacItem is None:
663
- raise errors.InvalidAPIUsage("Item doesn't exist", status_code=404)
693
+ raise errors.InvalidAPIUsage(_("Item doesn't exist"), status_code=404)
664
694
 
665
695
  account = auth.get_current_account()
666
696
  picStatusToHttpCode = {
@@ -709,7 +739,6 @@ def searchItems():
709
739
  sqlWhere = [SQL("(p.status = 'ready' OR p.account_id = %(account)s)"), SQL("(is_sequence_visible_by_user(s, %(account)s))")]
710
740
  sqlParams: Dict[str, Any] = {"account": accountId}
711
741
  sqlSubQueryWhere = [SQL("(p.status = 'ready' OR p.account_id = %(account)s)")]
712
-
713
742
  order_by = SQL("")
714
743
 
715
744
  #
@@ -720,7 +749,7 @@ def searchItems():
720
749
  args: MultiDict[str, str]
721
750
  if request.method == "POST":
722
751
  if request.headers.get("Content-Type") != "application/json":
723
- 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)
724
753
  args = MultiDict(request.json)
725
754
  else:
726
755
  args = request.args
@@ -729,7 +758,7 @@ def searchItems():
729
758
  if args.get("limit") is not None:
730
759
  limit = args.get("limit", type=int)
731
760
  if limit is None or limit < 1 or limit > 10000:
732
- 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)
733
762
  else:
734
763
  sqlParams["limit"] = limit
735
764
  else:
@@ -785,7 +814,7 @@ def searchItems():
785
814
  place_fov_tolerance = args.get("place_fov_tolerance", type=int, default=30)
786
815
  if place_fov_tolerance < 2 or place_fov_tolerance > 180:
787
816
  raise errors.InvalidAPIUsage(
788
- "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
789
818
  )
790
819
  else:
791
820
  sqlParams["placefov"] = place_fov_tolerance / 2
@@ -807,7 +836,7 @@ def searchItems():
807
836
  try:
808
837
  intersects = json.loads(args["intersects"])
809
838
  except:
810
- 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)
811
840
  if intersects["type"] == "Point":
812
841
  sqlWhere.append(SQL("p.geom && ST_Expand(ST_GeomFromGeoJSON(%(geom)s), 0.000001)"))
813
842
  else:
@@ -823,7 +852,7 @@ def searchItems():
823
852
  try:
824
853
  sqlParams["ids"] = [UUID(j) for j in parse_list(args.get("ids"), paramName="ids")]
825
854
  except:
826
- 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)
827
856
 
828
857
  # Collections
829
858
  if args.get("collections") is not None:
@@ -835,7 +864,7 @@ def searchItems():
835
864
  try:
836
865
  sqlParams["collections"] = [UUID(j) for j in parse_list(args["collections"], paramName="collections")]
837
866
  except:
838
- 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)
839
868
 
840
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
841
870
  if args.get("ids") is not None and args:
@@ -843,10 +872,10 @@ def searchItems():
843
872
  if ids and len(ids) == 1:
844
873
  picture_id = ids[0]
845
874
 
846
- 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:
847
876
  seq = cursor.execute("SELECT seq_id FROM sequences_pictures WHERE pic_id = %s", [picture_id]).fetchone()
848
877
  if not seq:
849
- raise errors.InvalidAPIUsage("Picture doesn't exist", status_code=404)
878
+ raise errors.InvalidAPIUsage(_("Picture doesn't exist"), status_code=404)
850
879
 
851
880
  item = _getPictureItemById(seq[0], UUID(picture_id))
852
881
  features = [item] if item else []
@@ -859,25 +888,25 @@ def searchItems():
859
888
  #
860
889
  # Database query
861
890
  #
862
-
863
- with psycopg.connect(current_app.config["DB_URL"], row_factory=dict_row, options="-c statement_timeout=30000") as conn:
864
- with conn.cursor() as cursor:
865
- query = SQL(
866
- """
867
- SELECT * FROM (
868
- SELECT
869
- p.id, p.ts, p.heading, p.metadata, p.inserted_at,
870
- ST_AsGeoJSON(p.geom)::json AS geojson,
871
- sp.seq_id, sp.rank AS rank,
872
- accounts.name AS account_name, p.exif
873
- FROM pictures p
874
- LEFT JOIN sequences_pictures sp ON p.id = sp.pic_id
875
- LEFT JOIN sequences s ON s.id = sp.seq_id
876
- LEFT JOIN accounts ON p.account_id = accounts.id
877
- WHERE {sqlWhere}
878
- {orderBy}
879
- LIMIT %(limit)s
880
- ) pic
891
+ with db.cursor(current_app, timeout=30000, row_factory=dict_row) as cursor:
892
+ query = SQL(
893
+ """
894
+ SELECT * FROM (
895
+ SELECT
896
+ p.id, p.ts, p.heading, p.metadata, p.inserted_at,
897
+ ST_AsGeoJSON(p.geom)::json AS geojson,
898
+ sp.seq_id, sp.rank AS rank,
899
+ accounts.name AS account_name,
900
+ p.account_id AS account_id,
901
+ p.exif
902
+ FROM pictures p
903
+ LEFT JOIN sequences_pictures sp ON p.id = sp.pic_id
904
+ LEFT JOIN sequences s ON s.id = sp.seq_id
905
+ LEFT JOIN accounts ON p.account_id = accounts.id
906
+ WHERE {sqlWhere}
907
+ {orderBy}
908
+ LIMIT %(limit)s
909
+ ) pic
881
910
  LEFT JOIN LATERAL (
882
911
  SELECT
883
912
  p.id AS prevpic, ST_AsGeoJSON(p.geom)::json AS prevpicgeojson
@@ -897,23 +926,24 @@ LEFT JOIN LATERAL (
897
926
  LIMIT 1
898
927
  ) next on true
899
928
  ;
900
- """
901
- ).format(sqlWhere=SQL(" AND ").join(sqlWhere), sqlSubQueryWhere=SQL(" AND ").join(sqlSubQueryWhere), orderBy=order_by)
902
- records = cursor.execute(query, sqlParams)
929
+ """
930
+ ).format(sqlWhere=SQL(" AND ").join(sqlWhere), sqlSubQueryWhere=SQL(" AND ").join(sqlSubQueryWhere), orderBy=order_by)
903
931
 
904
- items = [dbPictureToStacItem(str(dbPic["seq_id"]), dbPic) for dbPic in records]
932
+ records = cursor.execute(query, sqlParams)
905
933
 
906
- return (
907
- {
908
- "type": "FeatureCollection",
909
- "features": items,
910
- "links": [
911
- get_root_link(),
912
- ],
913
- },
914
- 200,
915
- {"Content-Type": "application/geo+json"},
916
- )
934
+ items = [dbPictureToStacItem(str(dbPic["seq_id"]), dbPic) for dbPic in records]
935
+
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
+ )
917
947
 
918
948
 
919
949
  @bp.route("/collections/<uuid:collectionId>/items", methods=["POST"])
@@ -948,18 +978,18 @@ def postCollectionItem(collectionId, account=None):
948
978
  """
949
979
 
950
980
  if not request.headers.get("Content-Type", "").startswith("multipart/form-data"):
951
- 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)
952
982
 
953
983
  # Check if position was given
954
984
  if request.form.get("position") is None:
955
- raise errors.InvalidAPIUsage('Missing "position" parameter', status_code=400)
985
+ raise errors.InvalidAPIUsage(_('Missing "position" parameter'), status_code=400)
956
986
  else:
957
987
  try:
958
988
  position = int(request.form["position"])
959
989
  if position <= 0:
960
990
  raise ValueError()
961
991
  except ValueError:
962
- 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)
963
993
 
964
994
  # Check if datetime was given
965
995
  ext_mtd = PictureMetadata()
@@ -973,11 +1003,11 @@ def postCollectionItem(collectionId, account=None):
973
1003
  lon, lat = request.form.get("override_longitude"), request.form.get("override_latitude")
974
1004
  if lon is not None or lat is not None:
975
1005
  if lat is None:
976
- 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"))
977
1007
  if lon is None:
978
- raise errors.InvalidAPIUsage("Latitude cannot be overridden alone, override_longitude also needs to be set")
979
- lon = as_longitude(lon, error=f"For parameter `override_longitude`, `{lon}` is not a valid longitude")
980
- 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))
981
1011
  ext_mtd.longitude = lon
982
1012
  ext_mtd.latitude = lat
983
1013
 
@@ -996,70 +1026,88 @@ def postCollectionItem(collectionId, account=None):
996
1026
  if request.form.get("isBlurred") is None or request.form.get("isBlurred") in ["true", "false"]:
997
1027
  isBlurred = request.form.get("isBlurred") == "true"
998
1028
  else:
999
- 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)
1000
1030
 
1001
1031
  # Check if a picture file was given
1002
1032
  if "picture" not in request.files:
1003
- raise errors.InvalidAPIUsage("No picture file was sent", status_code=400)
1033
+ raise errors.InvalidAPIUsage(_("No picture file was sent"), status_code=400)
1004
1034
  else:
1005
1035
  picture = request.files["picture"]
1006
1036
 
1007
1037
  # Check file validity
1008
1038
  if not (picture.filename != "" and "." in picture.filename and picture.filename.rsplit(".", 1)[1].lower() in ["jpg", "jpeg"]):
1009
- 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)
1010
1040
 
1011
- with psycopg.connect(current_app.config["DB_URL"]) as conn:
1012
- with conn.cursor() as cursor:
1041
+ with db.conn(current_app) as conn:
1042
+ with conn.transaction(), conn.cursor() as cursor:
1013
1043
  # Check if sequence exists
1014
- seq = cursor.execute("SELECT id FROM sequences WHERE id = %s", [collectionId]).fetchone()
1015
- if not seq or len(seq) != 1:
1016
- 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)
1017
1056
 
1018
1057
  # Compute various metadata
1019
1058
  accountId = accountIdOrDefault(account)
1020
1059
  raw_pic = picture.read()
1021
1060
  filesize = len(raw_pic)
1022
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
+
1023
1067
  additionalMetadata = {
1024
1068
  "blurredByAuthor": isBlurred,
1025
1069
  "originalFileName": os.path.basename(picture.filename),
1026
1070
  "originalFileSize": filesize,
1071
+ "originalContentMd5": md5,
1027
1072
  }
1028
1073
 
1029
1074
  # Update picture metadata if needed
1030
- updated_picture = writePictureMetadata(raw_pic, ext_mtd)
1075
+ with sentry_sdk.start_span(description="overwriting metadata"):
1076
+ updated_picture = writePictureMetadata(raw_pic, ext_mtd)
1031
1077
 
1032
1078
  # Insert picture into database
1033
- try:
1034
- picId = utils.pictures.insertNewPictureInDatabase(
1035
- conn, collectionId, position, updated_picture, accountId, additionalMetadata
1036
- )
1037
- except utils.pictures.PicturePositionConflict:
1038
- raise errors.InvalidAPIUsage("Picture at given position already exist", status_code=409)
1039
- except utils.pictures.MetadataReadingError as e:
1040
- 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}})
1041
1090
 
1042
1091
  # Save file into appropriate filesystem
1043
- try:
1044
- utils.pictures.saveRawPicture(picId, updated_picture, isBlurred)
1045
- except:
1046
- logging.exception("Picture wasn't correctly saved in filesystem")
1047
- raise errors.InvalidAPIUsage("Picture wasn't correctly saved in filesystem", status_code=500)
1048
-
1049
- conn.commit()
1050
-
1051
- runner_pictures.background_processor.process_pictures()
1052
-
1053
- # Return picture metadata
1054
- return (
1055
- getCollectionItem(collectionId, picId)[0],
1056
- 202,
1057
- {
1058
- "Content-Type": "application/json",
1059
- "Access-Control-Expose-Headers": "Location", # Needed for allowing web browsers access Location header
1060
- "Location": url_for("stac_items.getCollectionItem", _external=True, collectionId=collectionId, itemId=picId),
1061
- },
1062
- )
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
+ )
1063
1111
 
1064
1112
 
1065
1113
  @bp.route("/collections/<uuid:collectionId>/items/<uuid:itemId>", methods=["PATCH"])
@@ -1117,7 +1165,7 @@ def patchCollectionItem(collectionId, itemId, account):
1117
1165
  visible = metadata.get("visible")
1118
1166
  if visible is not None:
1119
1167
  if visible not in ["true", "false"]:
1120
- 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)
1121
1169
  visible = visible == "true"
1122
1170
 
1123
1171
  # Check if heading is valid
@@ -1129,7 +1177,9 @@ def patchCollectionItem(collectionId, itemId, account):
1129
1177
  raise ValueError()
1130
1178
  except ValueError:
1131
1179
  raise errors.InvalidAPIUsage(
1132
- "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
+ ),
1133
1183
  status_code=400,
1134
1184
  )
1135
1185
 
@@ -1138,17 +1188,17 @@ def patchCollectionItem(collectionId, itemId, account):
1138
1188
  return getCollectionItem(collectionId, itemId)
1139
1189
 
1140
1190
  # Check if picture exists and if given account is authorized to edit
1141
- with psycopg.connect(current_app.config["DB_URL"], row_factory=dict_row) as conn:
1142
- 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:
1143
1193
  pic = cursor.execute("SELECT status, account_id FROM pictures WHERE id = %s", [itemId]).fetchone()
1144
1194
 
1145
1195
  # Picture not found
1146
1196
  if not pic:
1147
- 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)
1148
1198
 
1149
1199
  # Account associated to picture doesn't match current user
1150
1200
  if account is not None and account.id != str(pic["account_id"]):
1151
- 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)
1152
1202
 
1153
1203
  sqlUpdates = []
1154
1204
  sqlParams = {"id": itemId, "account": account.id}
@@ -1158,7 +1208,12 @@ def patchCollectionItem(collectionId, itemId, account):
1158
1208
  if oldStatus not in ["ready", "hidden"]:
1159
1209
  # Picture is in a preparing/broken/... state so no edit possible
1160
1210
  raise errors.InvalidAPIUsage(
1161
- 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,
1162
1217
  )
1163
1218
 
1164
1219
  newStatus = None
@@ -1190,10 +1245,9 @@ def patchCollectionItem(collectionId, itemId, account):
1190
1245
  ).format(updates=SQL(", ").join(sqlUpdates)),
1191
1246
  sqlParams,
1192
1247
  )
1193
- conn.commit()
1194
1248
 
1195
- # Redirect response to a classic GET
1196
- return getCollectionItem(collectionId, itemId)
1249
+ # Redirect response to a classic GET
1250
+ return getCollectionItem(collectionId, itemId)
1197
1251
 
1198
1252
 
1199
1253
  @bp.route("/collections/<uuid:collectionId>/items/<uuid:itemId>", methods=["DELETE"])
@@ -1225,26 +1279,24 @@ def deleteCollectionItem(collectionId, itemId, account):
1225
1279
  """
1226
1280
 
1227
1281
  # Check if picture exists and if given account is authorized to edit
1228
- with psycopg.connect(current_app.config["DB_URL"]) as conn:
1229
- with conn.cursor() as cursor:
1282
+ with db.conn(current_app) as conn:
1283
+ with conn.transaction(), conn.cursor() as cursor:
1230
1284
  pic = cursor.execute("SELECT status, account_id FROM pictures WHERE id = %s", [itemId]).fetchone()
1231
1285
 
1232
1286
  # Picture not found
1233
1287
  if not pic:
1234
- 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)
1235
1289
 
1236
1290
  # Account associated to picture doesn't match current user
1237
1291
  if account is not None and account.id != str(pic[1]):
1238
- 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)
1239
1293
 
1240
1294
  cursor.execute("DELETE FROM pictures WHERE id = %s", [itemId])
1241
1295
 
1242
1296
  # delete images
1243
1297
  utils.pictures.removeAllFiles(itemId)
1244
1298
 
1245
- conn.commit()
1246
-
1247
- return "", 204
1299
+ return "", 204
1248
1300
 
1249
1301
 
1250
1302
  def _getHDJpgPictureURL(picId: str, status: Optional[str]):