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.
Files changed (57) hide show
  1. geovisio/__init__.py +36 -7
  2. geovisio/admin_cli/db.py +1 -4
  3. geovisio/config_app.py +40 -1
  4. geovisio/db_migrations.py +24 -3
  5. geovisio/templates/main.html +13 -13
  6. geovisio/templates/viewer.html +3 -3
  7. geovisio/translations/de/LC_MESSAGES/messages.mo +0 -0
  8. geovisio/translations/de/LC_MESSAGES/messages.po +667 -0
  9. geovisio/translations/en/LC_MESSAGES/messages.mo +0 -0
  10. geovisio/translations/en/LC_MESSAGES/messages.po +730 -0
  11. geovisio/translations/es/LC_MESSAGES/messages.mo +0 -0
  12. geovisio/translations/es/LC_MESSAGES/messages.po +778 -0
  13. geovisio/translations/fi/LC_MESSAGES/messages.mo +0 -0
  14. geovisio/translations/fi/LC_MESSAGES/messages.po +589 -0
  15. geovisio/translations/fr/LC_MESSAGES/messages.mo +0 -0
  16. geovisio/translations/fr/LC_MESSAGES/messages.po +814 -0
  17. geovisio/translations/ko/LC_MESSAGES/messages.mo +0 -0
  18. geovisio/translations/ko/LC_MESSAGES/messages.po +685 -0
  19. geovisio/translations/messages.pot +686 -0
  20. geovisio/translations/nl/LC_MESSAGES/messages.mo +0 -0
  21. geovisio/translations/nl/LC_MESSAGES/messages.po +594 -0
  22. geovisio/utils/__init__.py +1 -1
  23. geovisio/utils/auth.py +50 -11
  24. geovisio/utils/db.py +65 -0
  25. geovisio/utils/excluded_areas.py +83 -0
  26. geovisio/utils/extent.py +30 -0
  27. geovisio/utils/fields.py +1 -1
  28. geovisio/utils/filesystems.py +0 -1
  29. geovisio/utils/link.py +14 -0
  30. geovisio/utils/params.py +20 -0
  31. geovisio/utils/pictures.py +92 -68
  32. geovisio/utils/reports.py +171 -0
  33. geovisio/utils/sequences.py +264 -126
  34. geovisio/utils/tokens.py +37 -42
  35. geovisio/utils/upload_set.py +654 -0
  36. geovisio/web/auth.py +37 -37
  37. geovisio/web/collections.py +286 -302
  38. geovisio/web/configuration.py +14 -0
  39. geovisio/web/docs.py +241 -14
  40. geovisio/web/excluded_areas.py +377 -0
  41. geovisio/web/items.py +156 -108
  42. geovisio/web/map.py +20 -20
  43. geovisio/web/params.py +69 -26
  44. geovisio/web/pictures.py +14 -31
  45. geovisio/web/reports.py +399 -0
  46. geovisio/web/rss.py +13 -7
  47. geovisio/web/stac.py +129 -134
  48. geovisio/web/tokens.py +98 -109
  49. geovisio/web/upload_set.py +768 -0
  50. geovisio/web/users.py +100 -73
  51. geovisio/web/utils.py +28 -9
  52. geovisio/workers/runner_pictures.py +252 -204
  53. {geovisio-2.6.0.dist-info → geovisio-2.7.0.dist-info}/METADATA +16 -13
  54. geovisio-2.7.0.dist-info/RECORD +66 -0
  55. geovisio-2.6.0.dist-info/RECORD +0 -41
  56. {geovisio-2.6.0.dist-info → geovisio-2.7.0.dist-info}/LICENSE +0 -0
  57. {geovisio-2.6.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
- min_picture_ts AS mints,
345
- max_picture_ts AS maxts,
346
- nb_pictures AS nbpic
347
- FROM sequences s
348
- JOIN accounts ON s.account_id = accounts.id, (
349
- SELECT
350
- array_agg(DISTINCT jsonb_build_object(
351
- 'make', metadata->>'make',
352
- 'model', metadata->>'model',
353
- 'focal_length', metadata->>'focal_length',
354
- 'field_of_view', metadata->>'field_of_view'
355
- )) AS metas
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)
677
723
 
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
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 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
- SQL(
774
- """SELECT status
775
- FROM sequences
776
- WHERE id = %(seq_id)s
777
- AND (status != 'hidden' OR account_id = %(account)s)-- show deleted sequence here"""
778
- ),
779
- params,
780
- ).fetchone()
781
- if sequence_status is None:
782
- raise errors.InvalidAPIUsage("Sequence doesn't exists", status_code=404)
783
-
784
- pics_status = cursor.execute(
785
- """WITH
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
- params,
838
- ).fetchall()
839
- pics = [p["pic_status"] for p in pics_status if len(p["pic_status"]) > 0]
821
+ params,
822
+ ).fetchall()
823
+ pics = [p["pic_status"] for p in pics_status if len(p["pic_status"]) > 0]
840
824
 
841
- return {"status": sequence_status["status"], "items": pics}
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'"), SQL("p.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 psycopg.connect(current_app.config["DB_URL"], row_factory=dict_row) as conn:
924
- with conn.cursor() as cursor:
925
- userName = cursor.execute("SELECT name FROM accounts WHERE id = %s", [userId]).fetchone()
926
-
927
- if not userName:
928
- raise errors.InvalidAPIUsage(f"Impossible to find user {userId}")
929
- userName = userName["name"]
930
-
931
- meta_collection = cursor.execute(
932
- SQL(
933
- """SELECT
934
- COUNT(sp.pic_id) AS nbpic,
935
- COUNT(s.id) AS nbseq,
936
- MIN(p.ts) AS mints,
937
- MAX(p.ts) AS maxts,
938
- MIN(GREATEST(-180, ST_X(p.geom))) AS minx,
939
- MIN(GREATEST(-90, ST_Y(p.geom))) AS miny,
940
- MAX(LEAST(180, ST_X(p.geom))) AS maxx,
941
- MAX(LEAST(90, ST_Y(p.geom))) AS maxy,
942
- MIN(s.inserted_at) AS created,
943
- MAX(s.updated_at) AS updated,
944
- MIN({order_column}) AS min_order,
945
- MAX({order_column}) AS max_order
946
- FROM sequences s
947
- LEFT JOIN sequences_pictures sp ON s.id = sp.seq_id
948
- LEFT JOIN pictures p on sp.pic_id = p.id
949
- WHERE {filter}
950
- """
951
- ).format(
952
- filter=SQL(" AND ").join(meta_filter),
953
- order_column=collection_request.sort_by.fields[0].field.sql_filter,
954
- ),
955
- params={"account": userId},
956
- ).fetchone()
957
-
958
- if not meta_collection or meta_collection["created"] is None:
959
- # No data found, trying to give the most meaningfull error message
960
- if collection_request.user_filter is None:
961
- raise errors.InvalidAPIUsage(f"No data loaded for user {userId}", 404)
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}")