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.
Files changed (59) hide show
  1. geovisio/__init__.py +38 -8
  2. geovisio/admin_cli/__init__.py +2 -2
  3. geovisio/admin_cli/db.py +8 -0
  4. geovisio/config_app.py +64 -0
  5. geovisio/db_migrations.py +24 -3
  6. geovisio/templates/main.html +14 -14
  7. geovisio/templates/viewer.html +3 -3
  8. geovisio/translations/de/LC_MESSAGES/messages.mo +0 -0
  9. geovisio/translations/de/LC_MESSAGES/messages.po +667 -0
  10. geovisio/translations/en/LC_MESSAGES/messages.mo +0 -0
  11. geovisio/translations/en/LC_MESSAGES/messages.po +730 -0
  12. geovisio/translations/es/LC_MESSAGES/messages.mo +0 -0
  13. geovisio/translations/es/LC_MESSAGES/messages.po +778 -0
  14. geovisio/translations/fi/LC_MESSAGES/messages.mo +0 -0
  15. geovisio/translations/fi/LC_MESSAGES/messages.po +589 -0
  16. geovisio/translations/fr/LC_MESSAGES/messages.mo +0 -0
  17. geovisio/translations/fr/LC_MESSAGES/messages.po +814 -0
  18. geovisio/translations/ko/LC_MESSAGES/messages.mo +0 -0
  19. geovisio/translations/ko/LC_MESSAGES/messages.po +685 -0
  20. geovisio/translations/messages.pot +686 -0
  21. geovisio/translations/nl/LC_MESSAGES/messages.mo +0 -0
  22. geovisio/translations/nl/LC_MESSAGES/messages.po +594 -0
  23. geovisio/utils/__init__.py +1 -1
  24. geovisio/utils/auth.py +50 -11
  25. geovisio/utils/db.py +65 -0
  26. geovisio/utils/excluded_areas.py +83 -0
  27. geovisio/utils/extent.py +30 -0
  28. geovisio/utils/fields.py +1 -1
  29. geovisio/utils/filesystems.py +0 -1
  30. geovisio/utils/link.py +14 -0
  31. geovisio/utils/params.py +20 -0
  32. geovisio/utils/pictures.py +94 -69
  33. geovisio/utils/reports.py +171 -0
  34. geovisio/utils/sequences.py +288 -126
  35. geovisio/utils/tokens.py +37 -42
  36. geovisio/utils/upload_set.py +654 -0
  37. geovisio/web/auth.py +50 -37
  38. geovisio/web/collections.py +305 -319
  39. geovisio/web/configuration.py +14 -0
  40. geovisio/web/docs.py +288 -12
  41. geovisio/web/excluded_areas.py +377 -0
  42. geovisio/web/items.py +203 -151
  43. geovisio/web/map.py +322 -106
  44. geovisio/web/params.py +69 -26
  45. geovisio/web/pictures.py +14 -31
  46. geovisio/web/reports.py +399 -0
  47. geovisio/web/rss.py +13 -7
  48. geovisio/web/stac.py +129 -121
  49. geovisio/web/tokens.py +105 -112
  50. geovisio/web/upload_set.py +768 -0
  51. geovisio/web/users.py +100 -73
  52. geovisio/web/utils.py +38 -9
  53. geovisio/workers/runner_pictures.py +278 -183
  54. geovisio-2.7.0.dist-info/METADATA +95 -0
  55. geovisio-2.7.0.dist-info/RECORD +66 -0
  56. geovisio-2.5.0.dist-info/METADATA +0 -115
  57. geovisio-2.5.0.dist-info/RECORD +0 -41
  58. {geovisio-2.5.0.dist-info → geovisio-2.7.0.dist-info}/LICENSE +0 -0
  59. {geovisio-2.5.0.dist-info → geovisio-2.7.0.dist-info}/WHEEL +0 -0
