geovisio 2.7.1__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 (60) hide show
  1. geovisio/__init__.py +10 -2
  2. geovisio/admin_cli/__init__.py +3 -1
  3. geovisio/admin_cli/user.py +75 -0
  4. geovisio/config_app.py +87 -4
  5. geovisio/templates/main.html +2 -2
  6. geovisio/templates/viewer.html +3 -3
  7. geovisio/translations/da/LC_MESSAGES/messages.mo +0 -0
  8. geovisio/translations/da/LC_MESSAGES/messages.po +850 -0
  9. geovisio/translations/de/LC_MESSAGES/messages.mo +0 -0
  10. geovisio/translations/de/LC_MESSAGES/messages.po +97 -1
  11. geovisio/translations/el/LC_MESSAGES/messages.mo +0 -0
  12. geovisio/translations/en/LC_MESSAGES/messages.mo +0 -0
  13. geovisio/translations/en/LC_MESSAGES/messages.po +210 -127
  14. geovisio/translations/eo/LC_MESSAGES/messages.mo +0 -0
  15. geovisio/translations/eo/LC_MESSAGES/messages.po +790 -0
  16. geovisio/translations/es/LC_MESSAGES/messages.mo +0 -0
  17. geovisio/translations/fi/LC_MESSAGES/messages.mo +0 -0
  18. geovisio/translations/fr/LC_MESSAGES/messages.mo +0 -0
  19. geovisio/translations/fr/LC_MESSAGES/messages.po +39 -2
  20. geovisio/translations/hu/LC_MESSAGES/messages.mo +0 -0
  21. geovisio/translations/it/LC_MESSAGES/messages.mo +0 -0
  22. geovisio/translations/it/LC_MESSAGES/messages.po +875 -0
  23. geovisio/translations/ja/LC_MESSAGES/messages.mo +0 -0
  24. geovisio/translations/ja/LC_MESSAGES/messages.po +719 -0
  25. geovisio/translations/ko/LC_MESSAGES/messages.mo +0 -0
  26. geovisio/translations/messages.pot +191 -122
  27. geovisio/translations/nl/LC_MESSAGES/messages.mo +0 -0
  28. geovisio/translations/pl/LC_MESSAGES/messages.mo +0 -0
  29. geovisio/translations/pl/LC_MESSAGES/messages.po +727 -0
  30. geovisio/translations/zh_Hant/LC_MESSAGES/messages.mo +0 -0
  31. geovisio/translations/zh_Hant/LC_MESSAGES/messages.po +719 -0
  32. geovisio/utils/auth.py +80 -8
  33. geovisio/utils/link.py +3 -2
  34. geovisio/utils/model_query.py +55 -0
  35. geovisio/utils/pictures.py +12 -43
  36. geovisio/utils/semantics.py +120 -0
  37. geovisio/utils/sequences.py +10 -1
  38. geovisio/utils/tokens.py +5 -3
  39. geovisio/utils/upload_set.py +50 -15
  40. geovisio/utils/website.py +50 -0
  41. geovisio/web/annotations.py +17 -0
  42. geovisio/web/auth.py +9 -5
  43. geovisio/web/collections.py +217 -61
  44. geovisio/web/configuration.py +17 -1
  45. geovisio/web/docs.py +64 -53
  46. geovisio/web/items.py +220 -96
  47. geovisio/web/map.py +48 -18
  48. geovisio/web/pages.py +240 -0
  49. geovisio/web/params.py +17 -0
  50. geovisio/web/prepare.py +165 -0
  51. geovisio/web/stac.py +17 -4
  52. geovisio/web/tokens.py +14 -4
  53. geovisio/web/upload_set.py +10 -4
  54. geovisio/web/users.py +176 -44
  55. geovisio/workers/runner_pictures.py +61 -22
  56. {geovisio-2.7.1.dist-info → geovisio-2.8.0.dist-info}/METADATA +5 -4
  57. geovisio-2.8.0.dist-info/RECORD +89 -0
  58. geovisio-2.7.1.dist-info/RECORD +0 -70
  59. {geovisio-2.7.1.dist-info → geovisio-2.8.0.dist-info}/LICENSE +0 -0
  60. {geovisio-2.7.1.dist-info → geovisio-2.8.0.dist-info}/WHEEL +0 -0
