geovisio 2.6.0__py3-none-any.whl → 2.7.1__py3-none-any.whl

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