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/collections.py
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import
|
|
2
|
-
from geovisio import errors, utils
|
|
1
|
+
from enum import Enum
|
|
2
|
+
from geovisio import errors, utils, db
|
|
3
3
|
from geovisio.utils import auth, sequences
|
|
4
4
|
from geovisio.web.params import (
|
|
5
5
|
parse_datetime,
|
|
@@ -16,11 +16,10 @@ from geovisio.utils.sequences import (
|
|
|
16
16
|
)
|
|
17
17
|
from geovisio.utils.fields import SortBy, SortByField, SQLDirection, Bounds, BBox
|
|
18
18
|
from geovisio.web.rss import dbSequencesToGeoRSS
|
|
19
|
-
import psycopg
|
|
20
19
|
from psycopg.rows import dict_row
|
|
21
20
|
from psycopg.sql import SQL
|
|
22
|
-
import json
|
|
23
21
|
from flask import current_app, request, url_for, Blueprint
|
|
22
|
+
from flask_babel import gettext as _
|
|
24
23
|
from geovisio.web.utils import (
|
|
25
24
|
STAC_VERSION,
|
|
26
25
|
accountIdOrDefault,
|
|
@@ -31,12 +30,37 @@ from geovisio.web.utils import (
|
|
|
31
30
|
get_root_link,
|
|
32
31
|
removeNoneInDict,
|
|
33
32
|
)
|
|
34
|
-
from
|
|
33
|
+
from typing import Optional
|
|
35
34
|
|
|
36
35
|
|
|
37
36
|
bp = Blueprint("stac_collections", __name__, url_prefix="/api")
|
|
38
37
|
|
|
39
38
|
|
|
39
|
+
class UploadClient(Enum):
|
|
40
|
+
unknown = "unknown"
|
|
41
|
+
other = "other"
|
|
42
|
+
website = "website"
|
|
43
|
+
cli = "cli"
|
|
44
|
+
mobile_app = "mobile_app"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def userAgentToClient(user_agent: Optional[str] = None) -> UploadClient:
|
|
48
|
+
"""Transforms an open user agent string into a limited set of clients."""
|
|
49
|
+
if user_agent is None:
|
|
50
|
+
return UploadClient.unknown
|
|
51
|
+
|
|
52
|
+
software = user_agent.split("/")[0].lower().strip()
|
|
53
|
+
|
|
54
|
+
if software == "geovisiocli" or software == "panoramaxcli":
|
|
55
|
+
return UploadClient.cli
|
|
56
|
+
elif software == "geovisiowebsite":
|
|
57
|
+
return UploadClient.website
|
|
58
|
+
elif software == "panoramaxapp":
|
|
59
|
+
return UploadClient.mobile_app
|
|
60
|
+
else:
|
|
61
|
+
return UploadClient.other
|
|
62
|
+
|
|
63
|
+
|
|
40
64
|
def dbSequenceToStacCollection(dbSeq, description="A sequence of geolocated pictures"):
|
|
41
65
|
"""Transforms a sequence extracted from database into a STAC Collection
|
|
42
66
|
|
|
@@ -53,11 +77,15 @@ def dbSequenceToStacCollection(dbSeq, description="A sequence of geolocated pict
|
|
|
53
77
|
The equivalent in STAC Collection format
|
|
54
78
|
"""
|
|
55
79
|
mints, maxts = dbSeq.get("mints"), dbSeq.get("maxts")
|
|
80
|
+
nb_pic = int(dbSeq.get("nbpic")) if "nbpic" in dbSeq else None
|
|
56
81
|
return removeNoneInDict(
|
|
57
82
|
{
|
|
58
83
|
"type": "Collection",
|
|
59
84
|
"stac_version": STAC_VERSION,
|
|
60
|
-
"stac_extensions": [
|
|
85
|
+
"stac_extensions": [
|
|
86
|
+
"https://stac-extensions.github.io/stats/v0.2.0/schema.json", # For stats: fields
|
|
87
|
+
"https://stac.linz.govt.nz/v0.0.15/quality/schema.json", # For quality: fields
|
|
88
|
+
],
|
|
61
89
|
"id": str(dbSeq["id"]),
|
|
62
90
|
"title": str(dbSeq["name"]),
|
|
63
91
|
"description": description,
|
|
@@ -65,10 +93,18 @@ def dbSequenceToStacCollection(dbSeq, description="A sequence of geolocated pict
|
|
|
65
93
|
"license": current_app.config["API_PICTURES_LICENSE_SPDX_ID"],
|
|
66
94
|
"created": dbTsToStac(dbSeq["created"]),
|
|
67
95
|
"updated": dbTsToStac(dbSeq.get("updated")),
|
|
68
|
-
"geovisio:status":
|
|
96
|
+
"geovisio:status": (
|
|
97
|
+
dbSeq.get("status") if dbSeq.get("status") != "ready" else None
|
|
98
|
+
), # we do not want to add a `geovisio:status` = 'ready', we only use it for hidden/deleted status
|
|
69
99
|
"geovisio:sorted-by": dbSeq.get("current_sort"),
|
|
100
|
+
"geovisio:upload-software": userAgentToClient(dbSeq.get("user_agent")).value,
|
|
101
|
+
"geovisio:length_km": dbSeq.get("length_km"),
|
|
102
|
+
"quality:horizontal_accuracy": (
|
|
103
|
+
float("{:.1f}".format(dbSeq["computed_gps_accuracy"])) if dbSeq.get("computed_gps_accuracy") else None
|
|
104
|
+
),
|
|
105
|
+
"quality:horizontal_accuracy_type": "95% confidence interval" if "computed_gps_accuracy" in dbSeq else None,
|
|
70
106
|
"providers": [
|
|
71
|
-
{"name": dbSeq["account_name"], "roles": ["producer"]},
|
|
107
|
+
{"name": dbSeq["account_name"], "roles": ["producer"], "id": str(dbSeq["account_id"])},
|
|
72
108
|
],
|
|
73
109
|
"extent": {
|
|
74
110
|
"spatial": {"bbox": [[dbSeq["minx"] or -180.0, dbSeq["miny"] or -90.0, dbSeq["maxx"] or 180.0, dbSeq["maxy"] or 90.0]]},
|
|
@@ -81,8 +117,15 @@ def dbSequenceToStacCollection(dbSeq, description="A sequence of geolocated pict
|
|
|
81
117
|
]
|
|
82
118
|
},
|
|
83
119
|
},
|
|
84
|
-
"summaries": cleanNoneInDict(
|
|
85
|
-
|
|
120
|
+
"summaries": cleanNoneInDict(
|
|
121
|
+
{
|
|
122
|
+
"pers:interior_orientation": dbSeq.get("metas"),
|
|
123
|
+
"panoramax:horizontal_pixel_density": (
|
|
124
|
+
[dbSeq["computed_h_pixel_density"]] if "computed_h_pixel_density" in dbSeq else None
|
|
125
|
+
),
|
|
126
|
+
}
|
|
127
|
+
),
|
|
128
|
+
"stats:items": removeNoneInDict({"count": nb_pic}),
|
|
86
129
|
"links": cleanNoneInList(
|
|
87
130
|
[
|
|
88
131
|
(
|
|
@@ -230,10 +273,10 @@ def getAllCollections():
|
|
|
230
273
|
created_before = args.get("created_before")
|
|
231
274
|
|
|
232
275
|
if created_after:
|
|
233
|
-
collection_request.created_after = parse_datetime(created_after, error=
|
|
276
|
+
collection_request.created_after = parse_datetime(created_after, error="Invalid `created_after` argument", fallback_as_UTC=True)
|
|
234
277
|
|
|
235
278
|
if created_before:
|
|
236
|
-
collection_request.created_before = parse_datetime(created_before, error=
|
|
279
|
+
collection_request.created_before = parse_datetime(created_before, error="Invalid `created_before` argument", fallback_as_UTC=True)
|
|
237
280
|
|
|
238
281
|
links = [
|
|
239
282
|
get_root_link(),
|
|
@@ -249,16 +292,16 @@ def getAllCollections():
|
|
|
249
292
|
),
|
|
250
293
|
},
|
|
251
294
|
]
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
295
|
+
|
|
296
|
+
with db.cursor(current_app, row_factory=dict_row) as cursor:
|
|
297
|
+
stats = cursor.execute("SELECT min(inserted_at) as min, max(inserted_at) as max FROM sequences").fetchone()
|
|
298
|
+
if stats is None:
|
|
299
|
+
return ({"collections": [], "links": links}, 200, {"Content-Type": "application/json"})
|
|
300
|
+
datasetBounds = Bounds(min=stats["min"], max=stats["max"])
|
|
301
|
+
if collection_request.created_after and collection_request.created_after > datasetBounds.max:
|
|
302
|
+
raise errors.InvalidAPIUsage(_("There is no collection created after %(d)s", d=collection_request.created_after))
|
|
303
|
+
if collection_request.created_before and collection_request.created_before < datasetBounds.min:
|
|
304
|
+
raise errors.InvalidAPIUsage(_("There is no collection created before %(d)s", d=collection_request.created_before))
|
|
262
305
|
|
|
263
306
|
db_collections = get_collections(collection_request)
|
|
264
307
|
|
|
@@ -324,55 +367,60 @@ def getCollection(collectionId):
|
|
|
324
367
|
"account": account.id if account is not None else None,
|
|
325
368
|
}
|
|
326
369
|
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
370
|
+
record = db.fetchone(
|
|
371
|
+
current_app,
|
|
372
|
+
"""
|
|
373
|
+
SELECT
|
|
374
|
+
s.id,
|
|
375
|
+
s.metadata->>'title' AS name,
|
|
376
|
+
ST_XMin(s.bbox) AS minx,
|
|
377
|
+
ST_YMin(s.bbox) AS miny,
|
|
378
|
+
ST_XMax(s.bbox) AS maxx,
|
|
379
|
+
ST_YMax(s.bbox) AS maxy,
|
|
380
|
+
s.status AS status,
|
|
381
|
+
accounts.name AS account_name,
|
|
382
|
+
s.account_id AS account_id,
|
|
383
|
+
s.inserted_at AS created,
|
|
384
|
+
s.updated_at AS updated,
|
|
385
|
+
s.current_sort AS current_sort,
|
|
386
|
+
a.*,
|
|
387
|
+
min_picture_ts AS mints,
|
|
388
|
+
max_picture_ts AS maxts,
|
|
389
|
+
nb_pictures AS nbpic,
|
|
390
|
+
s.user_agent,
|
|
391
|
+
ROUND(ST_Length(s.geom::geography)) / 1000 as length_km,
|
|
392
|
+
s.computed_h_pixel_density,
|
|
393
|
+
s.computed_gps_accuracy
|
|
394
|
+
FROM sequences s
|
|
395
|
+
JOIN accounts ON s.account_id = accounts.id, (
|
|
331
396
|
SELECT
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
WHERE s.id = %(id)s
|
|
360
|
-
AND (s.status != 'hidden' OR s.account_id = %(account)s)
|
|
361
|
-
AND s.status != 'deleted'
|
|
362
|
-
""",
|
|
363
|
-
params,
|
|
364
|
-
).fetchone()
|
|
365
|
-
|
|
366
|
-
if record is None:
|
|
367
|
-
raise errors.InvalidAPIUsage("Collection doesn't exist", status_code=404)
|
|
368
|
-
|
|
369
|
-
return (
|
|
370
|
-
dbSequenceToStacCollection(record),
|
|
371
|
-
200,
|
|
372
|
-
{
|
|
373
|
-
"Content-Type": "application/json",
|
|
374
|
-
},
|
|
375
|
-
)
|
|
397
|
+
array_agg(DISTINCT jsonb_build_object(
|
|
398
|
+
'make', metadata->>'make',
|
|
399
|
+
'model', metadata->>'model',
|
|
400
|
+
'focal_length', metadata->>'focal_length',
|
|
401
|
+
'field_of_view', metadata->>'field_of_view'
|
|
402
|
+
)) AS metas
|
|
403
|
+
FROM pictures p
|
|
404
|
+
JOIN sequences_pictures sp ON sp.seq_id = %(id)s AND sp.pic_id = p.id
|
|
405
|
+
) a
|
|
406
|
+
WHERE s.id = %(id)s
|
|
407
|
+
AND (s.status != 'hidden' OR s.account_id = %(account)s)
|
|
408
|
+
AND s.status != 'deleted'
|
|
409
|
+
""",
|
|
410
|
+
params,
|
|
411
|
+
row_factory=dict_row,
|
|
412
|
+
)
|
|
413
|
+
|
|
414
|
+
if record is None:
|
|
415
|
+
raise errors.InvalidAPIUsage(_("Collection doesn't exist"), status_code=404)
|
|
416
|
+
|
|
417
|
+
return (
|
|
418
|
+
dbSequenceToStacCollection(record),
|
|
419
|
+
200,
|
|
420
|
+
{
|
|
421
|
+
"Content-Type": "application/json",
|
|
422
|
+
},
|
|
423
|
+
)
|
|
376
424
|
|
|
377
425
|
|
|
378
426
|
@bp.route("/collections/<uuid:collectionId>/thumb.jpg", methods=["GET"])
|
|
@@ -405,28 +453,27 @@ def getCollectionThumbnail(collectionId):
|
|
|
405
453
|
"account": account.id if account is not None else None,
|
|
406
454
|
}
|
|
407
455
|
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
).fetchone()
|
|
456
|
+
records = db.fetchone(
|
|
457
|
+
current_app,
|
|
458
|
+
"""SELECT
|
|
459
|
+
sp.pic_id
|
|
460
|
+
FROM sequences_pictures sp
|
|
461
|
+
JOIN pictures p ON sp.pic_id = p.id
|
|
462
|
+
JOIN sequences s ON sp.seq_id = s.id
|
|
463
|
+
WHERE
|
|
464
|
+
sp.seq_id = %(seq)s
|
|
465
|
+
AND (p.status = 'ready' OR p.account_id = %(account)s)
|
|
466
|
+
AND is_sequence_visible_by_user(s, %(account)s)
|
|
467
|
+
ORDER BY RANK ASC
|
|
468
|
+
LIMIT 1""",
|
|
469
|
+
params,
|
|
470
|
+
row_factory=dict_row,
|
|
471
|
+
)
|
|
425
472
|
|
|
426
|
-
|
|
427
|
-
|
|
473
|
+
if records is None:
|
|
474
|
+
raise errors.InvalidAPIUsage(_("Impossible to find a thumbnail for the collection"), status_code=404)
|
|
428
475
|
|
|
429
|
-
|
|
476
|
+
return utils.pictures.sendThumbnail(records["pic_id"], "jpg")
|
|
430
477
|
|
|
431
478
|
|
|
432
479
|
@bp.route("/collections", methods=["POST"])
|
|
@@ -436,6 +483,13 @@ def postCollection(account=None):
|
|
|
436
483
|
---
|
|
437
484
|
tags:
|
|
438
485
|
- Upload
|
|
486
|
+
parameters:
|
|
487
|
+
- in: header
|
|
488
|
+
name: User-Agent
|
|
489
|
+
required: false
|
|
490
|
+
schema:
|
|
491
|
+
type: string
|
|
492
|
+
description: An explicit User-Agent value is prefered if you create a production-ready tool, formatted like "PanoramaxCLI/1.0"
|
|
439
493
|
requestBody:
|
|
440
494
|
content:
|
|
441
495
|
application/json:
|
|
@@ -471,7 +525,7 @@ def postCollection(account=None):
|
|
|
471
525
|
|
|
472
526
|
# Create sequence folder
|
|
473
527
|
accountId = accountIdOrDefault(account)
|
|
474
|
-
seqId = sequences.createSequence(metadata, accountId)
|
|
528
|
+
seqId = sequences.createSequence(metadata, accountId, request.user_agent.string)
|
|
475
529
|
|
|
476
530
|
# Return created sequence
|
|
477
531
|
return (
|
|
@@ -537,19 +591,19 @@ def patchCollection(collectionId, account):
|
|
|
537
591
|
if visible in ["true", "false"]:
|
|
538
592
|
visible = visible == "true"
|
|
539
593
|
else:
|
|
540
|
-
raise errors.InvalidAPIUsage("Picture visibility parameter (visible) should be either unset, true or false", status_code=400)
|
|
594
|
+
raise errors.InvalidAPIUsage(_("Picture visibility parameter (visible) should be either unset, true or false"), status_code=400)
|
|
541
595
|
|
|
542
596
|
# Check if title is valid
|
|
543
597
|
newTitle = metadata.get("title")
|
|
544
598
|
if newTitle is not None:
|
|
545
599
|
if not (isinstance(newTitle, str) and len(newTitle) <= 250):
|
|
546
|
-
raise errors.InvalidAPIUsage("Sequence title is not valid, should be a string with a max of 250 characters", status_code=400)
|
|
600
|
+
raise errors.InvalidAPIUsage(_("Sequence title is not valid, should be a string with a max of 250 characters"), status_code=400)
|
|
547
601
|
|
|
548
602
|
# Check if sortby is valid
|
|
549
603
|
sortby = metadata.get("sortby")
|
|
550
604
|
if sortby is not None:
|
|
551
605
|
if sortby not in ["+gpsdate", "-gpsdate", "+filedate", "-filedate", "+filename", "-filename"]:
|
|
552
|
-
raise errors.InvalidAPIUsage("Sort order parameter is invalid", status_code=400)
|
|
606
|
+
raise errors.InvalidAPIUsage(_("Sort order parameter is invalid"), status_code=400)
|
|
553
607
|
|
|
554
608
|
# Check if relative_heading is valid
|
|
555
609
|
relHeading = metadata.get("relative_heading")
|
|
@@ -559,94 +613,101 @@ def patchCollection(collectionId, account):
|
|
|
559
613
|
if relHeading < -180 or relHeading > 180:
|
|
560
614
|
raise ValueError()
|
|
561
615
|
except ValueError:
|
|
562
|
-
raise errors.InvalidAPIUsage(
|
|
616
|
+
raise errors.InvalidAPIUsage(
|
|
617
|
+
_("Relative heading is not valid, should be an integer in degrees from -180 to 180"), status_code=400
|
|
618
|
+
)
|
|
563
619
|
|
|
564
620
|
# If no parameter is changed, no need to contact DB, just return sequence as is
|
|
565
621
|
if {visible, newTitle, relHeading, sortby} == {None}:
|
|
566
622
|
return getCollection(collectionId)
|
|
567
623
|
|
|
568
624
|
# Check if sequence exists and if given account is authorized to edit
|
|
569
|
-
with
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
625
|
+
with db.conn(current_app) as conn:
|
|
626
|
+
with conn.transaction():
|
|
627
|
+
with conn.cursor(row_factory=dict_row) as cursor:
|
|
628
|
+
seq = cursor.execute(
|
|
629
|
+
"SELECT status, metadata, account_id, current_sort FROM sequences WHERE id = %s AND status != 'deleted'", [collectionId]
|
|
630
|
+
).fetchone()
|
|
631
|
+
|
|
632
|
+
# Sequence not found
|
|
633
|
+
if not seq:
|
|
634
|
+
raise errors.InvalidAPIUsage(_("Collection %(c)s wasn't found in database", c=collectionId), status_code=404)
|
|
635
|
+
|
|
636
|
+
# Account associated to sequence doesn't match current user
|
|
637
|
+
if account is not None and account.id != str(seq["account_id"]):
|
|
638
|
+
raise errors.InvalidAPIUsage(_("You're not authorized to edit this sequence"), status_code=403)
|
|
639
|
+
|
|
640
|
+
oldStatus = seq["status"]
|
|
641
|
+
oldMetadata = seq["metadata"]
|
|
642
|
+
oldTitle = oldMetadata.get("title")
|
|
643
|
+
|
|
644
|
+
# Check if sequence is in a preparing/broken/... state so no edit possible
|
|
645
|
+
if oldStatus not in ["ready", "hidden"]:
|
|
646
|
+
raise errors.InvalidAPIUsage(
|
|
647
|
+
_("Sequence %(c)s is in %(s)s state, its visibility can't be changed for now", c=collectionId, s=oldStatus),
|
|
648
|
+
status_code=400,
|
|
649
|
+
)
|
|
650
|
+
|
|
651
|
+
sqlUpdates = []
|
|
652
|
+
sqlParams = {"id": collectionId, "account": account.id}
|
|
653
|
+
|
|
654
|
+
if visible is not None:
|
|
655
|
+
newStatus = "ready" if visible is True else "hidden"
|
|
656
|
+
if newStatus != oldStatus:
|
|
657
|
+
sqlUpdates.append(SQL("status = %(status)s"))
|
|
658
|
+
sqlParams["status"] = newStatus
|
|
659
|
+
|
|
660
|
+
new_metadata = {}
|
|
661
|
+
if newTitle is not None and oldTitle != newTitle:
|
|
662
|
+
new_metadata["title"] = newTitle
|
|
663
|
+
if relHeading:
|
|
664
|
+
new_metadata["relative_heading"] = relHeading
|
|
665
|
+
|
|
666
|
+
if new_metadata:
|
|
667
|
+
sqlUpdates.append(SQL("metadata = metadata || %(new_metadata)s"))
|
|
668
|
+
from psycopg.types.json import Jsonb
|
|
669
|
+
|
|
670
|
+
sqlParams["new_metadata"] = Jsonb(new_metadata)
|
|
671
|
+
|
|
672
|
+
if sortby is not None:
|
|
673
|
+
sqlUpdates.append(SQL("current_sort = %(sort)s"))
|
|
674
|
+
sqlParams["sort"] = sortby
|
|
675
|
+
|
|
676
|
+
if len(sqlUpdates) > 0:
|
|
677
|
+
# Note: we set the field `last_account_to_edit` to track who changed the collection last (later we'll make it possible for everybody to edit some collection fields)
|
|
678
|
+
# setting this field will trigger the history tracking of the collection (using postgres trigger)
|
|
679
|
+
sqlUpdates.append(SQL("last_account_to_edit = %(account)s"))
|
|
680
|
+
|
|
681
|
+
cursor.execute(
|
|
682
|
+
SQL(
|
|
683
|
+
"""
|
|
684
|
+
UPDATE sequences
|
|
685
|
+
SET {updates}
|
|
686
|
+
WHERE id = %(id)s
|
|
687
|
+
"""
|
|
688
|
+
).format(updates=SQL(", ").join(sqlUpdates)),
|
|
689
|
+
sqlParams,
|
|
690
|
+
)
|
|
691
|
+
|
|
692
|
+
# Edits picture sort order
|
|
693
|
+
if sortby is not None:
|
|
694
|
+
direction = sequences.Direction(sortby[0])
|
|
695
|
+
order = sequences.CollectionSortOrder(sortby[1:])
|
|
696
|
+
sequences.sort_collection(cursor, collectionId, sequences.CollectionSort(order=order, direction=direction))
|
|
697
|
+
if not relHeading:
|
|
698
|
+
# if we do not plan to override headings specifically, we recompute headings that have not bee provided by the users
|
|
699
|
+
# with the new movement track
|
|
700
|
+
sequences.update_headings(cursor, collectionId, editingAccount=account.id)
|
|
701
|
+
|
|
702
|
+
# Edits relative heading of pictures in sequence
|
|
703
|
+
if relHeading is not None:
|
|
704
|
+
# New heading is computed based on sequence movement track
|
|
705
|
+
# We take each picture and its following, compute azimuth,
|
|
706
|
+
# then add given relative heading to offset picture heading.
|
|
707
|
+
# Last picture is computed based on previous one in sequence.
|
|
708
|
+
sequences.update_headings(
|
|
709
|
+
cursor, collectionId, relativeHeading=relHeading, updateOnlyMissing=False, editingAccount=account.id
|
|
710
|
+
)
|
|
650
711
|
|
|
651
712
|
# Redirect response to a classic GET
|
|
652
713
|
return getCollection(collectionId)
|
|
@@ -674,73 +735,13 @@ def deleteCollection(collectionId, account):
|
|
|
674
735
|
204:
|
|
675
736
|
description: The collection has been correctly deleted
|
|
676
737
|
"""
|
|
738
|
+
nb_updated = utils.sequences.delete_collection(collectionId, account)
|
|
677
739
|
|
|
678
|
-
#
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
# sequence not found
|
|
684
|
-
if not sequence:
|
|
685
|
-
raise errors.InvalidAPIUsage(f"Collection {collectionId} wasn't found in database", status_code=404)
|
|
686
|
-
|
|
687
|
-
# Account associated to sequence doesn't match current user
|
|
688
|
-
if account is not None and account.id != str(sequence[1]):
|
|
689
|
-
raise errors.InvalidAPIUsage("You're not authorized to edit this sequence", status_code=403)
|
|
690
|
-
|
|
691
|
-
logging.info(f"Asking for deletion of sequence {collectionId} and all its pictures")
|
|
692
|
-
|
|
693
|
-
# mark all the pictures as waiting for deletion for async removal as this can be quite long if the storage is slow if there are lots of pictures
|
|
694
|
-
# Note: To avoid a deadlock if some workers are currently also working on those picture to prepare them,
|
|
695
|
-
# the SQL queries are split in 2:
|
|
696
|
-
# - First a query to add the async deletion task to the queue.
|
|
697
|
-
# - Then a query changing the status of the picture to `waiting-for-delete`
|
|
698
|
-
#
|
|
699
|
-
# The trick there is that there can only be one task for a given picture (either preparing or deleting it)
|
|
700
|
-
# And the first query do a `ON CONFLICT DO UPDATE` to change the remaining `prepare` task to `delete`.
|
|
701
|
-
# So at the end of this query, we know that there are no more workers working on those pictures, so we can change their status
|
|
702
|
-
# without fearing a deadlock.
|
|
703
|
-
nb_updated = cursor.execute(
|
|
704
|
-
"""
|
|
705
|
-
WITH pic2rm AS (
|
|
706
|
-
SELECT pic_id FROM sequences_pictures WHERE seq_id = %(seq)s
|
|
707
|
-
),
|
|
708
|
-
picWithoutOtherSeq AS (
|
|
709
|
-
SELECT pic_id FROM pic2rm
|
|
710
|
-
EXCEPT
|
|
711
|
-
SELECT pic_id FROM sequences_pictures WHERE pic_id IN (SELECT pic_id FROM pic2rm) AND seq_id != %(seq)s
|
|
712
|
-
)
|
|
713
|
-
INSERT INTO pictures_to_process(picture_id, task)
|
|
714
|
-
SELECT pic_id, 'delete' FROM picWithoutOtherSeq
|
|
715
|
-
ON CONFLICT (picture_id) DO UPDATE SET task = 'delete'
|
|
716
|
-
""",
|
|
717
|
-
{"seq": collectionId},
|
|
718
|
-
).rowcount
|
|
719
|
-
|
|
720
|
-
# after the task have been added to the queue, we mark all picture for deletion
|
|
721
|
-
cursor.execute(
|
|
722
|
-
"""
|
|
723
|
-
WITH pic2rm AS (
|
|
724
|
-
SELECT pic_id FROM sequences_pictures WHERE seq_id = %(seq)s
|
|
725
|
-
),
|
|
726
|
-
picWithoutOtherSeq AS (
|
|
727
|
-
SELECT pic_id FROM pic2rm
|
|
728
|
-
EXCEPT
|
|
729
|
-
SELECT pic_id FROM sequences_pictures WHERE pic_id IN (SELECT pic_id FROM pic2rm) AND seq_id != %(seq)s
|
|
730
|
-
)
|
|
731
|
-
UPDATE pictures SET status = 'waiting-for-delete' WHERE id IN (SELECT pic_id FROM picWithoutOtherSeq)
|
|
732
|
-
""",
|
|
733
|
-
{"seq": collectionId},
|
|
734
|
-
).rowcount
|
|
735
|
-
|
|
736
|
-
cursor.execute("UPDATE sequences SET status = 'deleted' WHERE id = %s", [collectionId])
|
|
737
|
-
conn.commit()
|
|
738
|
-
|
|
739
|
-
# add background task if needed, to really delete pictures
|
|
740
|
-
for _ in range(nb_updated):
|
|
741
|
-
runner_pictures.background_processor.process_pictures()
|
|
742
|
-
|
|
743
|
-
return "", 204
|
|
740
|
+
# add background task if needed, to really delete pictures
|
|
741
|
+
for _ in range(nb_updated):
|
|
742
|
+
current_app.background_processor.process_pictures()
|
|
743
|
+
|
|
744
|
+
return "", 204
|
|
744
745
|
|
|
745
746
|
|
|
746
747
|
@bp.route("/collections/<uuid:collectionId>/geovisio_status")
|
|
@@ -767,22 +768,21 @@ def getCollectionImportStatus(collectionId):
|
|
|
767
768
|
|
|
768
769
|
account = auth.get_current_account()
|
|
769
770
|
params = {"seq_id": collectionId, "account": account.id if account is not None else None}
|
|
770
|
-
with
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
"""WITH
|
|
771
|
+
with db.cursor(current_app, row_factory=dict_row) as cursor:
|
|
772
|
+
sequence_status = cursor.execute(
|
|
773
|
+
SQL(
|
|
774
|
+
"""SELECT status
|
|
775
|
+
FROM sequences
|
|
776
|
+
WHERE id = %(seq_id)s
|
|
777
|
+
AND (status != 'hidden' OR account_id = %(account)s)-- show deleted sequence here"""
|
|
778
|
+
),
|
|
779
|
+
params,
|
|
780
|
+
).fetchone()
|
|
781
|
+
if sequence_status is None:
|
|
782
|
+
raise errors.InvalidAPIUsage(_("Sequence doesn't exists"), status_code=404)
|
|
783
|
+
|
|
784
|
+
pics_status = cursor.execute(
|
|
785
|
+
"""WITH
|
|
786
786
|
pic_jobs_stats AS (
|
|
787
787
|
SELECT
|
|
788
788
|
picture_id,
|
|
@@ -834,11 +834,11 @@ SELECT json_strip_nulls(
|
|
|
834
834
|
)
|
|
835
835
|
) as pic_status
|
|
836
836
|
FROM items i;""",
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
837
|
+
params,
|
|
838
|
+
).fetchall()
|
|
839
|
+
pics = [p["pic_status"] for p in pics_status if len(p["pic_status"]) > 0]
|
|
840
840
|
|
|
841
|
-
|
|
841
|
+
return {"status": sequence_status["status"], "items": pics}
|
|
842
842
|
|
|
843
843
|
|
|
844
844
|
@bp.route("/users/<uuid:userId>/collection")
|
|
@@ -915,52 +915,50 @@ def getUserCollection(userId, userIdMatchesAccount=False):
|
|
|
915
915
|
if collection_request.user_filter is None or "status" not in collection_request.user_filter.as_string(None):
|
|
916
916
|
# if the filter does not contains any `status` condition, we want to show only 'ready' collection to the general users, and non deleted one for the owner
|
|
917
917
|
if not userIdMatchesAccount:
|
|
918
|
-
meta_filter.extend([SQL("s.status = 'ready'")
|
|
918
|
+
meta_filter.extend([SQL("s.status = 'ready'")])
|
|
919
919
|
else:
|
|
920
920
|
meta_filter.append(SQL("s.status != 'deleted'"))
|
|
921
921
|
|
|
922
922
|
# Check user account parameter
|
|
923
|
-
with
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
""
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
if
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
else:
|
|
963
|
-
raise errors.InvalidAPIUsage(f"No matching sequences found", 404)
|
|
923
|
+
with db.cursor(current_app, row_factory=dict_row) as cursor:
|
|
924
|
+
userName = cursor.execute("SELECT name FROM accounts WHERE id = %s", [userId]).fetchone()
|
|
925
|
+
|
|
926
|
+
if not userName:
|
|
927
|
+
raise errors.InvalidAPIUsage(_("Impossible to find user %(u)s", u=userId))
|
|
928
|
+
userName = userName["name"]
|
|
929
|
+
|
|
930
|
+
meta_collection = cursor.execute(
|
|
931
|
+
SQL(
|
|
932
|
+
"""SELECT
|
|
933
|
+
SUM(s.nb_pictures) AS nbpic,
|
|
934
|
+
COUNT(s.id) AS nbseq,
|
|
935
|
+
MIN(s.min_picture_ts) AS mints,
|
|
936
|
+
MAX(s.max_picture_ts) AS maxts,
|
|
937
|
+
MIN(GREATEST(-180, ST_XMin(s.bbox))) AS minx,
|
|
938
|
+
MIN(GREATEST(-90, ST_YMin(s.bbox))) AS miny,
|
|
939
|
+
MAX(LEAST(180, ST_XMax(s.bbox))) AS maxx,
|
|
940
|
+
MAX(LEAST(90, ST_YMax(s.bbox))) AS maxy,
|
|
941
|
+
MIN(s.inserted_at) AS created,
|
|
942
|
+
MAX(s.updated_at) AS updated,
|
|
943
|
+
MIN({order_column}) AS min_order,
|
|
944
|
+
MAX({order_column}) AS max_order,
|
|
945
|
+
ROUND(SUM(ST_Length(s.geom::geography))) / 1000 AS length_km
|
|
946
|
+
FROM sequences s
|
|
947
|
+
WHERE {filter}
|
|
948
|
+
"""
|
|
949
|
+
).format(
|
|
950
|
+
filter=SQL(" AND ").join(meta_filter),
|
|
951
|
+
order_column=collection_request.sort_by.fields[0].field.sql_filter,
|
|
952
|
+
),
|
|
953
|
+
params={"account": userId},
|
|
954
|
+
).fetchone()
|
|
955
|
+
|
|
956
|
+
if not meta_collection or meta_collection["created"] is None:
|
|
957
|
+
# No data found, trying to give the most meaningful error message
|
|
958
|
+
if collection_request.user_filter is None:
|
|
959
|
+
raise errors.InvalidAPIUsage(_("No data loaded for user %(u)s", u=userId), 404)
|
|
960
|
+
else:
|
|
961
|
+
raise errors.InvalidAPIUsage(_("No matching sequences found"), 404)
|
|
964
962
|
|
|
965
963
|
collections = get_collections(collection_request)
|
|
966
964
|
|
|
@@ -986,6 +984,7 @@ def getUserCollection(userId, userIdMatchesAccount=False):
|
|
|
986
984
|
"spatial": {"bbox": [[s["minx"] or -180.0, s["miny"] or -90.0, s["maxx"] or 180.0, s["maxy"] or 90.0]]},
|
|
987
985
|
},
|
|
988
986
|
"geovisio:status": s["status"] if userIdMatchesAccount else None,
|
|
987
|
+
"geovisio:length_km": s.get("length_km"),
|
|
989
988
|
}
|
|
990
989
|
)
|
|
991
990
|
for s in collections.collections
|
|
@@ -996,6 +995,7 @@ def getUserCollection(userId, userIdMatchesAccount=False):
|
|
|
996
995
|
"id": f"user:{userId}",
|
|
997
996
|
"name": f"{userName}'s sequences",
|
|
998
997
|
"account_name": userName,
|
|
998
|
+
"account_id": userId,
|
|
999
999
|
}
|
|
1000
1000
|
)
|
|
1001
1001
|
collection = dbSequenceToStacCollection(meta_collection, description=f"List of all sequences of user {userName}")
|