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.
- geovisio/__init__.py +11 -3
- geovisio/admin_cli/__init__.py +3 -1
- geovisio/admin_cli/cleanup.py +2 -2
- 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 +235 -2
- geovisio/translations/el/LC_MESSAGES/messages.mo +0 -0
- geovisio/translations/el/LC_MESSAGES/messages.po +685 -0
- geovisio/translations/en/LC_MESSAGES/messages.mo +0 -0
- geovisio/translations/en/LC_MESSAGES/messages.po +244 -153
- 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 +40 -3
- geovisio/translations/hu/LC_MESSAGES/messages.mo +0 -0
- geovisio/translations/hu/LC_MESSAGES/messages.po +773 -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 +225 -148
- geovisio/translations/nl/LC_MESSAGES/messages.mo +0 -0
- geovisio/translations/nl/LC_MESSAGES/messages.po +24 -16
- 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 +29 -62
- geovisio/utils/semantics.py +120 -0
- geovisio/utils/sequences.py +30 -23
- geovisio/utils/tokens.py +5 -3
- geovisio/utils/upload_set.py +87 -64
- geovisio/utils/website.py +50 -0
- geovisio/web/annotations.py +17 -0
- geovisio/web/auth.py +9 -5
- geovisio/web/collections.py +235 -63
- geovisio/web/configuration.py +17 -1
- geovisio/web/docs.py +99 -54
- geovisio/web/items.py +233 -100
- geovisio/web/map.py +129 -31
- 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 +19 -10
- geovisio/web/users.py +176 -44
- geovisio/workers/runner_pictures.py +75 -50
- {geovisio-2.7.0.dist-info → geovisio-2.8.0.dist-info}/METADATA +6 -5
- geovisio-2.8.0.dist-info/RECORD +89 -0
- {geovisio-2.7.0.dist-info → geovisio-2.8.0.dist-info}/WHEEL +1 -1
- geovisio-2.7.0.dist-info/RECORD +0 -66
- {geovisio-2.7.0.dist-info → geovisio-2.8.0.dist-info}/LICENSE +0 -0
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")
|
|
@@ -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": [
|
|
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(
|
|
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
|
-
|
|
665
|
+
metadata = None
|
|
666
|
+
try:
|
|
567
667
|
if request.is_json and request.json:
|
|
568
|
-
metadata
|
|
668
|
+
metadata = PatchCollectionParameter(**request.json)
|
|
569
669
|
elif content_type in ["multipart/form-data", "application/x-www-form-urlencoded"]:
|
|
570
|
-
metadata
|
|
571
|
-
|
|
572
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
631
|
-
|
|
632
|
-
|
|
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
|
|
646
|
-
new_metadata["title"] =
|
|
647
|
-
if
|
|
648
|
-
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
|
|
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
|
|
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
|
|
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=
|
|
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
|
-
|
|
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
|
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]
|