geovisio 2.10.0__py3-none-any.whl → 2.11.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 +3 -1
- geovisio/admin_cli/user.py +7 -2
- geovisio/config_app.py +21 -7
- geovisio/translations/be/LC_MESSAGES/messages.mo +0 -0
- geovisio/translations/be/LC_MESSAGES/messages.po +886 -0
- geovisio/translations/da/LC_MESSAGES/messages.mo +0 -0
- geovisio/translations/da/LC_MESSAGES/messages.po +96 -5
- geovisio/translations/de/LC_MESSAGES/messages.mo +0 -0
- geovisio/translations/de/LC_MESSAGES/messages.po +171 -132
- geovisio/translations/en/LC_MESSAGES/messages.mo +0 -0
- geovisio/translations/en/LC_MESSAGES/messages.po +169 -146
- geovisio/translations/eo/LC_MESSAGES/messages.mo +0 -0
- geovisio/translations/eo/LC_MESSAGES/messages.po +3 -2
- geovisio/translations/fr/LC_MESSAGES/messages.mo +0 -0
- geovisio/translations/fr/LC_MESSAGES/messages.po +3 -2
- geovisio/translations/it/LC_MESSAGES/messages.mo +0 -0
- geovisio/translations/it/LC_MESSAGES/messages.po +1 -1
- geovisio/translations/messages.pot +159 -138
- geovisio/translations/nl/LC_MESSAGES/messages.mo +0 -0
- geovisio/translations/nl/LC_MESSAGES/messages.po +44 -2
- geovisio/translations/oc/LC_MESSAGES/messages.mo +0 -0
- geovisio/translations/oc/LC_MESSAGES/messages.po +9 -6
- geovisio/translations/pt/LC_MESSAGES/messages.mo +0 -0
- geovisio/translations/pt/LC_MESSAGES/messages.po +944 -0
- geovisio/translations/pt_BR/LC_MESSAGES/messages.mo +0 -0
- geovisio/translations/pt_BR/LC_MESSAGES/messages.po +942 -0
- geovisio/translations/sv/LC_MESSAGES/messages.mo +0 -0
- geovisio/translations/sv/LC_MESSAGES/messages.po +1 -1
- geovisio/translations/tr/LC_MESSAGES/messages.mo +0 -0
- geovisio/translations/tr/LC_MESSAGES/messages.po +927 -0
- geovisio/translations/uk/LC_MESSAGES/messages.mo +0 -0
- geovisio/translations/uk/LC_MESSAGES/messages.po +920 -0
- geovisio/utils/annotations.py +7 -4
- geovisio/utils/auth.py +33 -0
- geovisio/utils/cql2.py +20 -3
- geovisio/utils/pictures.py +16 -18
- geovisio/utils/sequences.py +104 -75
- geovisio/utils/upload_set.py +20 -10
- geovisio/utils/users.py +18 -0
- geovisio/web/annotations.py +96 -3
- geovisio/web/collections.py +169 -76
- geovisio/web/configuration.py +12 -0
- geovisio/web/docs.py +17 -3
- geovisio/web/items.py +129 -72
- geovisio/web/map.py +92 -54
- geovisio/web/pages.py +48 -4
- geovisio/web/params.py +56 -11
- geovisio/web/pictures.py +3 -3
- geovisio/web/prepare.py +4 -2
- geovisio/web/queryables.py +57 -0
- geovisio/web/stac.py +8 -2
- geovisio/web/upload_set.py +83 -26
- geovisio/web/users.py +85 -4
- geovisio/web/utils.py +24 -6
- {geovisio-2.10.0.dist-info → geovisio-2.11.0.dist-info}/METADATA +3 -2
- {geovisio-2.10.0.dist-info → geovisio-2.11.0.dist-info}/RECORD +58 -46
- {geovisio-2.10.0.dist-info → geovisio-2.11.0.dist-info}/WHEEL +0 -0
- {geovisio-2.10.0.dist-info → geovisio-2.11.0.dist-info}/licenses/LICENSE +0 -0
geovisio/utils/annotations.py
CHANGED
|
@@ -177,7 +177,10 @@ def update_annotation(annotation: Annotation, tag_updates: List[SemanticTagUpdat
|
|
|
177
177
|
return a
|
|
178
178
|
|
|
179
179
|
|
|
180
|
-
def delete_annotation(conn: psycopg.Connection,
|
|
181
|
-
"""Delete an annotation from the database
|
|
182
|
-
|
|
183
|
-
|
|
180
|
+
def delete_annotation(conn: psycopg.Connection, annotation: Annotation, account_id: UUID) -> None:
|
|
181
|
+
"""Delete an annotation from the database
|
|
182
|
+
Note: to track the history, we delete each tags separately, and the annotation should be deleted after its last tag is deleted"""
|
|
183
|
+
with conn.cursor(row_factory=dict_row) as cursor:
|
|
184
|
+
actions = [SemanticTagUpdate(action=semantics.TagAction.delete, key=t.key, value=t.value) for t in annotation.semantics]
|
|
185
|
+
entity = semantics.Entity(id=annotation.id, type=semantics.EntityType.annotation)
|
|
186
|
+
semantics.update_tags(cursor, entity, actions, account=account_id, annotation=annotation)
|
geovisio/utils/auth.py
CHANGED
|
@@ -200,6 +200,25 @@ class Account(BaseModel):
|
|
|
200
200
|
"""Is account legitimate to edit web pages ?"""
|
|
201
201
|
return self.role == AccountRole.admin
|
|
202
202
|
|
|
203
|
+
def can_edit_item(self, item_account_id: str):
|
|
204
|
+
"""Is account legitimate to edit an item owned by `item_account_id` ?
|
|
205
|
+
Admin can edit everything, then the item owner can edit only its own item"""
|
|
206
|
+
return self.role == AccountRole.admin or self.id == item_account_id
|
|
207
|
+
|
|
208
|
+
def can_edit_collection(self, col_account_id: str):
|
|
209
|
+
"""Is account legitimate to edit a collection owned by `col_account_id` ?
|
|
210
|
+
Admin can edit everything, then the collection owner can edit only its own collection"""
|
|
211
|
+
return self.role == AccountRole.admin or self.id == col_account_id
|
|
212
|
+
|
|
213
|
+
def can_edit_upload_set(self, us_account_id: str):
|
|
214
|
+
"""Is account legitimate to edit an upload set owned by `us_account_id` ?
|
|
215
|
+
Admin can edit everything, then the us owner can edit only its own us"""
|
|
216
|
+
return self.role == AccountRole.admin or self.id == us_account_id
|
|
217
|
+
|
|
218
|
+
def can_see_all(self):
|
|
219
|
+
"""Can the account see all pictures/sequences/upload_sets ?"""
|
|
220
|
+
return self.role == AccountRole.admin
|
|
221
|
+
|
|
203
222
|
@property
|
|
204
223
|
def role(self) -> AccountRole:
|
|
205
224
|
if self.role_ is None:
|
|
@@ -387,6 +406,20 @@ def get_current_account() -> Optional[Account]:
|
|
|
387
406
|
return None
|
|
388
407
|
|
|
389
408
|
|
|
409
|
+
def get_current_account_id() -> Optional[UUID]:
|
|
410
|
+
"""Get the authenticated account ID.
|
|
411
|
+
|
|
412
|
+
This account is either stored in the flask's session or retrieved with the Bearer token passed with an `Authorization` header.
|
|
413
|
+
|
|
414
|
+
The flask session is usually used by browser, whereas the bearer token is handy for non interactive uses, like curls or CLI usage.
|
|
415
|
+
|
|
416
|
+
Returns:
|
|
417
|
+
The current logged account ID, None if nobody is logged
|
|
418
|
+
"""
|
|
419
|
+
account_to_query = get_current_account()
|
|
420
|
+
return account_to_query.id if account_to_query is not None else None
|
|
421
|
+
|
|
422
|
+
|
|
390
423
|
def _get_bearer_token() -> Optional[str]:
|
|
391
424
|
"""
|
|
392
425
|
Get the associated bearer token from the `Authorization` header
|
geovisio/utils/cql2.py
CHANGED
|
@@ -43,6 +43,11 @@ def parse_semantic_filter(value: Optional[str]) -> Optional[sql.SQL]:
|
|
|
43
43
|
SQL("((key = 'pouet') AND (value = 'stop'))")
|
|
44
44
|
>>> parse_semantic_filter("\\"semantics.osm|traffic_sign\\"='stop'")
|
|
45
45
|
SQL("((key = 'osm|traffic_sign') AND (value = 'stop'))")
|
|
46
|
+
>>> parse_semantic_filter("\\"semantics\\" IS NOT NULL")
|
|
47
|
+
SQL('True')
|
|
48
|
+
>>> parse_semantic_filter("\\"semantics\\" IS NULL") # doctest: +IGNORE_EXCEPTION_DETAIL
|
|
49
|
+
Traceback (most recent call last):
|
|
50
|
+
geovisio.errors.InvalidAPIUsage: Unsupported filter parameter: only `semantics IS NOT NULL` is supported (to express that we want all items with at least one semantic tags)
|
|
46
51
|
"""
|
|
47
52
|
return parse_cql2_filter(value, SEMANTIC_FIELD_MAPPOING, ast_updater=lambda a: SemanticAttributesAstUpdater().evaluate(a))
|
|
48
53
|
|
|
@@ -52,6 +57,8 @@ def parse_search_filter(value: Optional[str]) -> Optional[sql.SQL]:
|
|
|
52
57
|
|
|
53
58
|
Note that, for the moment, only semantics are supported. If more needs to be supported, we should evaluate the
|
|
54
59
|
non semantic filters separately (likely with a AstEvaluator).
|
|
60
|
+
|
|
61
|
+
Note: if more search filters are added, don't forget to add them to the qeryables endpoint (in queryables.py)
|
|
55
62
|
"""
|
|
56
63
|
s = parse_semantic_filter(value)
|
|
57
64
|
|
|
@@ -91,6 +98,7 @@ class SemanticAttributesAstUpdater(Evaluator):
|
|
|
91
98
|
So
|
|
92
99
|
* `semantics.some_tag='some_value'` becomes `(key = 'some_tag' AND value = 'some_value')`
|
|
93
100
|
* `semantics.some_tag IN ('some_value', 'some_other_value')` becomes `(key = 'some_tag' AND value IN ('some_value', 'some_other_value'))`
|
|
101
|
+
* `semantics IS NOT NULL` becomes `True` (to get all elements with some semantics)
|
|
94
102
|
"""
|
|
95
103
|
|
|
96
104
|
@handle(ast.Equal)
|
|
@@ -112,14 +120,23 @@ class SemanticAttributesAstUpdater(Evaluator):
|
|
|
112
120
|
|
|
113
121
|
@handle(ast.IsNull)
|
|
114
122
|
def is_null(self, node, lhs):
|
|
123
|
+
semantic_attribute = get_semantic_attribute(lhs)
|
|
124
|
+
if semantic_attribute is None:
|
|
125
|
+
if lhs.name == "semantics":
|
|
126
|
+
# semantics IS NOT NULL means we want all elements with some semantics (=> we return True)
|
|
127
|
+
# semantics IS NULL is not yet handled
|
|
128
|
+
if node.not_:
|
|
129
|
+
return True
|
|
130
|
+
raise errors.InvalidAPIUsage(
|
|
131
|
+
"Unsupported filter parameter: only `semantics IS NOT NULL` is supported (to express that we want all items with at least one semantic tags)",
|
|
132
|
+
status_code=400,
|
|
133
|
+
)
|
|
134
|
+
return node
|
|
115
135
|
if not node.not_:
|
|
116
136
|
raise errors.InvalidAPIUsage(
|
|
117
137
|
"Unsupported filter parameter: only `IS NOT NULL` is supported (to express that we want all values of a semantic tags)",
|
|
118
138
|
status_code=400,
|
|
119
139
|
)
|
|
120
|
-
semantic_attribute = get_semantic_attribute(lhs)
|
|
121
|
-
if semantic_attribute is None:
|
|
122
|
-
return node
|
|
123
140
|
return ast.Equal(ast.Attribute("key"), semantic_attribute)
|
|
124
141
|
|
|
125
142
|
@handle(ast.In)
|
geovisio/utils/pictures.py
CHANGED
|
@@ -437,25 +437,23 @@ def checkPictureStatus(fses, pictureId):
|
|
|
437
437
|
if current_app.config["DEBUG_PICTURES_SKIP_FS_CHECKS_WITH_PUBLIC_URL"]:
|
|
438
438
|
return {"status": "ready"}
|
|
439
439
|
|
|
440
|
-
|
|
441
|
-
accountId = account.id if account is not None else None
|
|
440
|
+
accountId = utils.auth.get_current_account_id()
|
|
442
441
|
# Check picture availability + status
|
|
443
442
|
picMetadata = utils.db.fetchone(
|
|
444
443
|
current_app,
|
|
445
|
-
"""
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
[pictureId],
|
|
444
|
+
"""SELECT
|
|
445
|
+
p.status,
|
|
446
|
+
(p.metadata->>'cols')::int AS cols,
|
|
447
|
+
(p.metadata->>'rows')::int AS rows,
|
|
448
|
+
p.metadata->>'type' AS type,
|
|
449
|
+
p.account_id,
|
|
450
|
+
s.status AS seq_status,
|
|
451
|
+
COALESCE(p.visibility, s.visibility) AS visibility
|
|
452
|
+
FROM pictures p
|
|
453
|
+
JOIN sequences_pictures sp ON sp.pic_id = p.id
|
|
454
|
+
JOIN sequences s ON s.id = sp.seq_id
|
|
455
|
+
WHERE p.id = %(pic_id)s AND is_picture_visible_by_user(p, %(account)s) AND is_sequence_visible_by_user(s, %(account)s)""",
|
|
456
|
+
{"pic_id": pictureId, "account": accountId},
|
|
459
457
|
row_factory=dict_row,
|
|
460
458
|
)
|
|
461
459
|
|
|
@@ -463,7 +461,7 @@ def checkPictureStatus(fses, pictureId):
|
|
|
463
461
|
raise errors.InvalidAPIUsage(_("Picture can't be found, you may check its ID"), status_code=404)
|
|
464
462
|
|
|
465
463
|
if (picMetadata["status"] != "ready" or picMetadata["seq_status"] != "ready") and accountId != str(picMetadata["account_id"]):
|
|
466
|
-
raise errors.InvalidAPIUsage(_("Picture is not available (
|
|
464
|
+
raise errors.InvalidAPIUsage(_("Picture is not available (currently in processing)"), status_code=403)
|
|
467
465
|
|
|
468
466
|
if current_app.config.get("PICTURE_PROCESS_DERIVATES_STRATEGY") == "PREPROCESS":
|
|
469
467
|
# if derivates are always generated, not need for other checks
|
|
@@ -501,7 +499,7 @@ def sendThumbnail(pictureId, format):
|
|
|
501
499
|
metadata = checkPictureStatus(fses, pictureId)
|
|
502
500
|
|
|
503
501
|
external_url = getPublicDerivatePictureExternalUrl(pictureId, format, "thumb.jpg")
|
|
504
|
-
if external_url and metadata["status"] == "ready":
|
|
502
|
+
if external_url and metadata["status"] == "ready" and metadata["visibility"] in ("anyone", None):
|
|
505
503
|
return redirect(external_url)
|
|
506
504
|
|
|
507
505
|
try:
|
geovisio/utils/sequences.py
CHANGED
|
@@ -1,20 +1,17 @@
|
|
|
1
|
-
from operator import ne
|
|
2
|
-
from click import Option
|
|
3
|
-
from numpy import sort
|
|
4
1
|
import psycopg
|
|
5
|
-
from flask import current_app,
|
|
2
|
+
from flask import current_app, url_for
|
|
6
3
|
from flask_babel import gettext as _
|
|
7
4
|
from psycopg.types.json import Jsonb
|
|
8
5
|
from psycopg.sql import SQL, Composable
|
|
9
6
|
from psycopg.rows import dict_row
|
|
10
7
|
from dataclasses import dataclass, field
|
|
11
|
-
from typing import Any, List, Dict, Optional
|
|
8
|
+
from typing import Any, List, Dict, Optional, Tuple
|
|
12
9
|
import datetime
|
|
13
10
|
from uuid import UUID
|
|
14
11
|
from enum import Enum
|
|
15
12
|
from geovisio.utils import db
|
|
16
|
-
from geovisio.utils.auth import Account
|
|
17
|
-
from geovisio.utils.fields import FieldMapping, SortBy, SQLDirection, BBox, Bounds
|
|
13
|
+
from geovisio.utils.auth import Account, get_current_account
|
|
14
|
+
from geovisio.utils.fields import FieldMapping, SortBy, SQLDirection, BBox, Bounds
|
|
18
15
|
from geopic_tag_reader import reader
|
|
19
16
|
from pathlib import PurePath
|
|
20
17
|
from geovisio import errors, utils
|
|
@@ -22,11 +19,22 @@ import logging
|
|
|
22
19
|
import sentry_sdk
|
|
23
20
|
|
|
24
21
|
|
|
25
|
-
def createSequence(
|
|
22
|
+
def createSequence(
|
|
23
|
+
metadata, accountId, user_agent: Optional[str] = None, upload_set_id: Optional[UUID] = None, visibility: Optional[str] = None
|
|
24
|
+
):
|
|
26
25
|
with db.execute(
|
|
27
26
|
current_app,
|
|
28
|
-
"INSERT INTO sequences(account_id, metadata, user_agent
|
|
29
|
-
|
|
27
|
+
"""INSERT INTO sequences(account_id, metadata, user_agent, upload_set_id, visibility)
|
|
28
|
+
VALUES(%(account_id)s, %(metadata)s, %(user_agent)s, %(upload_set_id)s,
|
|
29
|
+
COALESCE(%(visibility)s, (SELECT default_visibility FROM accounts WHERE id = %(account_id)s), (SELECT default_visibility FROM configurations LIMIT 1)))
|
|
30
|
+
RETURNING id""",
|
|
31
|
+
{
|
|
32
|
+
"account_id": accountId,
|
|
33
|
+
"metadata": Jsonb(metadata),
|
|
34
|
+
"user_agent": user_agent,
|
|
35
|
+
"upload_set_id": upload_set_id,
|
|
36
|
+
"visibility": visibility,
|
|
37
|
+
},
|
|
30
38
|
) as r:
|
|
31
39
|
seqId = r.fetchone()
|
|
32
40
|
if seqId is None:
|
|
@@ -41,7 +49,7 @@ STAC_FIELD_MAPPINGS = {
|
|
|
41
49
|
FieldMapping(sql_column=SQL("inserted_at"), stac="created"),
|
|
42
50
|
FieldMapping(sql_column=SQL("updated_at"), stac="updated"),
|
|
43
51
|
FieldMapping(sql_column=SQL("computed_capture_date"), stac="datetime"),
|
|
44
|
-
FieldMapping(sql_column=SQL("
|
|
52
|
+
FieldMapping(sql_column=SQL("visibility"), stac="visibility"),
|
|
45
53
|
FieldMapping(sql_column=SQL("id"), stac="id"),
|
|
46
54
|
]
|
|
47
55
|
}
|
|
@@ -72,77 +80,90 @@ class CollectionsRequest:
|
|
|
72
80
|
pagination_filter: Optional[SQL] = None
|
|
73
81
|
limit: int = 100
|
|
74
82
|
userOwnsAllCollections: bool = False # bool to represent that the user's asking for the collections is the owner of them
|
|
83
|
+
show_deleted: bool = False
|
|
84
|
+
"""Do we want to return deleted collections that respect the other filters in a separate field"""
|
|
75
85
|
|
|
76
86
|
def filters(self):
|
|
77
87
|
return [f for f in (self.user_filter, self.pagination_filter) if f is not None]
|
|
78
88
|
|
|
89
|
+
def to_sql_filters_and_params_without_permissions(self) -> Tuple[List[Composable], dict]:
|
|
90
|
+
"""Transform the request to a list of SQL filters and a dict of parameters
|
|
91
|
+
Note: the filters do not contain any filter on permission/status, they need to be added afterward"""
|
|
92
|
+
seq_filter: List[Composable] = []
|
|
93
|
+
seq_params: dict = {}
|
|
94
|
+
|
|
95
|
+
# Sort-by parameter
|
|
96
|
+
seq_filter.append(SQL("{field} IS NOT NULL").format(field=self.sort_by.fields[0].field.sql_filter))
|
|
97
|
+
seq_filter.extend(self.filters())
|
|
98
|
+
|
|
99
|
+
if self.user_id is not None:
|
|
100
|
+
seq_filter.append(SQL("s.account_id = %(account)s"))
|
|
101
|
+
seq_params["account"] = self.user_id
|
|
102
|
+
|
|
103
|
+
# Datetime
|
|
104
|
+
if self.min_dt is not None:
|
|
105
|
+
seq_filter.append(SQL("s.computed_capture_date >= %(cmindate)s::date"))
|
|
106
|
+
seq_params["cmindate"] = self.min_dt
|
|
107
|
+
if self.max_dt is not None:
|
|
108
|
+
seq_filter.append(SQL("s.computed_capture_date <= %(cmaxdate)s::date"))
|
|
109
|
+
seq_params["cmaxdate"] = self.max_dt
|
|
110
|
+
|
|
111
|
+
if self.bbox is not None:
|
|
112
|
+
seq_filter.append(SQL("ST_Intersects(s.geom, ST_MakeEnvelope(%(minx)s, %(miny)s, %(maxx)s, %(maxy)s, 4326))"))
|
|
113
|
+
seq_params["minx"] = self.bbox.minx
|
|
114
|
+
seq_params["miny"] = self.bbox.miny
|
|
115
|
+
seq_params["maxx"] = self.bbox.maxx
|
|
116
|
+
seq_params["maxy"] = self.bbox.maxy
|
|
117
|
+
|
|
118
|
+
# Created after/before
|
|
119
|
+
if self.created_after is not None:
|
|
120
|
+
seq_filter.append(SQL("s.inserted_at > %(created_after)s::timestamp with time zone"))
|
|
121
|
+
seq_params["created_after"] = self.created_after
|
|
122
|
+
|
|
123
|
+
if self.created_before:
|
|
124
|
+
seq_filter.append(SQL("s.inserted_at < %(created_before)s::timestamp with time zone"))
|
|
125
|
+
seq_params["created_before"] = self.created_before
|
|
126
|
+
|
|
127
|
+
return seq_filter, seq_params
|
|
128
|
+
|
|
79
129
|
|
|
80
130
|
def get_collections(request: CollectionsRequest) -> Collections:
|
|
81
131
|
# Check basic parameters
|
|
82
|
-
seq_filter
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
# for s in request.sort_by.fields:
|
|
88
|
-
# sqlConditionsSequences.append(SQL("{field} IS NOT NULL").format(field=s.field.sql_filter))
|
|
89
|
-
seq_filter.append(SQL("{field} IS NOT NULL").format(field=request.sort_by.fields[0].field.sql_filter))
|
|
90
|
-
seq_filter.extend(request.filters())
|
|
91
|
-
|
|
92
|
-
if request.user_id is not None:
|
|
93
|
-
seq_filter.append(SQL("s.account_id = %(account)s"))
|
|
94
|
-
seq_params["account"] = request.user_id
|
|
95
|
-
|
|
96
|
-
user_filter_str = request.user_filter.as_string(None) if request.user_filter is not None else None
|
|
97
|
-
if user_filter_str is None or "status" not in user_filter_str:
|
|
98
|
-
# if the filter does not contains any `status` condition, we want to show only 'ready' collection to the general users, and non deleted one for the owner
|
|
132
|
+
seq_filter, seq_params = request.to_sql_filters_and_params_without_permissions()
|
|
133
|
+
|
|
134
|
+
# Only the owner of an account can view sequences not 'ready' (and we don't want to show the deleted even to the owner)
|
|
135
|
+
account_to_query = get_current_account()
|
|
136
|
+
if not request.show_deleted:
|
|
99
137
|
if not request.userOwnsAllCollections:
|
|
100
138
|
seq_filter.append(SQL("status = 'ready'"))
|
|
101
139
|
else:
|
|
102
140
|
seq_filter.append(SQL("status != 'deleted'"))
|
|
103
141
|
else:
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
142
|
+
seq_filter.append(SQL("status IN ('deleted', 'ready')"))
|
|
143
|
+
|
|
144
|
+
seq_params["account_to_query"] = account_to_query.id if account_to_query is not None else None
|
|
107
145
|
|
|
108
|
-
|
|
146
|
+
if account_to_query is not None and account_to_query.can_see_all():
|
|
147
|
+
# if the account querying is an admin, we also do not filter, and we consider that the admin can see all sequences
|
|
148
|
+
visible_by_user = SQL("TRUE")
|
|
149
|
+
elif request.show_deleted:
|
|
150
|
+
# if asked to show deletion, we do not filter using the rights, but we'll output only the id of the non visible sequence
|
|
151
|
+
visible_by_user = SQL("is_sequence_visible_by_user(s, %(account_to_query)s)")
|
|
152
|
+
else:
|
|
153
|
+
visible_by_user = SQL("is_sequence_visible_by_user(s, %(account_to_query)s)")
|
|
154
|
+
seq_filter.append(SQL("is_sequence_visible_by_user(s, %(account_to_query)s)"))
|
|
155
|
+
|
|
156
|
+
status_field = SQL("s.status AS status")
|
|
109
157
|
if request.userOwnsAllCollections:
|
|
110
|
-
# only
|
|
111
|
-
|
|
158
|
+
# only show detailed visibility if the user querying owns all the collections (so on /api/users/me/collection)
|
|
159
|
+
visibility_field = SQL("s.visibility")
|
|
112
160
|
else:
|
|
113
|
-
|
|
114
|
-
status_field = SQL("CASE WHEN s.status IN ('hidden', 'deleted') THEN 'deleted' ELSE s.status END AS status")
|
|
115
|
-
|
|
116
|
-
# Datetime
|
|
117
|
-
if request.min_dt is not None:
|
|
118
|
-
seq_filter.append(SQL("s.computed_capture_date >= %(cmindate)s::date"))
|
|
119
|
-
seq_params["cmindate"] = request.min_dt
|
|
120
|
-
if request.max_dt is not None:
|
|
121
|
-
seq_filter.append(SQL("s.computed_capture_date <= %(cmaxdate)s::date"))
|
|
122
|
-
seq_params["cmaxdate"] = request.max_dt
|
|
123
|
-
|
|
124
|
-
if request.bbox is not None:
|
|
125
|
-
seq_filter.append(SQL("ST_Intersects(s.geom, ST_MakeEnvelope(%(minx)s, %(miny)s, %(maxx)s, %(maxy)s, 4326))"))
|
|
126
|
-
seq_params["minx"] = request.bbox.minx
|
|
127
|
-
seq_params["miny"] = request.bbox.miny
|
|
128
|
-
seq_params["maxx"] = request.bbox.maxx
|
|
129
|
-
seq_params["maxy"] = request.bbox.maxy
|
|
130
|
-
|
|
131
|
-
# Created after/before
|
|
132
|
-
if request.created_after is not None:
|
|
133
|
-
seq_filter.append(SQL("s.inserted_at > %(created_after)s::timestamp with time zone"))
|
|
134
|
-
seq_params["created_after"] = request.created_after
|
|
135
|
-
|
|
136
|
-
if request.created_before:
|
|
137
|
-
seq_filter.append(SQL("s.inserted_at < %(created_before)s::timestamp with time zone"))
|
|
138
|
-
seq_params["created_before"] = request.created_before
|
|
161
|
+
visibility_field = SQL("NULL AS visibility")
|
|
139
162
|
|
|
140
163
|
with utils.db.cursor(current_app, row_factory=dict_row) as cursor:
|
|
141
164
|
sqlSequencesRaw = SQL(
|
|
142
|
-
"""
|
|
143
|
-
SELECT
|
|
165
|
+
"""SELECT
|
|
144
166
|
s.id,
|
|
145
|
-
s.status,
|
|
146
167
|
s.metadata->>'title' AS name,
|
|
147
168
|
s.inserted_at AS created,
|
|
148
169
|
s.updated_at AS updated,
|
|
@@ -159,6 +180,8 @@ def get_collections(request: CollectionsRequest) -> Collections:
|
|
|
159
180
|
s.nb_pictures AS nbpic,
|
|
160
181
|
s.upload_set_id,
|
|
161
182
|
{status},
|
|
183
|
+
{visibility},
|
|
184
|
+
{visible_by_user} as is_sequence_visible_by_user,
|
|
162
185
|
s.computed_capture_date AS datetime,
|
|
163
186
|
s.user_agent,
|
|
164
187
|
ROUND(ST_Length(s.geom::geography)) / 1000 AS length_km,
|
|
@@ -177,14 +200,15 @@ def get_collections(request: CollectionsRequest) -> Collections:
|
|
|
177
200
|
) seq_sem ON seq_sem.sequence_id = s.id
|
|
178
201
|
WHERE {filter}
|
|
179
202
|
ORDER BY {order1}
|
|
180
|
-
LIMIT {limit}
|
|
181
|
-
"""
|
|
203
|
+
LIMIT {limit}"""
|
|
182
204
|
)
|
|
183
205
|
sqlSequences = sqlSequencesRaw.format(
|
|
184
206
|
filter=SQL(" AND ").join(seq_filter),
|
|
185
207
|
order1=request.sort_by.as_sql(),
|
|
186
208
|
limit=request.limit,
|
|
187
209
|
status=status_field,
|
|
210
|
+
visibility=visibility_field,
|
|
211
|
+
visible_by_user=visible_by_user,
|
|
188
212
|
)
|
|
189
213
|
|
|
190
214
|
# Different request if we want the last n sequences
|
|
@@ -201,13 +225,13 @@ def get_collections(request: CollectionsRequest) -> Collections:
|
|
|
201
225
|
order1=request.sort_by.revert(),
|
|
202
226
|
limit=request.limit,
|
|
203
227
|
status=status_field,
|
|
228
|
+
visibility=visibility_field,
|
|
229
|
+
visible_by_user=visible_by_user,
|
|
204
230
|
)
|
|
205
231
|
sqlSequences = SQL(
|
|
206
|
-
"""
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
ORDER BY {order2}
|
|
210
|
-
"""
|
|
232
|
+
"""SELECT *
|
|
233
|
+
FROM ({base_query}) s
|
|
234
|
+
ORDER BY {order2}"""
|
|
211
235
|
).format(
|
|
212
236
|
order2=request.sort_by.as_sql(),
|
|
213
237
|
base_query=base_query,
|
|
@@ -254,6 +278,7 @@ def get_dataset_bounds(
|
|
|
254
278
|
sortBy: SortBy,
|
|
255
279
|
additional_filters: Optional[SQL] = None,
|
|
256
280
|
additional_filters_params: Optional[Dict[str, Any]] = None,
|
|
281
|
+
account_to_query_id: Optional[UUID] = None,
|
|
257
282
|
) -> Optional[Bounds]:
|
|
258
283
|
"""Computes the dataset bounds from the sortBy field (using lexicographic order)
|
|
259
284
|
|
|
@@ -278,7 +303,7 @@ SELECT * FROM min_bounds, max_bounds;
|
|
|
278
303
|
reverse_fields=sortBy.revert_non_aliased_sql(),
|
|
279
304
|
filters=additional_filters or SQL("TRUE"),
|
|
280
305
|
),
|
|
281
|
-
params=additional_filters_params or {},
|
|
306
|
+
params=(additional_filters_params or {}) | {"account_to_query": account_to_query_id},
|
|
282
307
|
).fetchone()
|
|
283
308
|
if not sql_bounds:
|
|
284
309
|
return None
|
|
@@ -323,6 +348,7 @@ def get_pagination_links(
|
|
|
323
348
|
datasetBounds: Bounds,
|
|
324
349
|
dataBounds: Optional[Bounds],
|
|
325
350
|
additional_filters: Optional[str],
|
|
351
|
+
showDeleted: Optional[bool] = None,
|
|
326
352
|
) -> List:
|
|
327
353
|
"""Computes STAC links to handle pagination"""
|
|
328
354
|
|
|
@@ -337,7 +363,7 @@ def get_pagination_links(
|
|
|
337
363
|
{
|
|
338
364
|
"rel": "first",
|
|
339
365
|
"type": "application/json",
|
|
340
|
-
"href": url_for(route, _external=True, **routeArgs, filter=additional_filters, sortby=sortby),
|
|
366
|
+
"href": url_for(route, _external=True, **routeArgs, filter=additional_filters, sortby=sortby, show_deleted=showDeleted),
|
|
341
367
|
}
|
|
342
368
|
)
|
|
343
369
|
|
|
@@ -352,6 +378,7 @@ def get_pagination_links(
|
|
|
352
378
|
_external=True,
|
|
353
379
|
**routeArgs,
|
|
354
380
|
sortby=sortby,
|
|
381
|
+
show_deleted=showDeleted,
|
|
355
382
|
filter=additional_filters,
|
|
356
383
|
page=page_filter,
|
|
357
384
|
),
|
|
@@ -370,6 +397,7 @@ def get_pagination_links(
|
|
|
370
397
|
_external=True,
|
|
371
398
|
**routeArgs,
|
|
372
399
|
sortby=sortby,
|
|
400
|
+
show_deleted=showDeleted,
|
|
373
401
|
filter=additional_filters,
|
|
374
402
|
page=next_filter,
|
|
375
403
|
),
|
|
@@ -388,6 +416,7 @@ def get_pagination_links(
|
|
|
388
416
|
_external=True,
|
|
389
417
|
**routeArgs,
|
|
390
418
|
sortby=sortby,
|
|
419
|
+
show_deleted=showDeleted,
|
|
391
420
|
filter=additional_filters,
|
|
392
421
|
page=last_filter,
|
|
393
422
|
),
|
|
@@ -635,7 +664,7 @@ GROUP BY sp.seq_id
|
|
|
635
664
|
)
|
|
636
665
|
UPDATE sequences
|
|
637
666
|
SET
|
|
638
|
-
status =
|
|
667
|
+
status = 'ready'::sequence_status,
|
|
639
668
|
geom = compute_sequence_geom(id),
|
|
640
669
|
bbox = compute_sequence_bbox(id),
|
|
641
670
|
computed_type = CASE WHEN array_length(types, 1) = 1 THEN types[1] ELSE NULL END,
|
|
@@ -652,7 +681,7 @@ WHERE id = %(seq)s
|
|
|
652
681
|
logger.info(f"Sequence {seqId} is ready")
|
|
653
682
|
|
|
654
683
|
|
|
655
|
-
def update_pictures_grid() ->
|
|
684
|
+
def update_pictures_grid() -> bool:
|
|
656
685
|
"""Refreshes the pictures_grid materialized view for an up-to-date view of pictures availability on map.
|
|
657
686
|
|
|
658
687
|
Parameters
|
|
@@ -703,7 +732,7 @@ def delete_collection(collectionId: UUID, account: Optional[Account]) -> int:
|
|
|
703
732
|
raise errors.InvalidAPIUsage(_("Collection %(c)s wasn't found in database", c=collectionId), status_code=404)
|
|
704
733
|
|
|
705
734
|
# Account associated to sequence doesn't match current user
|
|
706
|
-
if account is not None and account.
|
|
735
|
+
if account is not None and not account.can_edit_collection(str(sequence[1])):
|
|
707
736
|
raise errors.InvalidAPIUsage("You're not authorized to edit this sequence", status_code=403)
|
|
708
737
|
|
|
709
738
|
logging.info(f"Asking for deletion of sequence {collectionId} and all its pictures")
|
geovisio/utils/upload_set.py
CHANGED
|
@@ -18,6 +18,7 @@ from flask import current_app
|
|
|
18
18
|
from flask_babel import gettext as _
|
|
19
19
|
from geopic_tag_reader import sequence as geopic_sequence, reader
|
|
20
20
|
from geovisio.utils.tags import SemanticTag
|
|
21
|
+
from geovisio.web.params import Visibility
|
|
21
22
|
|
|
22
23
|
from geovisio.utils.loggers import getLoggerWithExtra
|
|
23
24
|
|
|
@@ -90,6 +91,12 @@ class UploadSet(BaseModel):
|
|
|
90
91
|
"""Semantic tags associated to the upload_set"""
|
|
91
92
|
relative_heading: Optional[int] = None
|
|
92
93
|
"""The relative heading (in degrees), offset based on movement path (0° = looking forward, -90° = looking left, 90° = looking right). For single picture upload_sets, 0° is heading north). Is applied to all associated collections if set."""
|
|
94
|
+
visibility: Optional[Visibility] = None
|
|
95
|
+
"""Visibility of the upload set. Can be set to:
|
|
96
|
+
* `anyone`: the upload is visible to anyone
|
|
97
|
+
* `owner-only`: the upload is visible to the owner and administrator only
|
|
98
|
+
* `logged-only`: the upload is visible to logged users only
|
|
99
|
+
"""
|
|
93
100
|
|
|
94
101
|
@computed_field
|
|
95
102
|
@property
|
|
@@ -226,7 +233,7 @@ def get_simple_upload_set(id: UUID) -> Optional[UploadSet]:
|
|
|
226
233
|
return u
|
|
227
234
|
|
|
228
235
|
|
|
229
|
-
def get_upload_set(id: UUID) -> Optional[UploadSet]:
|
|
236
|
+
def get_upload_set(id: UUID, account_to_query: Optional[UUID] = None) -> Optional[UploadSet]:
|
|
230
237
|
"""Get the UploadSet corresponding to the ID"""
|
|
231
238
|
db_upload_set = db.fetchone(
|
|
232
239
|
current_app,
|
|
@@ -241,7 +248,7 @@ def get_upload_set(id: UUID) -> Optional[UploadSet]:
|
|
|
241
248
|
p.upload_set_id
|
|
242
249
|
FROM pictures p
|
|
243
250
|
LEFT JOIN job_history ON p.id = job_history.picture_id
|
|
244
|
-
WHERE p.upload_set_id = %(id)s
|
|
251
|
+
WHERE p.upload_set_id = %(id)s AND is_picture_visible_by_user(p, %(account_to_query)s)
|
|
245
252
|
GROUP BY p.id
|
|
246
253
|
),
|
|
247
254
|
picture_statuses AS (
|
|
@@ -266,7 +273,7 @@ associated_collections AS (
|
|
|
266
273
|
FROM picture_statuses ps
|
|
267
274
|
JOIN sequences_pictures sp ON sp.pic_id = ps.picture_id
|
|
268
275
|
JOIN sequences s ON s.id = sp.seq_id
|
|
269
|
-
WHERE ps.upload_set_id = %(id)s AND s.status != 'deleted'
|
|
276
|
+
WHERE ps.upload_set_id = %(id)s AND s.status != 'deleted' AND is_sequence_visible_by_user(s, %(account_to_query)s)
|
|
270
277
|
GROUP BY ps.upload_set_id,
|
|
271
278
|
s.id
|
|
272
279
|
),
|
|
@@ -340,9 +347,9 @@ SELECT u.*,
|
|
|
340
347
|
FROM upload_sets u
|
|
341
348
|
LEFT JOIN upload_set_statuses us on us.upload_set_id = u.id
|
|
342
349
|
LEFT JOIN semantics s on s.upload_set_id = u.id
|
|
343
|
-
WHERE u.id = %(id)s"""
|
|
350
|
+
WHERE u.id = %(id)s AND is_upload_set_visible_by_user(u, %(account_to_query)s)"""
|
|
344
351
|
),
|
|
345
|
-
{"id": id},
|
|
352
|
+
{"id": id, "account_to_query": account_to_query},
|
|
346
353
|
row_factory=class_row(UploadSet),
|
|
347
354
|
)
|
|
348
355
|
|
|
@@ -373,7 +380,9 @@ def _parse_filter(filter: Optional[str]) -> SQL:
|
|
|
373
380
|
return cql2.parse_cql2_filter(filter, FIELD_TO_SQL_FILTER)
|
|
374
381
|
|
|
375
382
|
|
|
376
|
-
def list_upload_sets(
|
|
383
|
+
def list_upload_sets(
|
|
384
|
+
account_id: UUID, limit: int = 100, filter: Optional[str] = None, account_to_query: Optional[UUID] = None
|
|
385
|
+
) -> UploadSets:
|
|
377
386
|
filter_sql = _parse_filter(filter)
|
|
378
387
|
l = db.fetchall(
|
|
379
388
|
current_app,
|
|
@@ -398,12 +407,12 @@ def list_upload_sets(account_id: UUID, limit: int = 100, filter: Optional[str] =
|
|
|
398
407
|
WHERE p.upload_set_id = u.id
|
|
399
408
|
) AS nb_items
|
|
400
409
|
FROM upload_sets u
|
|
401
|
-
WHERE account_id = %(account_id)s AND {filter}
|
|
410
|
+
WHERE account_id = %(account_id)s AND is_upload_set_visible_by_user(u, %(account_to_query)s) AND {filter}
|
|
402
411
|
ORDER BY created_at ASC
|
|
403
412
|
LIMIT %(limit)s
|
|
404
413
|
"""
|
|
405
414
|
).format(filter=filter_sql),
|
|
406
|
-
{"account_id": account_id, "limit": limit},
|
|
415
|
+
{"account_id": account_id, "limit": limit, "account_to_query": account_to_query},
|
|
407
416
|
row_factory=class_row(UploadSet),
|
|
408
417
|
)
|
|
409
418
|
|
|
@@ -589,8 +598,8 @@ WHERE d.picture_id = files.picture_id"""
|
|
|
589
598
|
new_title = f"{db_upload_set.title}{f'-{i}' if number_title else ''}"
|
|
590
599
|
seq_id = cursor.execute(
|
|
591
600
|
SQL(
|
|
592
|
-
"""INSERT INTO sequences(account_id, metadata, user_agent, upload_set_id)
|
|
593
|
-
VALUES (%(account_id)s, %(metadata)s, %(user_agent)s, %(upload_set_id)s)
|
|
601
|
+
"""INSERT INTO sequences(account_id, metadata, user_agent, upload_set_id, visibility)
|
|
602
|
+
VALUES (%(account_id)s, %(metadata)s, %(user_agent)s, %(upload_set_id)s, %(visibility)s)
|
|
594
603
|
RETURNING id"""
|
|
595
604
|
),
|
|
596
605
|
{
|
|
@@ -598,6 +607,7 @@ WHERE d.picture_id = files.picture_id"""
|
|
|
598
607
|
"metadata": Jsonb({"title": new_title}),
|
|
599
608
|
"user_agent": db_upload_set.user_agent,
|
|
600
609
|
"upload_set_id": db_upload_set.id,
|
|
610
|
+
"visibility": db_upload_set.visibility,
|
|
601
611
|
},
|
|
602
612
|
).fetchone()
|
|
603
613
|
seq_id = seq_id["id"]
|
geovisio/utils/users.py
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from geovisio.utils.auth import Account
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def delete_user_data(conn, account: Account):
|
|
6
|
+
"""Delete all the pictures of a user
|
|
7
|
+
|
|
8
|
+
Note that the database changes will be done synchronously but the picture deletion will be an asynchronous task,
|
|
9
|
+
so some background workers need to be run in order for the pictures deletion to be effective.
|
|
10
|
+
"""
|
|
11
|
+
with conn.transaction(), conn.cursor() as cursor:
|
|
12
|
+
logging.info(f"Deleting pictures of account {account.name} ({account.id})")
|
|
13
|
+
# Note: deleting a picture's row add a new `delete` async task to the queue, to delete the associated files
|
|
14
|
+
nb_deleted_pics = cursor.execute("DELETE FROM pictures WHERE account_id = %s", [account.id]).rowcount
|
|
15
|
+
cursor.execute("DELETE FROM upload_sets WHERE account_id = %s", [account.id])
|
|
16
|
+
cursor.execute("UPDATE sequences SET status = 'deleted' WHERE account_id = %s", [account.id])
|
|
17
|
+
logging.info(f"Deleted {nb_deleted_pics} pictures from account {account.name} ({account.id})")
|
|
18
|
+
return nb_deleted_pics
|