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.
- geovisio/__init__.py +10 -2
- geovisio/admin_cli/__init__.py +3 -1
- geovisio/admin_cli/user.py +75 -0
- geovisio/config_app.py +87 -4
- geovisio/templates/main.html +2 -2
- geovisio/templates/viewer.html +3 -3
- geovisio/translations/da/LC_MESSAGES/messages.mo +0 -0
- geovisio/translations/da/LC_MESSAGES/messages.po +850 -0
- geovisio/translations/de/LC_MESSAGES/messages.mo +0 -0
- geovisio/translations/de/LC_MESSAGES/messages.po +97 -1
- geovisio/translations/el/LC_MESSAGES/messages.mo +0 -0
- geovisio/translations/en/LC_MESSAGES/messages.mo +0 -0
- geovisio/translations/en/LC_MESSAGES/messages.po +210 -127
- geovisio/translations/eo/LC_MESSAGES/messages.mo +0 -0
- geovisio/translations/eo/LC_MESSAGES/messages.po +790 -0
- geovisio/translations/es/LC_MESSAGES/messages.mo +0 -0
- geovisio/translations/fi/LC_MESSAGES/messages.mo +0 -0
- geovisio/translations/fr/LC_MESSAGES/messages.mo +0 -0
- geovisio/translations/fr/LC_MESSAGES/messages.po +39 -2
- geovisio/translations/hu/LC_MESSAGES/messages.mo +0 -0
- geovisio/translations/it/LC_MESSAGES/messages.mo +0 -0
- geovisio/translations/it/LC_MESSAGES/messages.po +875 -0
- geovisio/translations/ja/LC_MESSAGES/messages.mo +0 -0
- geovisio/translations/ja/LC_MESSAGES/messages.po +719 -0
- geovisio/translations/ko/LC_MESSAGES/messages.mo +0 -0
- geovisio/translations/messages.pot +191 -122
- geovisio/translations/nl/LC_MESSAGES/messages.mo +0 -0
- geovisio/translations/pl/LC_MESSAGES/messages.mo +0 -0
- geovisio/translations/pl/LC_MESSAGES/messages.po +727 -0
- geovisio/translations/zh_Hant/LC_MESSAGES/messages.mo +0 -0
- geovisio/translations/zh_Hant/LC_MESSAGES/messages.po +719 -0
- geovisio/utils/auth.py +80 -8
- geovisio/utils/link.py +3 -2
- geovisio/utils/model_query.py +55 -0
- geovisio/utils/pictures.py +12 -43
- geovisio/utils/semantics.py +120 -0
- geovisio/utils/sequences.py +10 -1
- geovisio/utils/tokens.py +5 -3
- geovisio/utils/upload_set.py +50 -15
- geovisio/utils/website.py +50 -0
- geovisio/web/annotations.py +17 -0
- geovisio/web/auth.py +9 -5
- geovisio/web/collections.py +217 -61
- geovisio/web/configuration.py +17 -1
- geovisio/web/docs.py +64 -53
- geovisio/web/items.py +220 -96
- geovisio/web/map.py +48 -18
- geovisio/web/pages.py +240 -0
- geovisio/web/params.py +17 -0
- geovisio/web/prepare.py +165 -0
- geovisio/web/stac.py +17 -4
- geovisio/web/tokens.py +14 -4
- geovisio/web/upload_set.py +10 -4
- geovisio/web/users.py +176 -44
- geovisio/workers/runner_pictures.py +61 -22
- {geovisio-2.7.1.dist-info → geovisio-2.8.0.dist-info}/METADATA +5 -4
- geovisio-2.8.0.dist-info/RECORD +89 -0
- geovisio-2.7.1.dist-info/RECORD +0 -70
- {geovisio-2.7.1.dist-info → geovisio-2.8.0.dist-info}/LICENSE +0 -0
- {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
|
|
94
|
-
|
|
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
|
-
|
|
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"]
|
geovisio/web/collections.py
CHANGED
|
@@ -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
|
|
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
|
-
|
|
665
|
+
metadata = None
|
|
666
|
+
try:
|
|
583
667
|
if request.is_json and request.json:
|
|
584
|
-
metadata
|
|
668
|
+
metadata = PatchCollectionParameter(**request.json)
|
|
585
669
|
elif content_type in ["multipart/form-data", "application/x-www-form-urlencoded"]:
|
|
586
|
-
metadata
|
|
587
|
-
|
|
588
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
647
|
-
|
|
648
|
-
|
|
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
|
|
662
|
-
new_metadata["title"] =
|
|
663
|
-
if
|
|
664
|
-
new_metadata["relative_heading"] =
|
|
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
|
|
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
|
|
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=
|
|
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
|
-
|
|
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
|
geovisio/web/configuration.py
CHANGED
|
@@ -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 {
|
|
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]
|