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/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)
|
|
723
|
+
|
|
724
|
+
# add background task if needed, to really delete pictures
|
|
725
|
+
for _ in range(nb_updated):
|
|
726
|
+
current_app.background_processor.process_pictures()
|
|
677
727
|
|
|
678
|
-
|
|
679
|
-
with psycopg.connect(current_app.config["DB_URL"]) as conn:
|
|
680
|
-
with conn.cursor() as cursor:
|
|
681
|
-
sequence = cursor.execute("SELECT status, account_id FROM sequences WHERE id = %s", [collectionId]).fetchone()
|
|
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
|
|
728
|
+
return "", 204
|
|
744
729
|
|
|
745
730
|
|
|
746
731
|
@bp.route("/collections/<uuid:collectionId>/geovisio_status")
|
|
@@ -767,10 +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
|
-
"""
|
|
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
|
|
774
770
|
pic_jobs_stats AS (
|
|
775
771
|
SELECT
|
|
776
772
|
picture_id,
|
|
@@ -796,47 +792,37 @@ pic_jobs_stats AS (
|
|
|
796
792
|
pic_jobs_stats.nb_errors,
|
|
797
793
|
pic_jobs_stats.last_job_finished_at
|
|
798
794
|
FROM sequences s
|
|
799
|
-
|
|
800
|
-
|
|
795
|
+
JOIN sequences_pictures sp ON sp.seq_id = s.id
|
|
796
|
+
JOIN pictures p ON sp.pic_id = p.id
|
|
801
797
|
LEFT JOIN pic_jobs_stats ON pic_jobs_stats.picture_id = p.id
|
|
802
798
|
WHERE
|
|
803
799
|
s.id = %(seq_id)s
|
|
804
800
|
AND (p IS NULL OR p.status != 'hidden' OR p.account_id = %(account)s)
|
|
805
|
-
AND (s.status != 'hidden' OR s.account_id = %(account)s) -- show deleted sequence here
|
|
806
801
|
ORDER BY s.id, sp.rank
|
|
807
802
|
)
|
|
808
|
-
SELECT
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
'nb_errors', i.nb_errors,
|
|
824
|
-
'processed_at', i.last_job_finished_at,
|
|
825
|
-
'rank', i.rank
|
|
826
|
-
)
|
|
827
|
-
)
|
|
803
|
+
SELECT json_strip_nulls(
|
|
804
|
+
json_build_object(
|
|
805
|
+
'id', i.id,
|
|
806
|
+
-- status is a bit deprecated, we'll split this field in more fields (like `processing_in_progress`, `hidden`, ...)
|
|
807
|
+
-- but we maintain it for retrocompatibility
|
|
808
|
+
'status', CASE
|
|
809
|
+
WHEN i.is_job_running IS TRUE THEN 'preparing'
|
|
810
|
+
WHEN i.last_job_error IS NOT NULL THEN 'broken'
|
|
811
|
+
ELSE i.status
|
|
812
|
+
END,
|
|
813
|
+
'processing_in_progress', i.is_job_running,
|
|
814
|
+
'process_error', i.last_job_error,
|
|
815
|
+
'nb_errors', i.nb_errors,
|
|
816
|
+
'processed_at', i.last_job_finished_at,
|
|
817
|
+
'rank', i.rank
|
|
828
818
|
)
|
|
829
|
-
)
|
|
830
|
-
FROM items i
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
).fetchall()
|
|
835
|
-
|
|
836
|
-
if len(sequence_status) == 0:
|
|
837
|
-
raise errors.InvalidAPIUsage("Sequence doesn't exists", status_code=404)
|
|
819
|
+
) as pic_status
|
|
820
|
+
FROM items i;""",
|
|
821
|
+
params,
|
|
822
|
+
).fetchall()
|
|
823
|
+
pics = [p["pic_status"] for p in pics_status if len(p["pic_status"]) > 0]
|
|
838
824
|
|
|
839
|
-
|
|
825
|
+
return {"status": sequence_status["status"], "items": pics}
|
|
840
826
|
|
|
841
827
|
|
|
842
828
|
@bp.route("/users/<uuid:userId>/collection")
|
|
@@ -913,52 +899,50 @@ def getUserCollection(userId, userIdMatchesAccount=False):
|
|
|
913
899
|
if collection_request.user_filter is None or "status" not in collection_request.user_filter.as_string(None):
|
|
914
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
|
|
915
901
|
if not userIdMatchesAccount:
|
|
916
|
-
meta_filter.extend([SQL("s.status = 'ready'")
|
|
902
|
+
meta_filter.extend([SQL("s.status = 'ready'")])
|
|
917
903
|
else:
|
|
918
904
|
meta_filter.append(SQL("s.status != 'deleted'"))
|
|
919
905
|
|
|
920
906
|
# Check user account parameter
|
|
921
|
-
with
|
|
922
|
-
|
|
923
|
-
|
|
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
|
-
if
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
else:
|
|
961
|
-
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)
|
|
962
946
|
|
|
963
947
|
collections = get_collections(collection_request)
|
|
964
948
|
|
|
@@ -984,6 +968,7 @@ def getUserCollection(userId, userIdMatchesAccount=False):
|
|
|
984
968
|
"spatial": {"bbox": [[s["minx"] or -180.0, s["miny"] or -90.0, s["maxx"] or 180.0, s["maxy"] or 90.0]]},
|
|
985
969
|
},
|
|
986
970
|
"geovisio:status": s["status"] if userIdMatchesAccount else None,
|
|
971
|
+
"geovisio:length_km": s.get("length_km"),
|
|
987
972
|
}
|
|
988
973
|
)
|
|
989
974
|
for s in collections.collections
|
|
@@ -994,6 +979,7 @@ def getUserCollection(userId, userIdMatchesAccount=False):
|
|
|
994
979
|
"id": f"user:{userId}",
|
|
995
980
|
"name": f"{userName}'s sequences",
|
|
996
981
|
"account_name": userName,
|
|
982
|
+
"account_id": userId,
|
|
997
983
|
}
|
|
998
984
|
)
|
|
999
985
|
collection = dbSequenceToStacCollection(meta_collection, description=f"List of all sequences of user {userName}")
|