geovisio 2.6.0__py3-none-any.whl → 2.7.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- geovisio/__init__.py +36 -7
- 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 +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 +92 -68
- geovisio/utils/reports.py +171 -0
- geovisio/utils/sequences.py +264 -126
- geovisio/utils/tokens.py +37 -42
- geovisio/utils/upload_set.py +654 -0
- geovisio/web/auth.py +37 -37
- geovisio/web/collections.py +286 -302
- geovisio/web/configuration.py +14 -0
- geovisio/web/docs.py +241 -14
- geovisio/web/excluded_areas.py +377 -0
- geovisio/web/items.py +156 -108
- geovisio/web/map.py +20 -20
- 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 +768 -0
- geovisio/web/users.py +100 -73
- geovisio/web/utils.py +28 -9
- geovisio/workers/runner_pictures.py +252 -204
- {geovisio-2.6.0.dist-info → geovisio-2.7.0.dist-info}/METADATA +16 -13
- geovisio-2.7.0.dist-info/RECORD +66 -0
- geovisio-2.6.0.dist-info/RECORD +0 -41
- {geovisio-2.6.0.dist-info → geovisio-2.7.0.dist-info}/LICENSE +0 -0
- {geovisio-2.6.0.dist-info → geovisio-2.7.0.dist-info}/WHEEL +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,6 +77,7 @@ 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",
|
|
@@ -65,10 +90,14 @@ def dbSequenceToStacCollection(dbSeq, description="A sequence of geolocated pict
|
|
|
65
90
|
"license": current_app.config["API_PICTURES_LICENSE_SPDX_ID"],
|
|
66
91
|
"created": dbTsToStac(dbSeq["created"]),
|
|
67
92
|
"updated": dbTsToStac(dbSeq.get("updated")),
|
|
68
|
-
"geovisio:status":
|
|
93
|
+
"geovisio:status": (
|
|
94
|
+
dbSeq.get("status") if dbSeq.get("status") != "ready" else None
|
|
95
|
+
), # we do not want to add a `geovisio:status` = 'ready', we only use it for hidden/deleted status
|
|
69
96
|
"geovisio:sorted-by": dbSeq.get("current_sort"),
|
|
97
|
+
"geovisio:upload-software": userAgentToClient(dbSeq.get("user_agent")).value,
|
|
98
|
+
"geovisio:length_km": dbSeq.get("length_km"),
|
|
70
99
|
"providers": [
|
|
71
|
-
{"name": dbSeq["account_name"], "roles": ["producer"]},
|
|
100
|
+
{"name": dbSeq["account_name"], "roles": ["producer"], "id": str(dbSeq["account_id"])},
|
|
72
101
|
],
|
|
73
102
|
"extent": {
|
|
74
103
|
"spatial": {"bbox": [[dbSeq["minx"] or -180.0, dbSeq["miny"] or -90.0, dbSeq["maxx"] or 180.0, dbSeq["maxy"] or 90.0]]},
|
|
@@ -82,7 +111,7 @@ def dbSequenceToStacCollection(dbSeq, description="A sequence of geolocated pict
|
|
|
82
111
|
},
|
|
83
112
|
},
|
|
84
113
|
"summaries": cleanNoneInDict({"pers:interior_orientation": dbSeq.get("metas")}),
|
|
85
|
-
"stats:items": removeNoneInDict({"count":
|
|
114
|
+
"stats:items": removeNoneInDict({"count": nb_pic}),
|
|
86
115
|
"links": cleanNoneInList(
|
|
87
116
|
[
|
|
88
117
|
(
|
|
@@ -230,10 +259,10 @@ def getAllCollections():
|
|
|
230
259
|
created_before = args.get("created_before")
|
|
231
260
|
|
|
232
261
|
if created_after:
|
|
233
|
-
collection_request.created_after = parse_datetime(created_after, error=
|
|
262
|
+
collection_request.created_after = parse_datetime(created_after, error="Invalid `created_after` argument", fallback_as_UTC=True)
|
|
234
263
|
|
|
235
264
|
if created_before:
|
|
236
|
-
collection_request.created_before = parse_datetime(created_before, error=
|
|
265
|
+
collection_request.created_before = parse_datetime(created_before, error="Invalid `created_before` argument", fallback_as_UTC=True)
|
|
237
266
|
|
|
238
267
|
links = [
|
|
239
268
|
get_root_link(),
|
|
@@ -249,16 +278,16 @@ def getAllCollections():
|
|
|
249
278
|
),
|
|
250
279
|
},
|
|
251
280
|
]
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
281
|
+
|
|
282
|
+
with db.cursor(current_app, row_factory=dict_row) as cursor:
|
|
283
|
+
stats = cursor.execute("SELECT min(inserted_at) as min, max(inserted_at) as max FROM sequences").fetchone()
|
|
284
|
+
if stats is None:
|
|
285
|
+
return ({"collections": [], "links": links}, 200, {"Content-Type": "application/json"})
|
|
286
|
+
datasetBounds = Bounds(min=stats["min"], max=stats["max"])
|
|
287
|
+
if collection_request.created_after and collection_request.created_after > datasetBounds.max:
|
|
288
|
+
raise errors.InvalidAPIUsage(_("There is no collection created after %(d)s", d=collection_request.created_after))
|
|
289
|
+
if collection_request.created_before and collection_request.created_before < datasetBounds.min:
|
|
290
|
+
raise errors.InvalidAPIUsage(_("There is no collection created before %(d)s", d=collection_request.created_before))
|
|
262
291
|
|
|
263
292
|
db_collections = get_collections(collection_request)
|
|
264
293
|
|
|
@@ -324,55 +353,58 @@ def getCollection(collectionId):
|
|
|
324
353
|
"account": account.id if account is not None else None,
|
|
325
354
|
}
|
|
326
355
|
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
356
|
+
record = db.fetchone(
|
|
357
|
+
current_app,
|
|
358
|
+
"""
|
|
359
|
+
SELECT
|
|
360
|
+
s.id,
|
|
361
|
+
s.metadata->>'title' AS name,
|
|
362
|
+
ST_XMin(s.bbox) AS minx,
|
|
363
|
+
ST_YMin(s.bbox) AS miny,
|
|
364
|
+
ST_XMax(s.bbox) AS maxx,
|
|
365
|
+
ST_YMax(s.bbox) AS maxy,
|
|
366
|
+
s.status AS status,
|
|
367
|
+
accounts.name AS account_name,
|
|
368
|
+
s.account_id AS account_id,
|
|
369
|
+
s.inserted_at AS created,
|
|
370
|
+
s.updated_at AS updated,
|
|
371
|
+
s.current_sort AS current_sort,
|
|
372
|
+
a.*,
|
|
373
|
+
min_picture_ts AS mints,
|
|
374
|
+
max_picture_ts AS maxts,
|
|
375
|
+
nb_pictures AS nbpic,
|
|
376
|
+
s.user_agent,
|
|
377
|
+
ROUND(ST_Length(s.geom::geography)) / 1000 as length_km
|
|
378
|
+
FROM sequences s
|
|
379
|
+
JOIN accounts ON s.account_id = accounts.id, (
|
|
331
380
|
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
|
-
)
|
|
381
|
+
array_agg(DISTINCT jsonb_build_object(
|
|
382
|
+
'make', metadata->>'make',
|
|
383
|
+
'model', metadata->>'model',
|
|
384
|
+
'focal_length', metadata->>'focal_length',
|
|
385
|
+
'field_of_view', metadata->>'field_of_view'
|
|
386
|
+
)) AS metas
|
|
387
|
+
FROM pictures p
|
|
388
|
+
JOIN sequences_pictures sp ON sp.seq_id = %(id)s AND sp.pic_id = p.id
|
|
389
|
+
) a
|
|
390
|
+
WHERE s.id = %(id)s
|
|
391
|
+
AND (s.status != 'hidden' OR s.account_id = %(account)s)
|
|
392
|
+
AND s.status != 'deleted'
|
|
393
|
+
""",
|
|
394
|
+
params,
|
|
395
|
+
row_factory=dict_row,
|
|
396
|
+
)
|
|
397
|
+
|
|
398
|
+
if record is None:
|
|
399
|
+
raise errors.InvalidAPIUsage(_("Collection doesn't exist"), status_code=404)
|
|
400
|
+
|
|
401
|
+
return (
|
|
402
|
+
dbSequenceToStacCollection(record),
|
|
403
|
+
200,
|
|
404
|
+
{
|
|
405
|
+
"Content-Type": "application/json",
|
|
406
|
+
},
|
|
407
|
+
)
|
|
376
408
|
|
|
377
409
|
|
|
378
410
|
@bp.route("/collections/<uuid:collectionId>/thumb.jpg", methods=["GET"])
|
|
@@ -405,28 +437,27 @@ def getCollectionThumbnail(collectionId):
|
|
|
405
437
|
"account": account.id if account is not None else None,
|
|
406
438
|
}
|
|
407
439
|
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
).fetchone()
|
|
440
|
+
records = db.fetchone(
|
|
441
|
+
current_app,
|
|
442
|
+
"""SELECT
|
|
443
|
+
sp.pic_id
|
|
444
|
+
FROM sequences_pictures sp
|
|
445
|
+
JOIN pictures p ON sp.pic_id = p.id
|
|
446
|
+
JOIN sequences s ON sp.seq_id = s.id
|
|
447
|
+
WHERE
|
|
448
|
+
sp.seq_id = %(seq)s
|
|
449
|
+
AND (p.status = 'ready' OR p.account_id = %(account)s)
|
|
450
|
+
AND is_sequence_visible_by_user(s, %(account)s)
|
|
451
|
+
ORDER BY RANK ASC
|
|
452
|
+
LIMIT 1""",
|
|
453
|
+
params,
|
|
454
|
+
row_factory=dict_row,
|
|
455
|
+
)
|
|
425
456
|
|
|
426
|
-
|
|
427
|
-
|
|
457
|
+
if records is None:
|
|
458
|
+
raise errors.InvalidAPIUsage(_("Impossible to find a thumbnail for the collection"), status_code=404)
|
|
428
459
|
|
|
429
|
-
|
|
460
|
+
return utils.pictures.sendThumbnail(records["pic_id"], "jpg")
|
|
430
461
|
|
|
431
462
|
|
|
432
463
|
@bp.route("/collections", methods=["POST"])
|
|
@@ -436,6 +467,13 @@ def postCollection(account=None):
|
|
|
436
467
|
---
|
|
437
468
|
tags:
|
|
438
469
|
- Upload
|
|
470
|
+
parameters:
|
|
471
|
+
- in: header
|
|
472
|
+
name: User-Agent
|
|
473
|
+
required: false
|
|
474
|
+
schema:
|
|
475
|
+
type: string
|
|
476
|
+
description: An explicit User-Agent value is prefered if you create a production-ready tool, formatted like "PanoramaxCLI/1.0"
|
|
439
477
|
requestBody:
|
|
440
478
|
content:
|
|
441
479
|
application/json:
|
|
@@ -471,7 +509,7 @@ def postCollection(account=None):
|
|
|
471
509
|
|
|
472
510
|
# Create sequence folder
|
|
473
511
|
accountId = accountIdOrDefault(account)
|
|
474
|
-
seqId = sequences.createSequence(metadata, accountId)
|
|
512
|
+
seqId = sequences.createSequence(metadata, accountId, request.user_agent.string)
|
|
475
513
|
|
|
476
514
|
# Return created sequence
|
|
477
515
|
return (
|
|
@@ -537,19 +575,19 @@ def patchCollection(collectionId, account):
|
|
|
537
575
|
if visible in ["true", "false"]:
|
|
538
576
|
visible = visible == "true"
|
|
539
577
|
else:
|
|
540
|
-
raise errors.InvalidAPIUsage("Picture visibility parameter (visible) should be either unset, true or false", status_code=400)
|
|
578
|
+
raise errors.InvalidAPIUsage(_("Picture visibility parameter (visible) should be either unset, true or false"), status_code=400)
|
|
541
579
|
|
|
542
580
|
# Check if title is valid
|
|
543
581
|
newTitle = metadata.get("title")
|
|
544
582
|
if newTitle is not None:
|
|
545
583
|
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)
|
|
584
|
+
raise errors.InvalidAPIUsage(_("Sequence title is not valid, should be a string with a max of 250 characters"), status_code=400)
|
|
547
585
|
|
|
548
586
|
# Check if sortby is valid
|
|
549
587
|
sortby = metadata.get("sortby")
|
|
550
588
|
if sortby is not None:
|
|
551
589
|
if sortby not in ["+gpsdate", "-gpsdate", "+filedate", "-filedate", "+filename", "-filename"]:
|
|
552
|
-
raise errors.InvalidAPIUsage("Sort order parameter is invalid", status_code=400)
|
|
590
|
+
raise errors.InvalidAPIUsage(_("Sort order parameter is invalid"), status_code=400)
|
|
553
591
|
|
|
554
592
|
# Check if relative_heading is valid
|
|
555
593
|
relHeading = metadata.get("relative_heading")
|
|
@@ -559,94 +597,101 @@ def patchCollection(collectionId, account):
|
|
|
559
597
|
if relHeading < -180 or relHeading > 180:
|
|
560
598
|
raise ValueError()
|
|
561
599
|
except ValueError:
|
|
562
|
-
raise errors.InvalidAPIUsage(
|
|
600
|
+
raise errors.InvalidAPIUsage(
|
|
601
|
+
_("Relative heading is not valid, should be an integer in degrees from -180 to 180"), status_code=400
|
|
602
|
+
)
|
|
563
603
|
|
|
564
604
|
# If no parameter is changed, no need to contact DB, just return sequence as is
|
|
565
605
|
if {visible, newTitle, relHeading, sortby} == {None}:
|
|
566
606
|
return getCollection(collectionId)
|
|
567
607
|
|
|
568
608
|
# 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
|
-
|
|
609
|
+
with db.conn(current_app) as conn:
|
|
610
|
+
with conn.transaction():
|
|
611
|
+
with conn.cursor(row_factory=dict_row) as cursor:
|
|
612
|
+
seq = cursor.execute(
|
|
613
|
+
"SELECT status, metadata, account_id, current_sort FROM sequences WHERE id = %s AND status != 'deleted'", [collectionId]
|
|
614
|
+
).fetchone()
|
|
615
|
+
|
|
616
|
+
# Sequence not found
|
|
617
|
+
if not seq:
|
|
618
|
+
raise errors.InvalidAPIUsage(_("Collection %(c)s wasn't found in database", c=collectionId), status_code=404)
|
|
619
|
+
|
|
620
|
+
# Account associated to sequence doesn't match current user
|
|
621
|
+
if account is not None and account.id != str(seq["account_id"]):
|
|
622
|
+
raise errors.InvalidAPIUsage(_("You're not authorized to edit this sequence"), status_code=403)
|
|
623
|
+
|
|
624
|
+
oldStatus = seq["status"]
|
|
625
|
+
oldMetadata = seq["metadata"]
|
|
626
|
+
oldTitle = oldMetadata.get("title")
|
|
627
|
+
|
|
628
|
+
# Check if sequence is in a preparing/broken/... state so no edit possible
|
|
629
|
+
if oldStatus not in ["ready", "hidden"]:
|
|
630
|
+
raise errors.InvalidAPIUsage(
|
|
631
|
+
_("Sequence %(c)s is in %(s)s state, its visibility can't be changed for now", c=collectionId, s=oldStatus),
|
|
632
|
+
status_code=400,
|
|
633
|
+
)
|
|
634
|
+
|
|
635
|
+
sqlUpdates = []
|
|
636
|
+
sqlParams = {"id": collectionId, "account": account.id}
|
|
637
|
+
|
|
638
|
+
if visible is not None:
|
|
639
|
+
newStatus = "ready" if visible is True else "hidden"
|
|
640
|
+
if newStatus != oldStatus:
|
|
641
|
+
sqlUpdates.append(SQL("status = %(status)s"))
|
|
642
|
+
sqlParams["status"] = newStatus
|
|
643
|
+
|
|
644
|
+
new_metadata = {}
|
|
645
|
+
if newTitle is not None and oldTitle != newTitle:
|
|
646
|
+
new_metadata["title"] = newTitle
|
|
647
|
+
if relHeading:
|
|
648
|
+
new_metadata["relative_heading"] = relHeading
|
|
649
|
+
|
|
650
|
+
if new_metadata:
|
|
651
|
+
sqlUpdates.append(SQL("metadata = metadata || %(new_metadata)s"))
|
|
652
|
+
from psycopg.types.json import Jsonb
|
|
653
|
+
|
|
654
|
+
sqlParams["new_metadata"] = Jsonb(new_metadata)
|
|
655
|
+
|
|
656
|
+
if sortby is not None:
|
|
657
|
+
sqlUpdates.append(SQL("current_sort = %(sort)s"))
|
|
658
|
+
sqlParams["sort"] = sortby
|
|
659
|
+
|
|
660
|
+
if len(sqlUpdates) > 0:
|
|
661
|
+
# 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)
|
|
662
|
+
# setting this field will trigger the history tracking of the collection (using postgres trigger)
|
|
663
|
+
sqlUpdates.append(SQL("last_account_to_edit = %(account)s"))
|
|
664
|
+
|
|
665
|
+
cursor.execute(
|
|
666
|
+
SQL(
|
|
667
|
+
"""
|
|
668
|
+
UPDATE sequences
|
|
669
|
+
SET {updates}
|
|
670
|
+
WHERE id = %(id)s
|
|
671
|
+
"""
|
|
672
|
+
).format(updates=SQL(", ").join(sqlUpdates)),
|
|
673
|
+
sqlParams,
|
|
674
|
+
)
|
|
675
|
+
|
|
676
|
+
# Edits picture sort order
|
|
677
|
+
if sortby is not None:
|
|
678
|
+
direction = sequences.Direction(sortby[0])
|
|
679
|
+
order = sequences.CollectionSortOrder(sortby[1:])
|
|
680
|
+
sequences.sort_collection(cursor, collectionId, sequences.CollectionSort(order=order, direction=direction))
|
|
681
|
+
if not relHeading:
|
|
682
|
+
# if we do not plan to override headings specifically, we recompute headings that have not bee provided by the users
|
|
683
|
+
# with the new movement track
|
|
684
|
+
sequences.update_headings(cursor, collectionId, editingAccount=account.id)
|
|
685
|
+
|
|
686
|
+
# Edits relative heading of pictures in sequence
|
|
687
|
+
if relHeading is not None:
|
|
688
|
+
# New heading is computed based on sequence movement track
|
|
689
|
+
# We take each picture and its following, compute azimuth,
|
|
690
|
+
# then add given relative heading to offset picture heading.
|
|
691
|
+
# Last picture is computed based on previous one in sequence.
|
|
692
|
+
sequences.update_headings(
|
|
693
|
+
cursor, collectionId, relativeHeading=relHeading, updateOnlyMissing=False, editingAccount=account.id
|
|
694
|
+
)
|
|
650
695
|
|
|
651
696
|
# Redirect response to a classic GET
|
|
652
697
|
return getCollection(collectionId)
|
|
@@ -674,73 +719,13 @@ def deleteCollection(collectionId, account):
|
|
|
674
719
|
204:
|
|
675
720
|
description: The collection has been correctly deleted
|
|
676
721
|
"""
|
|
722
|
+
nb_updated = utils.sequences.delete_collection(collectionId, account)
|
|
677
723
|
|
|
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
|
|
724
|
+
# add background task if needed, to really delete pictures
|
|
725
|
+
for _ in range(nb_updated):
|
|
726
|
+
current_app.background_processor.process_pictures()
|
|
727
|
+
|
|
728
|
+
return "", 204
|
|
744
729
|
|
|
745
730
|
|
|
746
731
|
@bp.route("/collections/<uuid:collectionId>/geovisio_status")
|
|
@@ -767,22 +752,21 @@ def getCollectionImportStatus(collectionId):
|
|
|
767
752
|
|
|
768
753
|
account = auth.get_current_account()
|
|
769
754
|
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
|
|
755
|
+
with db.cursor(current_app, row_factory=dict_row) as cursor:
|
|
756
|
+
sequence_status = cursor.execute(
|
|
757
|
+
SQL(
|
|
758
|
+
"""SELECT status
|
|
759
|
+
FROM sequences
|
|
760
|
+
WHERE id = %(seq_id)s
|
|
761
|
+
AND (status != 'hidden' OR account_id = %(account)s)-- show deleted sequence here"""
|
|
762
|
+
),
|
|
763
|
+
params,
|
|
764
|
+
).fetchone()
|
|
765
|
+
if sequence_status is None:
|
|
766
|
+
raise errors.InvalidAPIUsage(_("Sequence doesn't exists"), status_code=404)
|
|
767
|
+
|
|
768
|
+
pics_status = cursor.execute(
|
|
769
|
+
"""WITH
|
|
786
770
|
pic_jobs_stats AS (
|
|
787
771
|
SELECT
|
|
788
772
|
picture_id,
|
|
@@ -834,11 +818,11 @@ SELECT json_strip_nulls(
|
|
|
834
818
|
)
|
|
835
819
|
) as pic_status
|
|
836
820
|
FROM items i;""",
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
821
|
+
params,
|
|
822
|
+
).fetchall()
|
|
823
|
+
pics = [p["pic_status"] for p in pics_status if len(p["pic_status"]) > 0]
|
|
840
824
|
|
|
841
|
-
|
|
825
|
+
return {"status": sequence_status["status"], "items": pics}
|
|
842
826
|
|
|
843
827
|
|
|
844
828
|
@bp.route("/users/<uuid:userId>/collection")
|
|
@@ -915,52 +899,50 @@ def getUserCollection(userId, userIdMatchesAccount=False):
|
|
|
915
899
|
if collection_request.user_filter is None or "status" not in collection_request.user_filter.as_string(None):
|
|
916
900
|
# 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
901
|
if not userIdMatchesAccount:
|
|
918
|
-
meta_filter.extend([SQL("s.status = 'ready'")
|
|
902
|
+
meta_filter.extend([SQL("s.status = 'ready'")])
|
|
919
903
|
else:
|
|
920
904
|
meta_filter.append(SQL("s.status != 'deleted'"))
|
|
921
905
|
|
|
922
906
|
# 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)
|
|
907
|
+
with db.cursor(current_app, row_factory=dict_row) as cursor:
|
|
908
|
+
userName = cursor.execute("SELECT name FROM accounts WHERE id = %s", [userId]).fetchone()
|
|
909
|
+
|
|
910
|
+
if not userName:
|
|
911
|
+
raise errors.InvalidAPIUsage(_("Impossible to find user %(u)s", u=userId))
|
|
912
|
+
userName = userName["name"]
|
|
913
|
+
|
|
914
|
+
meta_collection = cursor.execute(
|
|
915
|
+
SQL(
|
|
916
|
+
"""SELECT
|
|
917
|
+
SUM(s.nb_pictures) AS nbpic,
|
|
918
|
+
COUNT(s.id) AS nbseq,
|
|
919
|
+
MIN(s.min_picture_ts) AS mints,
|
|
920
|
+
MAX(s.max_picture_ts) AS maxts,
|
|
921
|
+
MIN(GREATEST(-180, ST_XMin(s.bbox))) AS minx,
|
|
922
|
+
MIN(GREATEST(-90, ST_YMin(s.bbox))) AS miny,
|
|
923
|
+
MAX(LEAST(180, ST_XMax(s.bbox))) AS maxx,
|
|
924
|
+
MAX(LEAST(90, ST_YMax(s.bbox))) AS maxy,
|
|
925
|
+
MIN(s.inserted_at) AS created,
|
|
926
|
+
MAX(s.updated_at) AS updated,
|
|
927
|
+
MIN({order_column}) AS min_order,
|
|
928
|
+
MAX({order_column}) AS max_order,
|
|
929
|
+
ROUND(SUM(ST_Length(s.geom::geography))) / 1000 AS length_km
|
|
930
|
+
FROM sequences s
|
|
931
|
+
WHERE {filter}
|
|
932
|
+
"""
|
|
933
|
+
).format(
|
|
934
|
+
filter=SQL(" AND ").join(meta_filter),
|
|
935
|
+
order_column=collection_request.sort_by.fields[0].field.sql_filter,
|
|
936
|
+
),
|
|
937
|
+
params={"account": userId},
|
|
938
|
+
).fetchone()
|
|
939
|
+
|
|
940
|
+
if not meta_collection or meta_collection["created"] is None:
|
|
941
|
+
# No data found, trying to give the most meaningful error message
|
|
942
|
+
if collection_request.user_filter is None:
|
|
943
|
+
raise errors.InvalidAPIUsage(_("No data loaded for user %(u)s", u=userId), 404)
|
|
944
|
+
else:
|
|
945
|
+
raise errors.InvalidAPIUsage(_("No matching sequences found"), 404)
|
|
964
946
|
|
|
965
947
|
collections = get_collections(collection_request)
|
|
966
948
|
|
|
@@ -986,6 +968,7 @@ def getUserCollection(userId, userIdMatchesAccount=False):
|
|
|
986
968
|
"spatial": {"bbox": [[s["minx"] or -180.0, s["miny"] or -90.0, s["maxx"] or 180.0, s["maxy"] or 90.0]]},
|
|
987
969
|
},
|
|
988
970
|
"geovisio:status": s["status"] if userIdMatchesAccount else None,
|
|
971
|
+
"geovisio:length_km": s.get("length_km"),
|
|
989
972
|
}
|
|
990
973
|
)
|
|
991
974
|
for s in collections.collections
|
|
@@ -996,6 +979,7 @@ def getUserCollection(userId, userIdMatchesAccount=False):
|
|
|
996
979
|
"id": f"user:{userId}",
|
|
997
980
|
"name": f"{userName}'s sequences",
|
|
998
981
|
"account_name": userName,
|
|
982
|
+
"account_id": userId,
|
|
999
983
|
}
|
|
1000
984
|
)
|
|
1001
985
|
collection = dbSequenceToStacCollection(meta_collection, description=f"List of all sequences of user {userName}")
|