geovisio 2.7.0__py3-none-any.whl → 2.8.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 (64) hide show
  1. geovisio/__init__.py +11 -3
  2. geovisio/admin_cli/__init__.py +3 -1
  3. geovisio/admin_cli/cleanup.py +2 -2
  4. geovisio/admin_cli/user.py +75 -0
  5. geovisio/config_app.py +87 -4
  6. geovisio/templates/main.html +2 -2
  7. geovisio/templates/viewer.html +3 -3
  8. geovisio/translations/da/LC_MESSAGES/messages.mo +0 -0
  9. geovisio/translations/da/LC_MESSAGES/messages.po +850 -0
  10. geovisio/translations/de/LC_MESSAGES/messages.mo +0 -0
  11. geovisio/translations/de/LC_MESSAGES/messages.po +235 -2
  12. geovisio/translations/el/LC_MESSAGES/messages.mo +0 -0
  13. geovisio/translations/el/LC_MESSAGES/messages.po +685 -0
  14. geovisio/translations/en/LC_MESSAGES/messages.mo +0 -0
  15. geovisio/translations/en/LC_MESSAGES/messages.po +244 -153
  16. geovisio/translations/eo/LC_MESSAGES/messages.mo +0 -0
  17. geovisio/translations/eo/LC_MESSAGES/messages.po +790 -0
  18. geovisio/translations/es/LC_MESSAGES/messages.mo +0 -0
  19. geovisio/translations/fi/LC_MESSAGES/messages.mo +0 -0
  20. geovisio/translations/fr/LC_MESSAGES/messages.mo +0 -0
  21. geovisio/translations/fr/LC_MESSAGES/messages.po +40 -3
  22. geovisio/translations/hu/LC_MESSAGES/messages.mo +0 -0
  23. geovisio/translations/hu/LC_MESSAGES/messages.po +773 -0
  24. geovisio/translations/it/LC_MESSAGES/messages.mo +0 -0
  25. geovisio/translations/it/LC_MESSAGES/messages.po +875 -0
  26. geovisio/translations/ja/LC_MESSAGES/messages.mo +0 -0
  27. geovisio/translations/ja/LC_MESSAGES/messages.po +719 -0
  28. geovisio/translations/ko/LC_MESSAGES/messages.mo +0 -0
  29. geovisio/translations/messages.pot +225 -148
  30. geovisio/translations/nl/LC_MESSAGES/messages.mo +0 -0
  31. geovisio/translations/nl/LC_MESSAGES/messages.po +24 -16
  32. geovisio/translations/pl/LC_MESSAGES/messages.mo +0 -0
  33. geovisio/translations/pl/LC_MESSAGES/messages.po +727 -0
  34. geovisio/translations/zh_Hant/LC_MESSAGES/messages.mo +0 -0
  35. geovisio/translations/zh_Hant/LC_MESSAGES/messages.po +719 -0
  36. geovisio/utils/auth.py +80 -8
  37. geovisio/utils/link.py +3 -2
  38. geovisio/utils/model_query.py +55 -0
  39. geovisio/utils/pictures.py +29 -62
  40. geovisio/utils/semantics.py +120 -0
  41. geovisio/utils/sequences.py +30 -23
  42. geovisio/utils/tokens.py +5 -3
  43. geovisio/utils/upload_set.py +87 -64
  44. geovisio/utils/website.py +50 -0
  45. geovisio/web/annotations.py +17 -0
  46. geovisio/web/auth.py +9 -5
  47. geovisio/web/collections.py +235 -63
  48. geovisio/web/configuration.py +17 -1
  49. geovisio/web/docs.py +99 -54
  50. geovisio/web/items.py +233 -100
  51. geovisio/web/map.py +129 -31
  52. geovisio/web/pages.py +240 -0
  53. geovisio/web/params.py +17 -0
  54. geovisio/web/prepare.py +165 -0
  55. geovisio/web/stac.py +17 -4
  56. geovisio/web/tokens.py +14 -4
  57. geovisio/web/upload_set.py +19 -10
  58. geovisio/web/users.py +176 -44
  59. geovisio/workers/runner_pictures.py +75 -50
  60. {geovisio-2.7.0.dist-info → geovisio-2.8.0.dist-info}/METADATA +6 -5
  61. geovisio-2.8.0.dist-info/RECORD +89 -0
  62. {geovisio-2.7.0.dist-info → geovisio-2.8.0.dist-info}/WHEEL +1 -1
  63. geovisio-2.7.0.dist-info/RECORD +0 -66
  64. {geovisio-2.7.0.dist-info → geovisio-2.8.0.dist-info}/LICENSE +0 -0