geovisio/web/auth.py CHANGED
@@ -70,7 +70,7 @@ def auth():
70
70
  oauth_info = utils.auth.oauth_provider.get_user_oauth_info(tokenResponse)
71
71
  with db.cursor(current_app) as cursor:
72
72
  res = cursor.execute(
73
- "INSERT INTO accounts (name, oauth_provider, oauth_id) VALUES (%(name)s, %(provider)s, %(id)s) ON CONFLICT (oauth_provider, oauth_id) DO UPDATE SET name = %(name)s RETURNING id, name",
73
+ "INSERT INTO accounts (name, oauth_provider, oauth_id) VALUES (%(name)s, %(provider)s, %(id)s) ON CONFLICT (oauth_provider, oauth_id) DO UPDATE SET name = %(name)s RETURNING id, name, tos_accepted",
74
74
  {
75
75
  "provider": utils.auth.oauth_provider.name,
76
76
  "id": oauth_info.id,
@@ -79,21 +79,25 @@ def auth():
79
79
  ).fetchone()
80
80
  if res is None:
81
81
  raise Exception("Impossible to insert user in database")
82
- id, name = res
82
+ id, name, tos_accepted = res
83
83
  account = Account(
84
84
  id=str(id), # convert uuid to string for serialization
85
85
  name=name,
86
86
  oauth_provider=utils.auth.oauth_provider.name,
87
87
  oauth_id=oauth_info.id,
88
+ tos_accepted=tos_accepted,
88
89
  )
89
90
  session[ACCOUNT_KEY] = account.model_dump(exclude_none=True)
90
91
  session.permanent = True
91
92
 
92
93
  next_url = session.pop(NEXT_URL_KEY, None)
93
- if next_url:
94
- response = flask.make_response(redirect(next_url))
94
+ if not tos_accepted and current_app.config["API_ENFORCE_TOS_ACCEPTANCE"]:
95
+ args = {"next_url": next_url} if next_url else None
96
+ next_url = current_app.config["API_WEBSITE_URL"].tos_validation_page(args)
95
97
  else:
96
- response = flask.make_response(redirect("/"))
98
+ next_url = next_url or "/"
99
+
100
+ response = flask.make_response(redirect(next_url))
97
101
 
98
102
  # also store id/name in cookies for the front end to use those
99
103
  max_age = current_app.config["PERMANENT_SESSION_LIFETIME"]
@@ -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")
@@ -90,6 +94,7 @@ def dbSequenceToStacCollection(dbSeq, description="A sequence of geolocated pict
90
94
  "title": str(dbSeq["name"]),
91
95
  "description": description,
92
96
  "keywords": ["pictures", str(dbSeq["name"])],
97
+ "semantics": dbSeq["semantics"] if "semantics" in dbSeq else None,
93
98
  "license": current_app.config["API_PICTURES_LICENSE_SPDX_ID"],
94
99
  "created": dbTsToStac(dbSeq["created"]),
95
100
  "updated": dbTsToStac(dbSeq.get("updated")),
@@ -390,8 +395,17 @@ def getCollection(collectionId):
390
395
  s.user_agent,
391
396
  ROUND(ST_Length(s.geom::geography)) / 1000 as length_km,
392
397
  s.computed_h_pixel_density,
393
- s.computed_gps_accuracy
398
+ s.computed_gps_accuracy,
399
+ t.semantics
394
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
395
409
  JOIN accounts ON s.account_id = accounts.id, (
396
410
  SELECT
397
411
  array_agg(DISTINCT jsonb_build_object(
@@ -539,13 +553,83 @@ def postCollection(account=None):
539
553
  )
540
554
 
541
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
+
542
619
  @bp.route("/collections/<uuid:collectionId>", methods=["PATCH"])
543
620
  @auth.login_required()
544
621
  def patchCollection(collectionId, account):
545
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.
546
629
  ---
547
630
  tags:
548
631
  - Editing
632
+ - Tags
549
633
  parameters:
550
634
  - name: collectionId
551
635
  in: path
@@ -577,48 +661,18 @@ def patchCollection(collectionId, account):
577
661
  """
578
662
 
579
663
  # Parse received parameters
580
- metadata = {}
581
664
  content_type = (request.headers.get("Content-Type") or "").split(";")[0]
582
- for param in ["visible", "title", "relative_heading", "sortby"]:
665
+ metadata = None
666
+ try:
583
667
  if request.is_json and request.json:
584
- metadata[param] = request.json.get(param)
668
+ metadata = PatchCollectionParameter(**request.json)
585
669
  elif content_type in ["multipart/form-data", "application/x-www-form-urlencoded"]:
586
- metadata[param] = request.form.get(param)
587
-
588
- # Check if visibility param is valid
589
- visible = metadata.get("visible")
590
- if visible is not None:
591
- if visible in ["true", "false"]:
592
- visible = visible == "true"
593
- else:
594
- raise errors.InvalidAPIUsage(_("Picture visibility parameter (visible) should be either unset, true or false"), status_code=400)
595
-
596
- # Check if title is valid
597
- newTitle = metadata.get("title")
598
- if newTitle is not None:
599
- if not (isinstance(newTitle, str) and len(newTitle) <= 250):
600
- raise errors.InvalidAPIUsage(_("Sequence title is not valid, should be a string with a max of 250 characters"), status_code=400)
601
-
602
- # Check if sortby is valid
603
- sortby = metadata.get("sortby")
604
- if sortby is not None:
605
- if sortby not in ["+gpsdate", "-gpsdate", "+filedate", "-filedate", "+filename", "-filename"]:
606
- raise errors.InvalidAPIUsage(_("Sort order parameter is invalid"), status_code=400)
607
-
608
- # Check if relative_heading is valid
609
- relHeading = metadata.get("relative_heading")
610
- if relHeading is not None:
611
- try:
612
- relHeading = int(relHeading)
613
- if relHeading < -180 or relHeading > 180:
614
- raise ValueError()
615
- except ValueError:
616
- raise errors.InvalidAPIUsage(
617
- _("Relative heading is not valid, should be an integer in degrees from -180 to 180"), status_code=400
618
- )
670
+ metadata = PatchCollectionParameter(**request.form)
671
+ except ValidationError as ve:
672
+ raise errors.InvalidAPIUsage(_("Impossible to parse parameters"), payload=validation_error(ve))
619
673
 
620
674
  # If no parameter is changed, no need to contact DB, just return sequence as is
621
- if {visible, newTitle, relHeading, sortby} == {None}:
675
+ if metadata is None or not metadata.has_override():
622
676
  return getCollection(collectionId)
623
677
 
624
678
  # Check if sequence exists and if given account is authorized to edit
@@ -633,9 +687,24 @@ def patchCollection(collectionId, account):
633
687
  if not seq:
634
688
  raise errors.InvalidAPIUsage(_("Collection %(c)s wasn't found in database", c=collectionId), status_code=404)
635
689
 
636
- # Account associated to sequence doesn't match current user
637
690
  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)
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
+ )
639
708
 
640
709
  oldStatus = seq["status"]
641
710
  oldMetadata = seq["metadata"]
@@ -643,25 +712,26 @@ def patchCollection(collectionId, account):
643
712
 
644
713
  # Check if sequence is in a preparing/broken/... state so no edit possible
645
714
  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
- )
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
+ )
650
720
 
651
721
  sqlUpdates = []
652
722
  sqlParams = {"id": collectionId, "account": account.id}
653
723
 
654
- if visible is not None:
655
- 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"
656
726
  if newStatus != oldStatus:
657
727
  sqlUpdates.append(SQL("status = %(status)s"))
658
728
  sqlParams["status"] = newStatus
659
729
 
660
730
  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
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
665
735
 
666
736
  if new_metadata:
667
737
  sqlUpdates.append(SQL("metadata = metadata || %(new_metadata)s"))
@@ -669,9 +739,9 @@ def patchCollection(collectionId, account):
669
739
 
670
740
  sqlParams["new_metadata"] = Jsonb(new_metadata)
671
741
 
672
- if sortby is not None:
742
+ if metadata.sortby is not None:
673
743
  sqlUpdates.append(SQL("current_sort = %(sort)s"))
674
- sqlParams["sort"] = sortby
744
+ sqlParams["sort"] = metadata.sortby
675
745
 
676
746
  if len(sqlUpdates) > 0:
677
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)
@@ -690,25 +760,29 @@ def patchCollection(collectionId, account):
690
760
  )
691
761
 
692
762
  # Edits picture sort order
693
- if sortby is not None:
694
- direction = sequences.Direction(sortby[0])
695
- 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:])
696
766
  sequences.sort_collection(cursor, collectionId, sequences.CollectionSort(order=order, direction=direction))
697
- if not relHeading:
767
+ if not metadata.relative_heading:
698
768
  # if we do not plan to override headings specifically, we recompute headings that have not bee provided by the users
699
769
  # with the new movement track
700
770
  sequences.update_headings(cursor, collectionId, editingAccount=account.id)
701
771
 
702
772
  # Edits relative heading of pictures in sequence
703
- if relHeading is not None:
773
+ if metadata.relative_heading is not None:
704
774
  # New heading is computed based on sequence movement track
705
775
  # We take each picture and its following, compute azimuth,
706
776
  # then add given relative heading to offset picture heading.
707
777
  # Last picture is computed based on previous one in sequence.
708
778
  sequences.update_headings(
709
- cursor, collectionId, relativeHeading=relHeading, updateOnlyMissing=False, editingAccount=account.id
779
+ cursor, collectionId, relativeHeading=metadata.relative_heading, updateOnlyMissing=False, editingAccount=account.id
710
780
  )
711
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
+
712
786
  # Redirect response to a classic GET
713
787
  return getCollection(collectionId)
714
788
 
@@ -841,6 +915,60 @@ FROM items i;""",
841
915
  return {"status": sequence_status["status"], "items": pics}
842
916
 
843
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
+
844
972
  @bp.route("/users/<uuid:userId>/collection")
845
973
  @auth.isUserIdMatchingCurrentAccount()
846
974
  def getUserCollection(userId, userIdMatchesAccount=False):
@@ -854,6 +982,8 @@ def getUserCollection(userId, userIdMatchesAccount=False):
854
982
 
855
983
  Note that on paginated results, filter can only be used with column used in sortby parameter.
856
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.
857
987
  ---
858
988
  tags:
859
989
  - Sequences
@@ -865,6 +995,14 @@ def getUserCollection(userId, userIdMatchesAccount=False):
865
995
  required: true
866
996
  schema:
867
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
868
1006
  - $ref: '#/components/parameters/STAC_collections_limit'
869
1007
  - $ref: '#/components/parameters/STAC_collections_filter'
870
1008
  - $ref: '#/components/parameters/STAC_bbox'
@@ -876,8 +1014,20 @@ def getUserCollection(userId, userIdMatchesAccount=False):
876
1014
  application/json:
877
1015
  schema:
878
1016
  $ref: '#/components/schemas/GeoVisioCollectionOfCollection'
1017
+
1018
+ text/csv:
1019
+ schema:
1020
+ $ref: '#/components/schemas/GeoVisioCSVCollections'
879
1021
  """
880
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
+
881
1031
  # Sort-by parameter
882
1032
  sortBy = parse_sortby(request.args.get("sortby"))
883
1033
  if not sortBy:
@@ -892,7 +1042,9 @@ def getUserCollection(userId, userIdMatchesAccount=False):
892
1042
  collection_request.pagination_filter = parse_filter(request.args.get("page"))
893
1043
 
894
1044
  # Limit parameter
895
- 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)
896
1048
  collection_request.user_id = userId
897
1049
 
898
1050
  # Bounding box
@@ -927,6 +1079,9 @@ def getUserCollection(userId, userIdMatchesAccount=False):
927
1079
  raise errors.InvalidAPIUsage(_("Impossible to find user %(u)s", u=userId))
928
1080
  userName = userName["name"]
929
1081
 
1082
+ if format == "csv":
1083
+ return send_collections_as_csv(collection_request)
1084
+
930
1085
  meta_collection = cursor.execute(
931
1086
  SQL(
932
1087
  """SELECT
@@ -1000,6 +1155,7 @@ def getUserCollection(userId, userIdMatchesAccount=False):
1000
1155
  )
1001
1156
  collection = dbSequenceToStacCollection(meta_collection, description=f"List of all sequences of user {userName}")
1002
1157
 
1158
+ collection["stats:collections"] = removeNoneInDict({"count": meta_collection["nbseq"]})
1003
1159
  additional_filters = None
1004
1160
  if collection_request.user_filter is not None:
1005
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]