@@ -1,5 +1,5 @@
1
- import logging
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 geovisio.workers import runner_pictures
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": dbSeq.get("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": dbSeq.get("nbpic")}),
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=f"Invalid `created_after` argument", fallback_as_UTC=True)
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=f"Invalid `created_before` argument", fallback_as_UTC=True)
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
- with psycopg.connect(current_app.config["DB_URL"], row_factory=dict_row) as conn:
253
- with conn.cursor() as cursor:
254
- stats = cursor.execute("SELECT min(inserted_at) as min, max(inserted_at) as max FROM sequences").fetchone()
255
- if stats is None:
256
- return ({"collections": [], "links": links}, 200, {"Content-Type": "application/json"})
257
- datasetBounds = Bounds(min=stats["min"], max=stats["max"])
258
- if collection_request.created_after and collection_request.created_after > datasetBounds.max:
259
- raise errors.InvalidAPIUsage(f"There is no collection created after {collection_request.created_after}")
260
- if collection_request.created_before and collection_request.created_before < datasetBounds.min:
261
- raise errors.InvalidAPIUsage(f"There is no collection created before {collection_request.created_before}")
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
- with psycopg.connect(current_app.config["DB_URL"], row_factory=dict_row) as conn:
328
- with conn.cursor() as cursor:
329
- record = cursor.execute(
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
- s.id,
333
- s.metadata->>'title' AS name,
334
- ST_XMin(s.bbox) AS minx,
335
- ST_YMin(s.bbox) AS miny,
336
- ST_XMax(s.bbox) AS maxx,
337
- ST_YMax(s.bbox) AS maxy,
338
- s.status AS status,
339
- accounts.name AS account_name,
340
- s.inserted_at AS created,
341
- s.updated_at AS updated,
342
- s.current_sort AS current_sort,
343
- a.*
344
- FROM sequences s
345
- JOIN accounts ON s.account_id = accounts.id, (
346
- SELECT
347
- MIN(ts) as mints,
348
- MAX(ts) as maxts,
349
- array_agg(DISTINCT jsonb_build_object(
350
- 'make', metadata->>'make',
351
- 'model', metadata->>'model',
352
- 'focal_length', metadata->>'focal_length',
353
- 'field_of_view', metadata->>'field_of_view'
354
- )) AS metas,
355
- COUNT(*) AS nbpic
356
- FROM pictures p
357
- JOIN sequences_pictures sp ON sp.seq_id = %(id)s AND sp.pic_id = p.id
358
- ) a
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
- with psycopg.connect(current_app.config["DB_URL"], row_factory=dict_row) as conn:
409
- with conn.cursor() as cursor:
410
- records = cursor.execute(
411
- """
412
- SELECT
413
- sp.pic_id
414
- FROM sequences_pictures sp
415
- JOIN pictures p ON sp.pic_id = p.id
416
- JOIN sequences s ON sp.seq_id = s.id
417
- WHERE
418
- sp.seq_id = %(seq)s
419
- AND (p.status = 'ready' OR p.account_id = %(account)s)
420
- AND is_sequence_visible_by_user(s, %(account)s)
421
- LIMIT 1
422
- """,
423
- params,
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
- if records is None:
427
- raise errors.InvalidAPIUsage("Impossible to find a thumbnail for the collection", status_code=404)
457
+ if records is None:
458
+ raise errors.InvalidAPIUsage(_("Impossible to find a thumbnail for the collection"), status_code=404)
428
459
 
429
- return utils.pictures.sendThumbnail(records["pic_id"], "jpg")
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("Relative heading is not valid, should be an integer in degrees from -180 to 180", status_code=400)
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 psycopg.connect(current_app.config["DB_URL"], row_factory=dict_row) as conn, conn.cursor() as cursor:
570
- seq = cursor.execute("SELECT status, metadata, account_id, current_sort FROM sequences WHERE id = %s", [collectionId]).fetchone()
571
-
572
- # Sequence not found
573
- if not seq:
574
- raise errors.InvalidAPIUsage(f"Sequence {collectionId} wasn't found in database", status_code=404)
575
-
576
- # Account associated to sequence doesn't match current user
577
- if account is not None and account.id != str(seq["account_id"]):
578
- raise errors.InvalidAPIUsage("You're not authorized to edit this sequence", status_code=403)
579
-
580
- oldStatus = seq["status"]
581
- oldMetadata = seq["metadata"]
582
- oldTitle = oldMetadata.get("title")
583
-
584
- # Check if sequence is in a preparing/broken/... state so no edit possible
585
- if oldStatus not in ["ready", "hidden"]:
586
- raise errors.InvalidAPIUsage(
587
- f"Sequence {collectionId} is in {oldStatus} state, its visibility can't be changed for now", status_code=400
588
- )
589
-
590
- sqlUpdates = []
591
- sqlParams = {"id": collectionId, "account": account.id}
592
-
593
- if visible is not None:
594
- newStatus = "ready" if visible is True else "hidden"
595
- if newStatus != oldStatus:
596
- sqlUpdates.append(SQL("status = %(status)s"))
597
- sqlParams["status"] = newStatus
598
-
599
- new_metadata = {}
600
- if newTitle is not None and oldTitle != newTitle:
601
- new_metadata["title"] = newTitle
602
- if relHeading:
603
- new_metadata["relative_heading"] = relHeading
604
-
605
- if new_metadata:
606
- sqlUpdates.append(SQL("metadata = metadata || %(new_metadata)s"))
607
- from psycopg.types.json import Jsonb
608
-
609
- sqlParams["new_metadata"] = Jsonb(new_metadata)
610
-
611
- if sortby is not None:
612
- sqlUpdates.append(SQL("current_sort = %(sort)s"))
613
- sqlParams["sort"] = sortby
614
-
615
- if len(sqlUpdates) > 0:
616
- # 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)
617
- # setting this field will trigger the history tracking of the collection (using postgres trigger)
618
- sqlUpdates.append(SQL("last_account_to_edit = %(account)s"))
619
-
620
- cursor.execute(
621
- SQL(
622
- """
623
- UPDATE sequences
624
- SET {updates}
625
- WHERE id = %(id)s
626
- """
627
- ).format(updates=SQL(", ").join(sqlUpdates)),
628
- sqlParams,
629
- )
630
-
631
- # Edits picture sort order
632
- if sortby is not None:
633
- direction = sequences.Direction(sortby[0])
634
- order = sequences.CollectionSortOrder(sortby[1:])
635
- sequences.sort_collection(cursor, collectionId, sequences.CollectionSort(order=order, direction=direction))
636
- if not relHeading:
637
- # if we do not plan to override headings specifically, we recompute headings that have not bee provided by the users
638
- # with the new movement track
639
- sequences.update_headings(cursor, collectionId, editingAccount=account.id)
640
-
641
- # Edits relative heading of pictures in sequence
642
- if relHeading is not None:
643
- # New heading is computed based on sequence movement track
644
- # We take each picture and its following, compute azimuth,
645
- # then add given relative heading to offset picture heading.
646
- # Last picture is computed based on previous one in sequence.
647
- sequences.update_headings(cursor, collectionId, relativeHeading=relHeading, updateOnlyMissing=False, editingAccount=account.id)
648
-
649
- conn.commit()
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
- # Check if collection exists and if given account is authorized to edit
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 psycopg.connect(current_app.config["DB_URL"], row_factory=dict_row) as conn:
771
- with conn.cursor() as cursor:
772
- sequence_status = cursor.execute(
773
- """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
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
- LEFT JOIN sequences_pictures sp ON sp.seq_id = s.id
800
- LEFT JOIN pictures p ON sp.pic_id = p.id
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 json_build_object(
809
- 'status', s.status,
810
- 'items', json_agg(
811
- json_strip_nulls(
812
- json_build_object(
813
- 'id', i.id,
814
- -- status is a bit deprecated, we'll split this field in more fields (like `processing_in_progress`, `hidden`, ...)
815
- -- but we maintain it for retrocompatibility
816
- 'status', CASE
817
- WHEN i.is_job_running IS TRUE THEN 'preparing'
818
- WHEN i.last_job_error IS NOT NULL THEN 'broken'
819
- ELSE i.status
820
- END,
821
- 'processing_in_progress', i.is_job_running,
822
- 'process_error', i.last_job_error,
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
- ) AS sequence
830
- FROM items i
831
- JOIN sequences s on i.seq_id = s.id
832
- GROUP by s.id;""",
833
- params,
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
- return sequence_status[0]["sequence"]
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'"), SQL("p.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 psycopg.connect(current_app.config["DB_URL"], row_factory=dict_row) as conn:
922
- with conn.cursor() as cursor:
923
- userName = cursor.execute("SELECT name FROM accounts WHERE id = %s", [userId]).fetchone()
924
-
925
- if not userName:
926
- raise errors.InvalidAPIUsage(f"Impossible to find user {userId}")
927
- userName = userName["name"]
928
-
929
- meta_collection = cursor.execute(
930
- SQL(
931
- """SELECT
932
- COUNT(sp.pic_id) AS nbpic,
933
- COUNT(s.id) AS nbseq,
934
- MIN(p.ts) AS mints,
935
- MAX(p.ts) AS maxts,
936
- MIN(GREATEST(-180, ST_X(p.geom))) AS minx,
937
- MIN(GREATEST(-90, ST_Y(p.geom))) AS miny,
938
- MAX(LEAST(180, ST_X(p.geom))) AS maxx,
939
- MAX(LEAST(90, ST_Y(p.geom))) AS maxy,
940
- MIN(s.inserted_at) AS created,
941
- MAX(s.updated_at) AS updated,
942
- MIN({order_column}) AS min_order,
943
- MAX({order_column}) AS max_order
944
- FROM sequences s
945
- LEFT JOIN sequences_pictures sp ON s.id = sp.seq_id
946
- LEFT JOIN pictures p on sp.pic_id = p.id
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 meaningfull error message
958
- if collection_request.user_filter is None:
959
- raise errors.InvalidAPIUsage(f"No data loaded for user {userId}", 404)
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}")