@@ -1,6 +1,9 @@
1
1
  from enum import Enum
2
+ from attr import dataclass
2
3
  from geovisio import errors, utils, db
3
4
  from geovisio.utils import auth, sequences
5
+ from geovisio.utils.params import validation_error
6
+ from geovisio.utils.semantics import SemanticTagUpdate, Entity, EntityType, update_tags
4
7
  from geovisio.web.params import (
5
8
  parse_datetime,
6
9
  parse_datetime_interval,
@@ -18,7 +21,8 @@ from geovisio.utils.fields import SortBy, SortByField, SQLDirection, Bounds, BBo
18
21
  from geovisio.web.rss import dbSequencesToGeoRSS
19
22
  from psycopg.rows import dict_row
20
23
  from psycopg.sql import SQL
21
- from flask import current_app, request, url_for, Blueprint
24
+ from pydantic import BaseModel, Field, ValidationError, field_validator
25
+ from flask import current_app, request, url_for, Blueprint, stream_with_context
22
26
  from flask_babel import gettext as _
23
27
  from geovisio.web.utils import (
24
28
  STAC_VERSION,
@@ -30,7 +34,7 @@ from geovisio.web.utils import (
30
34
  get_root_link,
31
35
  removeNoneInDict,
32
36
  )
33
- from typing import Optional
37
+ from typing import List, Optional
34
38
 
35
39
 
36
40
  bp = Blueprint("stac_collections", __name__, url_prefix="/api")
@@ -82,11 +86,15 @@ def dbSequenceToStacCollection(dbSeq, description="A sequence of geolocated pict
82
86
  {
83
87
  "type": "Collection",
84
88
  "stac_version": STAC_VERSION,
85
- "stac_extensions": ["https://stac-extensions.github.io/stats/v0.2.0/schema.json"], # For stats: fields
89
+ "stac_extensions": [
90
+ "https://stac-extensions.github.io/stats/v0.2.0/schema.json", # For stats: fields
91
+ "https://stac.linz.govt.nz/v0.0.15/quality/schema.json", # For quality: fields
92
+ ],
86
93
  "id": str(dbSeq["id"]),
87
94
  "title": str(dbSeq["name"]),
88
95
  "description": description,
89
96
  "keywords": ["pictures", str(dbSeq["name"])],
97
+ "semantics": dbSeq["semantics"] if "semantics" in dbSeq else None,
90
98
  "license": current_app.config["API_PICTURES_LICENSE_SPDX_ID"],
91
99
  "created": dbTsToStac(dbSeq["created"]),
92
100
  "updated": dbTsToStac(dbSeq.get("updated")),
@@ -96,6 +104,10 @@ def dbSequenceToStacCollection(dbSeq, description="A sequence of geolocated pict
96
104
  "geovisio:sorted-by": dbSeq.get("current_sort"),
97
105
  "geovisio:upload-software": userAgentToClient(dbSeq.get("user_agent")).value,
98
106
  "geovisio:length_km": dbSeq.get("length_km"),
107
+ "quality:horizontal_accuracy": (
108
+ float("{:.1f}".format(dbSeq["computed_gps_accuracy"])) if dbSeq.get("computed_gps_accuracy") else None
109
+ ),
110
+ "quality:horizontal_accuracy_type": "95% confidence interval" if "computed_gps_accuracy" in dbSeq else None,
99
111
  "providers": [
100
112
  {"name": dbSeq["account_name"], "roles": ["producer"], "id": str(dbSeq["account_id"])},
101
113
  ],
@@ -110,7 +122,14 @@ def dbSequenceToStacCollection(dbSeq, description="A sequence of geolocated pict
110
122
  ]
111
123
  },
112
124
  },
113
- "summaries": cleanNoneInDict({"pers:interior_orientation": dbSeq.get("metas")}),
125
+ "summaries": cleanNoneInDict(
126
+ {
127
+ "pers:interior_orientation": dbSeq.get("metas"),
128
+ "panoramax:horizontal_pixel_density": (
129
+ [dbSeq["computed_h_pixel_density"]] if "computed_h_pixel_density" in dbSeq else None
130
+ ),
131
+ }
132
+ ),
114
133
  "stats:items": removeNoneInDict({"count": nb_pic}),
115
134
  "links": cleanNoneInList(
116
135
  [
@@ -374,8 +393,19 @@ def getCollection(collectionId):
374
393
  max_picture_ts AS maxts,
375
394
  nb_pictures AS nbpic,
376
395
  s.user_agent,
377
- ROUND(ST_Length(s.geom::geography)) / 1000 as length_km
396
+ ROUND(ST_Length(s.geom::geography)) / 1000 as length_km,
397
+ s.computed_h_pixel_density,
398
+ s.computed_gps_accuracy,
399
+ t.semantics
378
400
  FROM sequences s
401
+ LEFT JOIN (
402
+ SELECT sequence_id, json_agg(json_strip_nulls(json_build_object(
403
+ 'key', key,
404
+ 'value', value
405
+ ))) AS semantics
406
+ FROM sequences_semantics
407
+ GROUP BY sequence_id
408
+ ) t ON t.sequence_id = s.id
379
409
  JOIN accounts ON s.account_id = accounts.id, (
380
410
  SELECT
381
411
  array_agg(DISTINCT jsonb_build_object(
@@ -523,13 +553,83 @@ def postCollection(account=None):
523
553
  )
524
554
 
525
555
 
556
+ class PatchCollectionParameter(BaseModel):
557
+ """Parameters used to add an item to an UploadSet"""
558
+
559
+ relative_heading: Optional[int] = None
560
+ """The relative heading (in degrees), offset based on movement path (0° = looking forward, -90° = looking left, 90° = looking right). Headings are unchanged if this parameter is not set."""
561
+ visible: Optional[bool] = None
562
+ """Should the sequence be publicly visible ?"""
563
+ title: Optional[str] = Field(max_length=250, default=None)
564
+ """The sequence title (publicly displayed)"""
565
+ sortby: Optional[str] = None
566
+ """Define the pictures sort order based on given property. Sort order is defined based on preceding '+' (asc) or '-' (desc).
567
+
568
+ Available properties are:
569
+ * `gpsdate`: sort by GPS datetime
570
+ * `filedate`: sort by the camera-generated capture date. This is based on EXIF tags `Exif.Image.DateTimeOriginal`, `Exif.Photo.DateTimeOriginal`, `Exif.Image.DateTime` or `Xmp.GPano.SourceImageCreateTime` (in this order).
571
+ * `filename`: sort by the original picture file name
572
+
573
+ If unset, sort order is unchanged."""
574
+ semantics: Optional[List[SemanticTagUpdate]] = None
575
+ """Tags to update on the picture. By default each tag will be added to the picture's tags, but you can change this behavior by setting the `action` parameter to `delete`.
576
+
577
+ Like:
578
+ [
579
+ {"key": "some_key", "value": "some_value", "action": "delete"},
580
+ {"key": "some_key", "value": "some_new_value"}
581
+ ]
582
+
583
+ Note that updating tags is only possible with JSON data, not with form-data."""
584
+
585
+ def has_override(self) -> bool:
586
+ return self.model_fields_set
587
+
588
+ @field_validator("visible", mode="before")
589
+ @classmethod
590
+ def parse_visible(cls, value):
591
+ if value not in ["true", "false"]:
592
+ raise errors.InvalidAPIUsage(_("Picture visibility parameter (visible) should be either unset, true or false"), status_code=400)
593
+ return value == "true"
594
+
595
+ @field_validator("sortby", mode="before")
596
+ @classmethod
597
+ def check_sortby(cls, value):
598
+ if value not in ["+gpsdate", "-gpsdate", "+filedate", "-filedate", "+filename", "-filename"]:
599
+ raise errors.InvalidAPIUsage(_("Sort order parameter is invalid"), status_code=400)
600
+ return value
601
+
602
+ @field_validator("relative_heading", mode="before")
603
+ @classmethod
604
+ def parse_relative_heading(cls, value):
605
+ try:
606
+ relHeading = int(value)
607
+ if relHeading < -180 or relHeading > 180:
608
+ raise ValueError()
609
+ return relHeading
610
+ except ValueError:
611
+ raise errors.InvalidAPIUsage(
612
+ _("Relative heading is not valid, should be an integer in degrees from -180 to 180"), status_code=400
613
+ )
614
+
615
+ def has_only_semantics_updates(self):
616
+ return self.model_fields_set == {"semantics"}
617
+
618
+
526
619
  @bp.route("/collections/<uuid:collectionId>", methods=["PATCH"])
527
620
  @auth.login_required()
528
621
  def patchCollection(collectionId, account):
529
622
  """Edits properties of an existing collection
623
+
624
+ Note that there are rules on the editing of a sequence's metadata:
625
+
626
+ - Only the owner of a picture can change its visibility and title
627
+ - For core metadata (relative_heading, sort_by), the owner can restrict their change by other accounts (see `collaborative_metadata` field in `/api/users/me`) and if not explicitly defined by the user, the instance's default value is used.
628
+ - Everyone can add/edit/delete semantics tags.
530
629
  ---
531
630
  tags:
532
631
  - Editing
632
+ - Tags
533
633
  parameters:
534
634
  - name: collectionId
535
635
  in: path
@@ -561,48 +661,18 @@ def patchCollection(collectionId, account):
561
661
  """
562
662
 
563
663
  # Parse received parameters
564
- metadata = {}
565
664
  content_type = (request.headers.get("Content-Type") or "").split(";")[0]
566
- for param in ["visible", "title", "relative_heading", "sortby"]:
665
+ metadata = None
666
+ try:
567
667
  if request.is_json and request.json:
568
- metadata[param] = request.json.get(param)
668
+ metadata = PatchCollectionParameter(**request.json)
569
669
  elif content_type in ["multipart/form-data", "application/x-www-form-urlencoded"]:
570
- metadata[param] = request.form.get(param)
571
-
572
- # Check if visibility param is valid
573
- visible = metadata.get("visible")
574
- if visible is not None:
575
- if visible in ["true", "false"]:
576
- visible = visible == "true"
577
- else:
578
- raise errors.InvalidAPIUsage(_("Picture visibility parameter (visible) should be either unset, true or false"), status_code=400)
579
-
580
- # Check if title is valid
581
- newTitle = metadata.get("title")
582
- if newTitle is not None:
583
- if not (isinstance(newTitle, str) and len(newTitle) <= 250):
584
- raise errors.InvalidAPIUsage(_("Sequence title is not valid, should be a string with a max of 250 characters"), status_code=400)
585
-
586
- # Check if sortby is valid
587
- sortby = metadata.get("sortby")
588
- if sortby is not None:
589
- if sortby not in ["+gpsdate", "-gpsdate", "+filedate", "-filedate", "+filename", "-filename"]:
590
- raise errors.InvalidAPIUsage(_("Sort order parameter is invalid"), status_code=400)
591
-
592
- # Check if relative_heading is valid
593
- relHeading = metadata.get("relative_heading")
594
- if relHeading is not None:
595
- try:
596
- relHeading = int(relHeading)
597
- if relHeading < -180 or relHeading > 180:
598
- raise ValueError()
599
- except ValueError:
600
- raise errors.InvalidAPIUsage(
601
- _("Relative heading is not valid, should be an integer in degrees from -180 to 180"), status_code=400
602
- )
670
+ metadata = PatchCollectionParameter(**request.form)
671
+ except ValidationError as ve:
672
+ raise errors.InvalidAPIUsage(_("Impossible to parse parameters"), payload=validation_error(ve))
603
673
 
604
674
  # If no parameter is changed, no need to contact DB, just return sequence as is
605
- if {visible, newTitle, relHeading, sortby} == {None}:
675
+ if metadata is None or not metadata.has_override():
606
676
  return getCollection(collectionId)
607
677
 
608
678
  # Check if sequence exists and if given account is authorized to edit
@@ -617,9 +687,24 @@ def patchCollection(collectionId, account):
617
687
  if not seq:
618
688
  raise errors.InvalidAPIUsage(_("Collection %(c)s wasn't found in database", c=collectionId), status_code=404)
619
689
 
620
- # Account associated to sequence doesn't match current user
621
690
  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)
691
+ # Only owner of the sequence is allower to change its visibility and title
692
+ # tags and headings can be changed by anyone
693
+ if metadata.visible is not None or metadata.title is not None:
694
+ raise errors.InvalidAPIUsage(
695
+ _(
696
+ "You're not authorized to edit those fields for this sequence. Only the owner can change the visibility and the title"
697
+ ),
698
+ status_code=403,
699
+ )
700
+
701
+ # for core metadata editing (all appart the semantic tags), we check if the user has allowed it
702
+ if not metadata.has_only_semantics_updates():
703
+ if not auth.account_allow_collaborative_editing(seq["account_id"]):
704
+ raise errors.InvalidAPIUsage(
705
+ _("You're not authorized to edit this sequence, collaborative editing is not allowed"),
706
+ status_code=403,
707
+ )
623
708
 
624
709
  oldStatus = seq["status"]
625
710
  oldMetadata = seq["metadata"]
@@ -627,25 +712,26 @@ def patchCollection(collectionId, account):
627
712
 
628
713
  # Check if sequence is in a preparing/broken/... state so no edit possible
629
714
  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
- )
715
+ if metadata.visible is not None:
716
+ raise errors.InvalidAPIUsage(
717
+ _("Sequence %(c)s is in %(s)s state, its visibility can't be changed for now", c=collectionId, s=oldStatus),
718
+ status_code=400,
719
+ )
634
720
 
635
721
  sqlUpdates = []
636
722
  sqlParams = {"id": collectionId, "account": account.id}
637
723
 
638
- if visible is not None:
639
- newStatus = "ready" if visible is True else "hidden"
724
+ if metadata.visible is not None:
725
+ newStatus = "ready" if metadata.visible is True else "hidden"
640
726
  if newStatus != oldStatus:
641
727
  sqlUpdates.append(SQL("status = %(status)s"))
642
728
  sqlParams["status"] = newStatus
643
729
 
644
730
  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
731
+ if metadata.title is not None and oldTitle != metadata.title:
732
+ new_metadata["title"] = metadata.title
733
+ if metadata.relative_heading:
734
+ new_metadata["relative_heading"] = metadata.relative_heading
649
735
 
650
736
  if new_metadata:
651
737
  sqlUpdates.append(SQL("metadata = metadata || %(new_metadata)s"))
@@ -653,9 +739,9 @@ def patchCollection(collectionId, account):
653
739
 
654
740
  sqlParams["new_metadata"] = Jsonb(new_metadata)
655
741
 
656
- if sortby is not None:
742
+ if metadata.sortby is not None:
657
743
  sqlUpdates.append(SQL("current_sort = %(sort)s"))
658
- sqlParams["sort"] = sortby
744
+ sqlParams["sort"] = metadata.sortby
659
745
 
660
746
  if len(sqlUpdates) > 0:
661
747
  # 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)
@@ -674,25 +760,29 @@ def patchCollection(collectionId, account):
674
760
  )
675
761
 
676
762
  # Edits picture sort order
677
- if sortby is not None:
678
- direction = sequences.Direction(sortby[0])
679
- order = sequences.CollectionSortOrder(sortby[1:])
763
+ if metadata.sortby is not None:
764
+ direction = sequences.Direction(metadata.sortby[0])
765
+ order = sequences.CollectionSortOrder(metadata.sortby[1:])
680
766
  sequences.sort_collection(cursor, collectionId, sequences.CollectionSort(order=order, direction=direction))
681
- if not relHeading:
767
+ if not metadata.relative_heading:
682
768
  # if we do not plan to override headings specifically, we recompute headings that have not bee provided by the users
683
769
  # with the new movement track
684
770
  sequences.update_headings(cursor, collectionId, editingAccount=account.id)
685
771
 
686
772
  # Edits relative heading of pictures in sequence
687
- if relHeading is not None:
773
+ if metadata.relative_heading is not None:
688
774
  # New heading is computed based on sequence movement track
689
775
  # We take each picture and its following, compute azimuth,
690
776
  # then add given relative heading to offset picture heading.
691
777
  # Last picture is computed based on previous one in sequence.
692
778
  sequences.update_headings(
693
- cursor, collectionId, relativeHeading=relHeading, updateOnlyMissing=False, editingAccount=account.id
779
+ cursor, collectionId, relativeHeading=metadata.relative_heading, updateOnlyMissing=False, editingAccount=account.id
694
780
  )
695
781
 
782
+ if metadata.semantics is not None:
783
+ # semantic tags are managed separately
784
+ update_tags(cursor, Entity(type=EntityType.seq, id=collectionId), metadata.semantics, account=account.id)
785
+
696
786
  # Redirect response to a classic GET
697
787
  return getCollection(collectionId)
698
788
 
@@ -825,6 +915,60 @@ FROM items i;""",
825
915
  return {"status": sequence_status["status"], "items": pics}
826
916
 
827
917
 
918
+ def send_collections_as_csv(collection_request: CollectionsRequest):
919
+ """Retrieves all collections of a given user as a CSV file.
920
+
921
+ The response is streamed from the database to be more efficient, so for the moment we do not support many parameters
922
+ """
923
+ if collection_request.pagination_filter:
924
+ raise errors.InvalidAPIUsage(_("CSV export does not support pagination"), status_code=400)
925
+ if collection_request.filters():
926
+ raise errors.InvalidAPIUsage(_("CSV export does not support filters"), status_code=400)
927
+ if collection_request.sort_by != SortBy(fields=[SortByField(field=STAC_FIELD_MAPPINGS["created"], direction=SQLDirection.DESC)]):
928
+ raise errors.InvalidAPIUsage(_("CSV export does not support sorting by anything but creation date"), status_code=400)
929
+
930
+ def generate_csv():
931
+ # yield f"{','.join([f.name for f in CSV_FIELDS])}\n"
932
+ filters = [SQL("account_id = %(account)s")]
933
+ params = {"account": collection_request.user_id}
934
+ filters.append(SQL("status != 'deleted'") if collection_request.userOwnsAllCollections else SQL("status = 'ready'"))
935
+
936
+ with db.cursor(current_app) as cursor:
937
+
938
+ with cursor.copy(
939
+ SQL(
940
+ """COPY (
941
+ SELECT
942
+ s.id AS id,
943
+ s.status AS status,
944
+ s.metadata->>'title' AS name,
945
+ s.inserted_at AS created,
946
+ s.updated_at AS updated,
947
+ s.computed_capture_date AS capture_date,
948
+ s.min_picture_ts AS minimum_capture_time,
949
+ s.max_picture_ts AS maximum_capture_time,
950
+ ST_XMin(s.bbox) AS min_x,
951
+ ST_YMin(s.bbox) AS min_y,
952
+ ST_XMax(s.bbox) AS max_x,
953
+ ST_YMax(s.bbox) AS max_y,
954
+ s.nb_pictures AS nb_pictures,
955
+ ROUND(ST_Length(s.geom::geography)) / 1000 AS length_km,
956
+ s.computed_h_pixel_density AS computed_h_pixel_density,
957
+ s.computed_gps_accuracy AS computed_gps_accuracy
958
+ FROM sequences s
959
+ WHERE {filter}
960
+ ORDER BY s.inserted_at DESC
961
+ ) TO STDOUT CSV HEADER"""
962
+ ).format(filter=SQL(" AND ").join(filters)),
963
+ params,
964
+ ) as copy:
965
+
966
+ for a in copy:
967
+ yield bytes(a)
968
+
969
+ return stream_with_context(generate_csv()), {"Content-Disposition": "attachment"}
970
+
971
+
828
972
  @bp.route("/users/<uuid:userId>/collection")
829
973
  @auth.isUserIdMatchingCurrentAccount()
830
974
  def getUserCollection(userId, userIdMatchesAccount=False):
@@ -838,6 +982,8 @@ def getUserCollection(userId, userIdMatchesAccount=False):
838
982
 
839
983
  Note that on paginated results, filter can only be used with column used in sortby parameter.
840
984
 
985
+ The result can also be a CSV file, if the "Accept" header is set to "text/csv", or if the "format" query parameter is set to "csv".
986
+ Note that when requesting a CSV file, the filters/sortby/pagination parameters are not supported, and `limit` is ignored, you always get the full list of collections.
841
987
  ---
842
988
  tags:
843
989
  - Sequences
@@ -849,6 +995,14 @@ def getUserCollection(userId, userIdMatchesAccount=False):
849
995
  required: true
850
996
  schema:
851
997
  type: string
998
+ - name: format
999
+ in: query
1000
+ description: Expected output format (STAC JSON or a csv file). Note that the CSV format support less parameters than the JSON format (cf documentation).
1001
+ required: false
1002
+ schema:
1003
+ type: string
1004
+ enum: [csv, json]
1005
+ default: json
852
1006
  - $ref: '#/components/parameters/STAC_collections_limit'
853
1007
  - $ref: '#/components/parameters/STAC_collections_filter'
854
1008
  - $ref: '#/components/parameters/STAC_bbox'
@@ -860,8 +1014,20 @@ def getUserCollection(userId, userIdMatchesAccount=False):
860
1014
  application/json:
861
1015
  schema:
862
1016
  $ref: '#/components/schemas/GeoVisioCollectionOfCollection'
1017
+
1018
+ text/csv:
1019
+ schema:
1020
+ $ref: '#/components/schemas/GeoVisioCSVCollections'
863
1021
  """
864
1022
 
1023
+ # Expected output format
1024
+ format = request.args["format"] if request.args.get("format") in ["csv", "json"] else "json"
1025
+ if (
1026
+ request.args.get("format") is None
1027
+ and request.accept_mimetypes.best_match(["application/json", "text/csv"], "application/json") == "text/csv"
1028
+ ):
1029
+ format = "csv"
1030
+
865
1031
  # Sort-by parameter
866
1032
  sortBy = parse_sortby(request.args.get("sortby"))
867
1033
  if not sortBy:
@@ -876,7 +1042,9 @@ def getUserCollection(userId, userIdMatchesAccount=False):
876
1042
  collection_request.pagination_filter = parse_filter(request.args.get("page"))
877
1043
 
878
1044
  # Limit parameter
879
- collection_request.limit = parse_collections_limit(request.args.get("limit"))
1045
+ # if not specified, the default with CSV it 1000. if there are more, the paginated API should be used
1046
+ arg_limit = request.args.get("limit")
1047
+ collection_request.limit = parse_collections_limit(arg_limit)
880
1048
  collection_request.user_id = userId
881
1049
 
882
1050
  # Bounding box
@@ -911,6 +1079,9 @@ def getUserCollection(userId, userIdMatchesAccount=False):
911
1079
  raise errors.InvalidAPIUsage(_("Impossible to find user %(u)s", u=userId))
912
1080
  userName = userName["name"]
913
1081
 
1082
+ if format == "csv":
1083
+ return send_collections_as_csv(collection_request)
1084
+
914
1085
  meta_collection = cursor.execute(
915
1086
  SQL(
916
1087
  """SELECT
@@ -984,6 +1155,7 @@ def getUserCollection(userId, userIdMatchesAccount=False):
984
1155
  )
985
1156
  collection = dbSequenceToStacCollection(meta_collection, description=f"List of all sequences of user {userName}")
986
1157
 
1158
+ collection["stats:collections"] = removeNoneInDict({"count": meta_collection["nbseq"]})
987
1159
  additional_filters = None
988
1160
  if collection_request.user_filter is not None:
989
1161
  # if some filters were given, we continue to pass them to the pagination
@@ -28,11 +28,14 @@ def configuration():
28
28
  {
29
29
  "name": _get_translated(apiSum.name, userLang),
30
30
  "description": _get_translated(apiSum.description, userLang),
31
+ "geo_coverage": _get_translated(apiSum.geo_coverage, userLang),
31
32
  "logo": apiSum.logo,
32
33
  "color": str(apiSum.color),
34
+ "email": apiSum.email,
33
35
  "auth": _auth_configuration(),
34
36
  "license": _license_configuration(),
35
37
  "version": get_api_version(),
38
+ "pages": _get_pages(),
36
39
  }
37
40
  )
38
41
 
@@ -47,7 +50,11 @@ def _auth_configuration():
47
50
  if auth.oauth_provider is None:
48
51
  return {"enabled": False}
49
52
  else:
50
- return {"enabled": True, "user_profile": {"url": auth.oauth_provider.user_profile_page_url()}}
53
+ return {
54
+ "enabled": True,
55
+ "user_profile": {"url": auth.oauth_provider.user_profile_page_url()},
56
+ "enforce_tos_acceptance": flask.current_app.config["API_ENFORCE_TOS_ACCEPTANCE"],
57
+ }
51
58
 
52
59
 
53
60
  def _license_configuration():
@@ -56,3 +63,12 @@ def _license_configuration():
56
63
  if u:
57
64
  l["url"] = u
58
65
  return l
66
+
67
+
68
+ def _get_pages():
69
+ from geovisio.utils import db
70
+ from flask import current_app
71
+
72
+ pages = db.fetchall(current_app, "SELECT distinct(name) FROM pages")
73
+
74
+ return [p[0] for p in pages]