geovisio 2.4.0__py3-none-any.whl → 2.6.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.
@@ -1,7 +1,6 @@
1
1
  import logging
2
2
  from geovisio import errors, utils
3
- from geovisio.utils import auth
4
- from geovisio.utils.sequences import get_pagination_links
3
+ from geovisio.utils import auth, sequences
5
4
  from geovisio.web.params import (
6
5
  parse_datetime,
7
6
  parse_datetime_interval,
@@ -12,9 +11,7 @@ from geovisio.web.params import (
12
11
  )
13
12
  from geovisio.utils.sequences import (
14
13
  STAC_FIELD_MAPPINGS,
15
- STAC_FIELD_TO_SQL_FILTER,
16
14
  CollectionsRequest,
17
- Collections,
18
15
  get_collections,
19
16
  )
20
17
  from geovisio.utils.fields import SortBy, SortByField, SQLDirection, Bounds, BBox
@@ -22,6 +19,7 @@ from geovisio.web.rss import dbSequencesToGeoRSS
22
19
  import psycopg
23
20
  from psycopg.rows import dict_row
24
21
  from psycopg.sql import SQL
22
+ import json
25
23
  from flask import current_app, request, url_for, Blueprint
26
24
  from geovisio.web.utils import (
27
25
  STAC_VERSION,
@@ -34,8 +32,6 @@ from geovisio.web.utils import (
34
32
  removeNoneInDict,
35
33
  )
36
34
  from geovisio.workers import runner_pictures
37
- import geovisio.utils.sequences
38
- from typing import Dict, Any
39
35
 
40
36
 
41
37
  bp = Blueprint("stac_collections", __name__, url_prefix="/api")
@@ -70,6 +66,7 @@ def dbSequenceToStacCollection(dbSeq, description="A sequence of geolocated pict
70
66
  "created": dbTsToStac(dbSeq["created"]),
71
67
  "updated": dbTsToStac(dbSeq.get("updated")),
72
68
  "geovisio:status": dbSeq.get("status"),
69
+ "geovisio:sorted-by": dbSeq.get("current_sort"),
73
70
  "providers": [
74
71
  {"name": dbSeq["account_name"], "roles": ["producer"]},
75
72
  ],
@@ -274,7 +271,7 @@ def getAllCollections():
274
271
 
275
272
  additional_filters = request.args.get("filter")
276
273
 
277
- pagination_links = get_pagination_links(
274
+ pagination_links = sequences.get_pagination_links(
278
275
  route="stac_collections.getAllCollections",
279
276
  routeArgs={"limit": collection_request.limit},
280
277
  field=sortBy.fields[0].field.stac,
@@ -331,37 +328,38 @@ def getCollection(collectionId):
331
328
  with conn.cursor() as cursor:
332
329
  record = cursor.execute(
333
330
  """
334
- SELECT
335
- s.id,
336
- s.metadata->>'title' AS name,
337
- ST_XMin(s.geom) AS minx,
338
- ST_YMin(s.geom) AS miny,
339
- ST_XMax(s.geom) AS maxx,
340
- ST_YMax(s.geom) AS maxy,
341
- s.status AS status,
342
- accounts.name AS account_name,
343
- s.inserted_at AS created,
344
- s.updated_at AS updated,
345
- a.*
346
- FROM sequences s
347
- JOIN accounts ON s.account_id = accounts.id, (
348
- SELECT
349
- MIN(ts) as mints,
350
- MAX(ts) as maxts,
351
- array_agg(DISTINCT jsonb_build_object(
352
- 'make', metadata->>'make',
353
- 'model', metadata->>'model',
354
- 'focal_length', metadata->>'focal_length',
355
- 'field_of_view', metadata->>'field_of_view'
356
- )) AS metas,
357
- COUNT(*) AS nbpic
358
- FROM pictures p
359
- JOIN sequences_pictures sp ON sp.seq_id = %(id)s AND sp.pic_id = p.id
360
- ) a
361
- WHERE s.id = %(id)s
362
- AND (s.status != 'hidden' OR s.account_id = %(account)s)
363
- AND s.status != 'deleted'
364
- """,
331
+ 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
+ """,
365
363
  params,
366
364
  ).fetchone()
367
365
 
@@ -411,17 +409,17 @@ def getCollectionThumbnail(collectionId):
411
409
  with conn.cursor() as cursor:
412
410
  records = cursor.execute(
413
411
  """
414
- SELECT
415
- sp.pic_id
416
- FROM sequences_pictures sp
417
- JOIN pictures p ON sp.pic_id = p.id
418
- JOIN sequences s ON sp.seq_id = s.id
419
- WHERE
420
- sp.seq_id = %(seq)s
421
- AND (p.status = 'ready' OR p.account_id = %(account)s)
422
- AND is_sequence_visible_by_user(s, %(account)s)
423
- LIMIT 1
424
- """,
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
+ """,
425
423
  params,
426
424
  ).fetchone()
427
425
 
@@ -464,7 +462,7 @@ def postCollection(account=None):
464
462
  # Parse received parameters
465
463
  metadata = {}
466
464
  content_type = request.headers.get("Content-Type")
467
- if request.is_json:
465
+ if request.is_json and request.json:
468
466
  metadata["title"] = request.json.get("title")
469
467
  elif content_type in ["multipart/form-data", "application/x-www-form-urlencoded"]:
470
468
  metadata["title"] = request.form.get("title")
@@ -473,7 +471,7 @@ def postCollection(account=None):
473
471
 
474
472
  # Create sequence folder
475
473
  accountId = accountIdOrDefault(account)
476
- seqId = geovisio.utils.sequences.createSequence(metadata, accountId)
474
+ seqId = sequences.createSequence(metadata, accountId)
477
475
 
478
476
  # Return created sequence
479
477
  return (
@@ -527,8 +525,8 @@ def patchCollection(collectionId, account):
527
525
  # Parse received parameters
528
526
  metadata = {}
529
527
  content_type = (request.headers.get("Content-Type") or "").split(";")[0]
530
- for param in ["visible", "title"]:
531
- if request.is_json:
528
+ for param in ["visible", "title", "relative_heading", "sortby"]:
529
+ if request.is_json and request.json:
532
530
  metadata[param] = request.json.get(param)
533
531
  elif content_type in ["multipart/form-data", "application/x-www-form-urlencoded"]:
534
532
  metadata[param] = request.form.get(param)
@@ -547,13 +545,29 @@ def patchCollection(collectionId, account):
547
545
  if not (isinstance(newTitle, str) and len(newTitle) <= 250):
548
546
  raise errors.InvalidAPIUsage("Sequence title is not valid, should be a string with a max of 250 characters", status_code=400)
549
547
 
548
+ # Check if sortby is valid
549
+ sortby = metadata.get("sortby")
550
+ if sortby is not None:
551
+ if sortby not in ["+gpsdate", "-gpsdate", "+filedate", "-filedate", "+filename", "-filename"]:
552
+ raise errors.InvalidAPIUsage("Sort order parameter is invalid", status_code=400)
553
+
554
+ # Check if relative_heading is valid
555
+ relHeading = metadata.get("relative_heading")
556
+ if relHeading is not None:
557
+ try:
558
+ relHeading = int(relHeading)
559
+ if relHeading < -180 or relHeading > 180:
560
+ raise ValueError()
561
+ except ValueError:
562
+ raise errors.InvalidAPIUsage("Relative heading is not valid, should be an integer in degrees from -180 to 180", status_code=400)
563
+
550
564
  # If no parameter is changed, no need to contact DB, just return sequence as is
551
- if {visible, newTitle} == {None}:
565
+ if {visible, newTitle, relHeading, sortby} == {None}:
552
566
  return getCollection(collectionId)
553
567
 
554
568
  # Check if sequence exists and if given account is authorized to edit
555
569
  with psycopg.connect(current_app.config["DB_URL"], row_factory=dict_row) as conn, conn.cursor() as cursor:
556
- seq = cursor.execute("SELECT status, metadata, account_id FROM sequences WHERE id = %s", [collectionId]).fetchone()
570
+ seq = cursor.execute("SELECT status, metadata, account_id, current_sort FROM sequences WHERE id = %s", [collectionId]).fetchone()
557
571
 
558
572
  # Sequence not found
559
573
  if not seq:
@@ -573,9 +587,8 @@ def patchCollection(collectionId, account):
573
587
  f"Sequence {collectionId} is in {oldStatus} state, its visibility can't be changed for now", status_code=400
574
588
  )
575
589
 
576
- # Let's edit this picture
577
590
  sqlUpdates = []
578
- sqlParams = {"id": collectionId}
591
+ sqlParams = {"id": collectionId, "account": account.id}
579
592
 
580
593
  if visible is not None:
581
594
  newStatus = "ready" if visible is True else "hidden"
@@ -583,22 +596,57 @@ def patchCollection(collectionId, account):
583
596
  sqlUpdates.append(SQL("status = %(status)s"))
584
597
  sqlParams["status"] = newStatus
585
598
 
599
+ new_metadata = {}
586
600
  if newTitle is not None and oldTitle != newTitle:
587
- sqlUpdates.append(SQL("metadata = jsonb_set(metadata, '{title}', %(title)s)"))
588
- sqlParams["title"] = f'"{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
589
614
 
590
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
+
591
620
  cursor.execute(
592
621
  SQL(
593
622
  """
594
- UPDATE sequences
595
- SET {updates}
596
- WHERE id = %(id)s
597
- """
623
+ UPDATE sequences
624
+ SET {updates}
625
+ WHERE id = %(id)s
626
+ """
598
627
  ).format(updates=SQL(", ").join(sqlUpdates)),
599
628
  sqlParams,
600
629
  )
601
- conn.commit()
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()
602
650
 
603
651
  # Redirect response to a classic GET
604
652
  return getCollection(collectionId)
@@ -665,7 +713,7 @@ def deleteCollection(collectionId, account):
665
713
  INSERT INTO pictures_to_process(picture_id, task)
666
714
  SELECT pic_id, 'delete' FROM picWithoutOtherSeq
667
715
  ON CONFLICT (picture_id) DO UPDATE SET task = 'delete'
668
- """,
716
+ """,
669
717
  {"seq": collectionId},
670
718
  ).rowcount
671
719
 
@@ -681,7 +729,7 @@ def deleteCollection(collectionId, account):
681
729
  SELECT pic_id FROM sequences_pictures WHERE pic_id IN (SELECT pic_id FROM pic2rm) AND seq_id != %(seq)s
682
730
  )
683
731
  UPDATE pictures SET status = 'waiting-for-delete' WHERE id IN (SELECT pic_id FROM picWithoutOtherSeq)
684
- """,
732
+ """,
685
733
  {"seq": collectionId},
686
734
  ).rowcount
687
735
 
@@ -722,6 +770,18 @@ def getCollectionImportStatus(collectionId):
722
770
  with psycopg.connect(current_app.config["DB_URL"], row_factory=dict_row) as conn:
723
771
  with conn.cursor() as cursor:
724
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(
725
785
  """WITH
726
786
  pic_jobs_stats AS (
727
787
  SELECT
@@ -748,47 +808,37 @@ pic_jobs_stats AS (
748
808
  pic_jobs_stats.nb_errors,
749
809
  pic_jobs_stats.last_job_finished_at
750
810
  FROM sequences s
751
- LEFT JOIN sequences_pictures sp ON sp.seq_id = s.id
752
- LEFT JOIN pictures p ON sp.pic_id = p.id
811
+ JOIN sequences_pictures sp ON sp.seq_id = s.id
812
+ JOIN pictures p ON sp.pic_id = p.id
753
813
  LEFT JOIN pic_jobs_stats ON pic_jobs_stats.picture_id = p.id
754
814
  WHERE
755
815
  s.id = %(seq_id)s
756
816
  AND (p IS NULL OR p.status != 'hidden' OR p.account_id = %(account)s)
757
- AND (s.status != 'hidden' OR s.account_id = %(account)s) -- show deleted sequence here
758
817
  ORDER BY s.id, sp.rank
759
818
  )
760
- SELECT json_build_object(
761
- 'status', s.status,
762
- 'items', json_agg(
763
- json_strip_nulls(
764
- json_build_object(
765
- 'id', i.id,
766
- -- status is a bit deprecated, we'll split this field in more fields (like `processing_in_progress`, `hidden`, ...)
767
- -- but we maintain it for retrocompatibility
768
- 'status', CASE
769
- WHEN i.is_job_running IS TRUE THEN 'preparing'
770
- WHEN i.last_job_error IS NOT NULL THEN 'broken'
771
- ELSE i.status
772
- END,
773
- 'processing_in_progress', i.is_job_running,
774
- 'process_error', i.last_job_error,
775
- 'nb_errors', i.nb_errors,
776
- 'processed_at', i.last_job_finished_at,
777
- 'rank', i.rank
778
- )
779
- )
819
+ SELECT json_strip_nulls(
820
+ json_build_object(
821
+ 'id', i.id,
822
+ -- status is a bit deprecated, we'll split this field in more fields (like `processing_in_progress`, `hidden`, ...)
823
+ -- but we maintain it for retrocompatibility
824
+ 'status', CASE
825
+ WHEN i.is_job_running IS TRUE THEN 'preparing'
826
+ WHEN i.last_job_error IS NOT NULL THEN 'broken'
827
+ ELSE i.status
828
+ END,
829
+ 'processing_in_progress', i.is_job_running,
830
+ 'process_error', i.last_job_error,
831
+ 'nb_errors', i.nb_errors,
832
+ 'processed_at', i.last_job_finished_at,
833
+ 'rank', i.rank
780
834
  )
781
- ) AS sequence
782
- FROM items i
783
- JOIN sequences s on i.seq_id = s.id
784
- GROUP by s.id;""",
835
+ ) as pic_status
836
+ FROM items i;""",
785
837
  params,
786
838
  ).fetchall()
839
+ pics = [p["pic_status"] for p in pics_status if len(p["pic_status"]) > 0]
787
840
 
788
- if len(sequence_status) == 0:
789
- raise errors.InvalidAPIUsage("Sequence doesn't exists", status_code=404)
790
-
791
- return sequence_status[0]["sequence"]
841
+ return {"status": sequence_status["status"], "items": pics}
792
842
 
793
843
 
794
844
  @bp.route("/users/<uuid:userId>/collection")
@@ -881,23 +931,23 @@ def getUserCollection(userId, userIdMatchesAccount=False):
881
931
  meta_collection = cursor.execute(
882
932
  SQL(
883
933
  """SELECT
884
- COUNT(sp.pic_id) AS nbpic,
885
- COUNT(s.id) AS nbseq,
886
- MIN(p.ts) AS mints,
887
- MAX(p.ts) AS maxts,
888
- MIN(GREATEST(-180, ST_X(p.geom))) AS minx,
889
- MIN(GREATEST(-90, ST_Y(p.geom))) AS miny,
890
- MAX(LEAST(180, ST_X(p.geom))) AS maxx,
891
- MAX(LEAST(90, ST_Y(p.geom))) AS maxy,
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,
892
942
  MIN(s.inserted_at) AS created,
893
943
  MAX(s.updated_at) AS updated,
894
944
  MIN({order_column}) AS min_order,
895
945
  MAX({order_column}) AS max_order
896
- FROM sequences s
897
- LEFT JOIN sequences_pictures sp ON s.id = sp.seq_id
898
- LEFT JOIN pictures p on sp.pic_id = p.id
899
- WHERE {filter}
900
- """
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
+ """
901
951
  ).format(
902
952
  filter=SQL(" AND ").join(meta_filter),
903
953
  order_column=collection_request.sort_by.fields[0].field.sql_filter,
@@ -955,7 +1005,7 @@ def getUserCollection(userId, userIdMatchesAccount=False):
955
1005
  # if some filters were given, we continue to pass them to the pagination
956
1006
  additional_filters = request.args.get("filter")
957
1007
 
958
- pagination_links = get_pagination_links(
1008
+ pagination_links = sequences.get_pagination_links(
959
1009
  route="stac_collections.getUserCollection",
960
1010
  routeArgs={"userId": str(userId), "limit": collection_request.limit},
961
1011
  field=sortBy.fields[0].field.stac,