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.
- geovisio/__init__.py +38 -8
- geovisio/admin_cli/__init__.py +2 -2
- geovisio/admin_cli/db.py +8 -0
- geovisio/config_app.py +64 -0
- geovisio/db_migrations.py +24 -3
- geovisio/templates/main.html +14 -14
- geovisio/templates/viewer.html +3 -3
- geovisio/translations/de/LC_MESSAGES/messages.mo +0 -0
- geovisio/translations/de/LC_MESSAGES/messages.po +667 -0
- geovisio/translations/en/LC_MESSAGES/messages.mo +0 -0
- geovisio/translations/en/LC_MESSAGES/messages.po +730 -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/ko/LC_MESSAGES/messages.mo +0 -0
- geovisio/translations/ko/LC_MESSAGES/messages.po +685 -0
- geovisio/translations/messages.pot +686 -0
- geovisio/translations/nl/LC_MESSAGES/messages.mo +0 -0
- geovisio/translations/nl/LC_MESSAGES/messages.po +594 -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 +94 -69
- geovisio/utils/reports.py +171 -0
- geovisio/utils/sequences.py +288 -126
- geovisio/utils/tokens.py +37 -42
- geovisio/utils/upload_set.py +654 -0
- geovisio/web/auth.py +50 -37
- geovisio/web/collections.py +305 -319
- geovisio/web/configuration.py +14 -0
- geovisio/web/docs.py +288 -12
- geovisio/web/excluded_areas.py +377 -0
- geovisio/web/items.py +203 -151
- geovisio/web/map.py +322 -106
- 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 -121
- geovisio/web/tokens.py +105 -112
- geovisio/web/upload_set.py +768 -0
- geovisio/web/users.py +100 -73
- geovisio/web/utils.py +38 -9
- geovisio/workers/runner_pictures.py +278 -183
- geovisio-2.7.0.dist-info/METADATA +95 -0
- geovisio-2.7.0.dist-info/RECORD +66 -0
- geovisio-2.5.0.dist-info/METADATA +0 -115
- geovisio-2.5.0.dist-info/RECORD +0 -41
- {geovisio-2.5.0.dist-info → geovisio-2.7.0.dist-info}/LICENSE +0 -0
- {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
|
-
|
|
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
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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(
|
|
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
|
|
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(
|
|
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(
|
|
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
|
|
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
|
|
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
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
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
|
-
|
|
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
|
-
|
|
932
|
+
records = cursor.execute(query, sqlParams)
|
|
905
933
|
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
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=
|
|
980
|
-
lat = as_latitude(lat, error=
|
|
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
|
|
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
|
|
1015
|
-
if not seq
|
|
1016
|
-
raise errors.InvalidAPIUsage(
|
|
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
|
-
|
|
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
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
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
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
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
|
-
|
|
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
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
1196
|
-
|
|
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
|
|
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(
|
|
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
|
-
|
|
1246
|
-
|
|
1247
|
-
return "", 204
|
|
1299
|
+
return "", 204
|
|
1248
1300
|
|
|
1249
1301
|
|
|
1250
1302
|
def _getHDJpgPictureURL(picId: str, status: Optional[str]):
|