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.
- geovisio/__init__.py +36 -7
- geovisio/admin_cli/cleanup.py +2 -2
- geovisio/admin_cli/db.py +1 -4
- geovisio/config_app.py +40 -1
- geovisio/db_migrations.py +24 -3
- geovisio/templates/main.html +13 -13
- geovisio/templates/viewer.html +3 -3
- geovisio/translations/de/LC_MESSAGES/messages.mo +0 -0
- geovisio/translations/de/LC_MESSAGES/messages.po +804 -0
- geovisio/translations/el/LC_MESSAGES/messages.mo +0 -0
- geovisio/translations/el/LC_MESSAGES/messages.po +685 -0
- geovisio/translations/en/LC_MESSAGES/messages.mo +0 -0
- geovisio/translations/en/LC_MESSAGES/messages.po +738 -0
- geovisio/translations/es/LC_MESSAGES/messages.mo +0 -0
- geovisio/translations/es/LC_MESSAGES/messages.po +778 -0
- geovisio/translations/fi/LC_MESSAGES/messages.mo +0 -0
- geovisio/translations/fi/LC_MESSAGES/messages.po +589 -0
- geovisio/translations/fr/LC_MESSAGES/messages.mo +0 -0
- geovisio/translations/fr/LC_MESSAGES/messages.po +814 -0
- geovisio/translations/hu/LC_MESSAGES/messages.mo +0 -0
- geovisio/translations/hu/LC_MESSAGES/messages.po +773 -0
- geovisio/translations/ko/LC_MESSAGES/messages.mo +0 -0
- geovisio/translations/ko/LC_MESSAGES/messages.po +685 -0
- geovisio/translations/messages.pot +694 -0
- geovisio/translations/nl/LC_MESSAGES/messages.mo +0 -0
- geovisio/translations/nl/LC_MESSAGES/messages.po +602 -0
- geovisio/utils/__init__.py +1 -1
- geovisio/utils/auth.py +50 -11
- geovisio/utils/db.py +65 -0
- geovisio/utils/excluded_areas.py +83 -0
- geovisio/utils/extent.py +30 -0
- geovisio/utils/fields.py +1 -1
- geovisio/utils/filesystems.py +0 -1
- geovisio/utils/link.py +14 -0
- geovisio/utils/params.py +20 -0
- geovisio/utils/pictures.py +110 -88
- geovisio/utils/reports.py +171 -0
- geovisio/utils/sequences.py +262 -126
- geovisio/utils/tokens.py +37 -42
- geovisio/utils/upload_set.py +642 -0
- geovisio/web/auth.py +37 -37
- geovisio/web/collections.py +304 -304
- geovisio/web/configuration.py +14 -0
- geovisio/web/docs.py +276 -15
- geovisio/web/excluded_areas.py +377 -0
- geovisio/web/items.py +169 -112
- geovisio/web/map.py +104 -36
- geovisio/web/params.py +69 -26
- geovisio/web/pictures.py +14 -31
- geovisio/web/reports.py +399 -0
- geovisio/web/rss.py +13 -7
- geovisio/web/stac.py +129 -134
- geovisio/web/tokens.py +98 -109
- geovisio/web/upload_set.py +771 -0
- geovisio/web/users.py +100 -73
- geovisio/web/utils.py +28 -9
- geovisio/workers/runner_pictures.py +241 -207
- {geovisio-2.6.0.dist-info → geovisio-2.7.1.dist-info}/METADATA +17 -14
- geovisio-2.7.1.dist-info/RECORD +70 -0
- {geovisio-2.6.0.dist-info → geovisio-2.7.1.dist-info}/WHEEL +1 -1
- geovisio-2.6.0.dist-info/RECORD +0 -41
- {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(
|
|
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(
|
|
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
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
867
|
-
|
|
868
|
-
|
|
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,
|
|
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
|
-
|
|
938
|
+
"""
|
|
939
|
+
).format(sqlWhere=SQL(" AND ").join(sqlWhere), sqlSubQueryWhere=SQL(" AND ").join(sqlSubQueryWhere), orderBy=order_by)
|
|
905
940
|
|
|
906
|
-
|
|
941
|
+
records = cursor.execute(query, sqlParams)
|
|
907
942
|
|
|
908
|
-
|
|
943
|
+
items = [dbPictureToStacItem(str(dbPic["seq_id"]), dbPic) for dbPic in records]
|
|
909
944
|
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
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=
|
|
984
|
-
lat = as_latitude(lat, error=
|
|
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
|
|
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
|
|
1019
|
-
if not seq
|
|
1020
|
-
raise errors.InvalidAPIUsage(
|
|
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
|
-
|
|
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
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
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
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
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
|
-
|
|
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
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
1200
|
-
|
|
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
|
|
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(
|
|
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
|
-
|
|
1247
|
-
|
|
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
|
-
|
|
1308
|
+
return "", 204
|
|
1252
1309
|
|
|
1253
1310
|
|
|
1254
1311
|
def _getHDJpgPictureURL(picId: str, status: Optional[str]):
|