geovisio 2.6.0__py3-none-any.whl → 2.7.1__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 +36 -7
- geovisio/admin_cli/cleanup.py +2 -2
- geovisio/admin_cli/db.py +1 -4
- geovisio/config_app.py +40 -1
- geovisio/db_migrations.py +24 -3
- geovisio/templates/main.html +13 -13
- geovisio/templates/viewer.html +3 -3
- geovisio/translations/de/LC_MESSAGES/messages.mo +0 -0
- geovisio/translations/de/LC_MESSAGES/messages.po +804 -0
- 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 +738 -0
- geovisio/translations/es/LC_MESSAGES/messages.mo +0 -0
- geovisio/translations/es/LC_MESSAGES/messages.po +778 -0
- geovisio/translations/fi/LC_MESSAGES/messages.mo +0 -0
- geovisio/translations/fi/LC_MESSAGES/messages.po +589 -0
- geovisio/translations/fr/LC_MESSAGES/messages.mo +0 -0
- geovisio/translations/fr/LC_MESSAGES/messages.po +814 -0
- geovisio/translations/hu/LC_MESSAGES/messages.mo +0 -0
- geovisio/translations/hu/LC_MESSAGES/messages.po +773 -0
- geovisio/translations/ko/LC_MESSAGES/messages.mo +0 -0
- geovisio/translations/ko/LC_MESSAGES/messages.po +685 -0
- geovisio/translations/messages.pot +694 -0
- geovisio/translations/nl/LC_MESSAGES/messages.mo +0 -0
- geovisio/translations/nl/LC_MESSAGES/messages.po +602 -0
- geovisio/utils/__init__.py +1 -1
- geovisio/utils/auth.py +50 -11
- geovisio/utils/db.py +65 -0
- geovisio/utils/excluded_areas.py +83 -0
- geovisio/utils/extent.py +30 -0
- geovisio/utils/fields.py +1 -1
- geovisio/utils/filesystems.py +0 -1
- geovisio/utils/link.py +14 -0
- geovisio/utils/params.py +20 -0
- geovisio/utils/pictures.py +110 -88
- geovisio/utils/reports.py +171 -0
- geovisio/utils/sequences.py +262 -126
- geovisio/utils/tokens.py +37 -42
- geovisio/utils/upload_set.py +642 -0
- geovisio/web/auth.py +37 -37
- geovisio/web/collections.py +304 -304
- geovisio/web/configuration.py +14 -0
- geovisio/web/docs.py +276 -15
- geovisio/web/excluded_areas.py +377 -0
- geovisio/web/items.py +169 -112
- geovisio/web/map.py +104 -36
- geovisio/web/params.py +69 -26
- geovisio/web/pictures.py +14 -31
- geovisio/web/reports.py +399 -0
- geovisio/web/rss.py +13 -7
- geovisio/web/stac.py +129 -134
- geovisio/web/tokens.py +98 -109
- geovisio/web/upload_set.py +771 -0
- geovisio/web/users.py +100 -73
- geovisio/web/utils.py +28 -9
- geovisio/workers/runner_pictures.py +241 -207
- {geovisio-2.6.0.dist-info → geovisio-2.7.1.dist-info}/METADATA +17 -14
- geovisio-2.7.1.dist-info/RECORD +70 -0
- {geovisio-2.6.0.dist-info → geovisio-2.7.1.dist-info}/WHEEL +1 -1
- geovisio-2.6.0.dist-info/RECORD +0 -41
- {geovisio-2.6.0.dist-info → geovisio-2.7.1.dist-info}/LICENSE +0 -0
geovisio/utils/auth.py
CHANGED
|
@@ -1,12 +1,17 @@
|
|
|
1
1
|
import flask
|
|
2
2
|
from flask import current_app, url_for, session, redirect, request
|
|
3
|
+
from flask_babel import gettext as _
|
|
3
4
|
from functools import wraps
|
|
4
5
|
from authlib.integrations.flask_client import OAuth
|
|
5
6
|
from dataclasses import dataclass
|
|
6
7
|
from abc import ABC, abstractmethod
|
|
7
8
|
from typing import Any
|
|
8
9
|
from typing import Optional
|
|
10
|
+
from enum import Enum
|
|
11
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
9
12
|
import sentry_sdk
|
|
13
|
+
from psycopg.rows import dict_row
|
|
14
|
+
from geovisio.utils import db
|
|
10
15
|
|
|
11
16
|
|
|
12
17
|
ACCOUNT_KEY = "account" # Key in flask's session with the account's information
|
|
@@ -144,12 +149,45 @@ def make_auth(app):
|
|
|
144
149
|
return oauth
|
|
145
150
|
|
|
146
151
|
|
|
147
|
-
|
|
148
|
-
|
|
152
|
+
class AccountRole(Enum):
|
|
153
|
+
user = "user"
|
|
154
|
+
admin = "admin"
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
class Account(BaseModel):
|
|
149
158
|
id: str
|
|
150
159
|
name: str
|
|
151
|
-
oauth_provider: str
|
|
152
|
-
oauth_id: str
|
|
160
|
+
oauth_provider: Optional[str] = None
|
|
161
|
+
oauth_id: Optional[str] = None
|
|
162
|
+
|
|
163
|
+
model_config = ConfigDict(extra="forbid")
|
|
164
|
+
|
|
165
|
+
def __init__(self, role: Optional[AccountRole] = None, **kwargs) -> None:
|
|
166
|
+
super().__init__(**kwargs)
|
|
167
|
+
self.role = role
|
|
168
|
+
|
|
169
|
+
# Note: this field is excluded since we do not want to persist it in the cookie. It will be fetched from the database if needed
|
|
170
|
+
# and accessed though the `role` property
|
|
171
|
+
role_: Optional[AccountRole] = Field(default=None, exclude=True)
|
|
172
|
+
|
|
173
|
+
def can_check_reports(self):
|
|
174
|
+
"""Is account legitimate to read any report ?"""
|
|
175
|
+
return self.role == AccountRole.admin
|
|
176
|
+
|
|
177
|
+
def can_edit_excluded_areas(self):
|
|
178
|
+
"""Is account legitimate to read and edit excluded areas ?"""
|
|
179
|
+
return self.role == AccountRole.admin
|
|
180
|
+
|
|
181
|
+
@property
|
|
182
|
+
def role(self) -> AccountRole:
|
|
183
|
+
if self.role_ is None:
|
|
184
|
+
role = db.fetchone(current_app, "SELECT role FROM accounts WHERE id = %s", (self.id,), row_factory=dict_row)
|
|
185
|
+
self.role_ = AccountRole(role["role"])
|
|
186
|
+
return self.role_
|
|
187
|
+
|
|
188
|
+
@role.setter
|
|
189
|
+
def role(self, r: AccountRole) -> None:
|
|
190
|
+
self.role_ = r
|
|
153
191
|
|
|
154
192
|
|
|
155
193
|
def login_required():
|
|
@@ -160,7 +198,7 @@ def login_required():
|
|
|
160
198
|
def decorator(*args, **kwargs):
|
|
161
199
|
account = get_current_account()
|
|
162
200
|
if not account:
|
|
163
|
-
return flask.abort(flask.make_response(flask.jsonify(message="Authentication is mandatory"), 401))
|
|
201
|
+
return flask.abort(flask.make_response(flask.jsonify(message=_("Authentication is mandatory")), 401))
|
|
164
202
|
kwargs["account"] = account
|
|
165
203
|
|
|
166
204
|
return f(*args, **kwargs)
|
|
@@ -236,7 +274,7 @@ class UnknowAccountException(Exception):
|
|
|
236
274
|
status_code = 401
|
|
237
275
|
|
|
238
276
|
def __init__(self):
|
|
239
|
-
msg =
|
|
277
|
+
msg = "No account with this oauth id is know, you should login first"
|
|
240
278
|
super().__init__(msg)
|
|
241
279
|
|
|
242
280
|
|
|
@@ -244,7 +282,7 @@ class LoginRequiredException(Exception):
|
|
|
244
282
|
status_code = 401
|
|
245
283
|
|
|
246
284
|
def __init__(self):
|
|
247
|
-
msg =
|
|
285
|
+
msg = "You should login to request this API"
|
|
248
286
|
super().__init__(msg)
|
|
249
287
|
|
|
250
288
|
|
|
@@ -259,9 +297,10 @@ def get_current_account():
|
|
|
259
297
|
Account: the current logged account, None if nobody is logged
|
|
260
298
|
"""
|
|
261
299
|
if ACCOUNT_KEY in session:
|
|
262
|
-
|
|
300
|
+
a = session[ACCOUNT_KEY]
|
|
301
|
+
session_account = Account(**a)
|
|
263
302
|
|
|
264
|
-
sentry_sdk.set_user(session_account.
|
|
303
|
+
sentry_sdk.set_user(session_account.model_dump(exclude_none=True))
|
|
265
304
|
return session_account
|
|
266
305
|
|
|
267
306
|
bearer_token = _get_bearer_token()
|
|
@@ -269,7 +308,7 @@ def get_current_account():
|
|
|
269
308
|
from geovisio.utils import tokens
|
|
270
309
|
|
|
271
310
|
a = tokens.get_account_from_jwt_token(bearer_token)
|
|
272
|
-
sentry_sdk.set_user(a.
|
|
311
|
+
sentry_sdk.set_user(a.model_dump(exclude_none=True))
|
|
273
312
|
return a
|
|
274
313
|
|
|
275
314
|
return None
|
|
@@ -288,5 +327,5 @@ def _get_bearer_token() -> Optional[str]:
|
|
|
288
327
|
if not auth_header.startswith("Bearer "):
|
|
289
328
|
from geovisio.utils.tokens import InvalidTokenException
|
|
290
329
|
|
|
291
|
-
raise InvalidTokenException("Only Bearer token are supported")
|
|
330
|
+
raise InvalidTokenException(_("Only Bearer token are supported"))
|
|
292
331
|
return auth_header.split(" ")[1]
|
geovisio/utils/db.py
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
from psycopg_pool import ConnectionPool
|
|
2
|
+
from contextlib import contextmanager
|
|
3
|
+
from typing import Optional
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def create_db_pool(app):
|
|
7
|
+
"""
|
|
8
|
+
Create Database connection pool
|
|
9
|
+
|
|
10
|
+
Note: all returned connections are autocommit connection. If it's not the wanted behavior, wrap the query in an explicit transaction, or acquire a connection outside of the pool.
|
|
11
|
+
"""
|
|
12
|
+
if hasattr(app, "pool"):
|
|
13
|
+
return
|
|
14
|
+
min_size = int(app.config["DB_MIN_CNX"])
|
|
15
|
+
max_size = int(app.config["DB_MAX_CNX"])
|
|
16
|
+
statement_timeout = app.config["DB_STATEMENT_TIMEOUT"]
|
|
17
|
+
args = {"autocommit": True}
|
|
18
|
+
if statement_timeout > 0:
|
|
19
|
+
args["options"] = f"-c statement_timeout={statement_timeout}"
|
|
20
|
+
app.pool = ConnectionPool(conninfo=app.config["DB_URL"], min_size=min_size, max_size=max_size, open=True, kwargs=args)
|
|
21
|
+
# add also a connection pool without timeout for queries that are known to be long
|
|
22
|
+
# This is useful for example for refreshing the pictures_grid materialized view
|
|
23
|
+
app.long_queries_pool = ConnectionPool(
|
|
24
|
+
conninfo=app.config["DB_URL"], min_size=0, max_size=max_size, open=True, kwargs={"autocommit": True}
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@contextmanager
|
|
29
|
+
def conn(app, timeout: Optional[float] = None):
|
|
30
|
+
"""Get a psycopg connection from the connection pool"""
|
|
31
|
+
with app.pool.connection(timeout=timeout) as conn:
|
|
32
|
+
yield conn
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@contextmanager
|
|
36
|
+
def cursor(app, timeout: Optional[float] = None, **kwargs):
|
|
37
|
+
"""Get a psycopg cursor from the connection pool"""
|
|
38
|
+
with app.pool.connection(timeout=timeout) as conn:
|
|
39
|
+
yield conn.cursor(**kwargs)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@contextmanager
|
|
43
|
+
def execute(app, sql, params=None, timeout: Optional[float] = None, **kwargs):
|
|
44
|
+
"""Simple helpers to simplify simple calls to get a cursor and execute a query on it"""
|
|
45
|
+
with cursor(app, timeout=timeout, **kwargs) as c:
|
|
46
|
+
yield c.execute(sql, params=params)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def fetchone(app, sql, params=None, timeout: Optional[float] = None, **kwargs):
|
|
50
|
+
"""Simple helpers to simplify simple calls to fetchone"""
|
|
51
|
+
with execute(app, sql, params, timeout=timeout, **kwargs) as q:
|
|
52
|
+
return q.fetchone()
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def fetchall(app, sql, params=None, timeout: Optional[float] = None, **kwargs):
|
|
56
|
+
"""Simple helpers to simplify simple calls to fetchall"""
|
|
57
|
+
with execute(app, sql, params, timeout=timeout, **kwargs) as q:
|
|
58
|
+
return q.fetchall()
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@contextmanager
|
|
62
|
+
def long_queries_conn(app, connection_timeout: Optional[float] = None):
|
|
63
|
+
"""Get a psycopg connection for queries that are known to be long from the connection pool"""
|
|
64
|
+
with app.long_queries_pool.connection(timeout=connection_timeout) as conn:
|
|
65
|
+
yield conn
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
from uuid import UUID
|
|
2
|
+
from typing import Optional
|
|
3
|
+
from pydantic import BaseModel, ConfigDict
|
|
4
|
+
from geojson_pydantic import MultiPolygon, FeatureCollection, Feature
|
|
5
|
+
from geovisio.utils import db
|
|
6
|
+
from geovisio.errors import InvalidAPIUsage
|
|
7
|
+
from flask import current_app
|
|
8
|
+
from flask_babel import gettext as _
|
|
9
|
+
from psycopg.sql import SQL, Literal
|
|
10
|
+
from psycopg.rows import class_row
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class ExcludedArea(BaseModel):
|
|
14
|
+
"""An excluded area is a geographical boundary where pictures should not be accepted."""
|
|
15
|
+
|
|
16
|
+
id: UUID
|
|
17
|
+
label: Optional[str] = None
|
|
18
|
+
is_public: bool = False
|
|
19
|
+
account_id: Optional[UUID] = None
|
|
20
|
+
|
|
21
|
+
model_config = ConfigDict()
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
ExcludedAreaFeature = Feature[MultiPolygon, ExcludedArea]
|
|
25
|
+
ExcludedAreaFeatureCollection = FeatureCollection[ExcludedAreaFeature]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def get_excluded_area(id: UUID) -> Optional[ExcludedAreaFeature]:
|
|
29
|
+
"""Get the excluded area corresponding to the ID"""
|
|
30
|
+
return db.fetchone(
|
|
31
|
+
current_app,
|
|
32
|
+
SQL(
|
|
33
|
+
"""SELECT id, label, is_public, account_id, ST_AsGeoJSON(geom) AS geometry
|
|
34
|
+
FROM excluded_area
|
|
35
|
+
WHERE id = %(id)s"""
|
|
36
|
+
),
|
|
37
|
+
{"id": id},
|
|
38
|
+
row_factory=class_row(ExcludedAreaFeature),
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def list_excluded_areas(is_public: Optional[bool] = None, account_id: Optional[UUID] = None) -> ExcludedAreaFeatureCollection:
|
|
43
|
+
where = [Literal(True)]
|
|
44
|
+
if is_public is not None:
|
|
45
|
+
where.append(SQL("is_public IS {}").format(Literal(is_public)))
|
|
46
|
+
if account_id:
|
|
47
|
+
where.append(SQL("account_id = {}").format(Literal(account_id)))
|
|
48
|
+
|
|
49
|
+
areas = db.fetchall(
|
|
50
|
+
current_app,
|
|
51
|
+
SQL(
|
|
52
|
+
"""SELECT
|
|
53
|
+
'Feature' as type,
|
|
54
|
+
json_build_object(
|
|
55
|
+
'id', id,
|
|
56
|
+
'label', label,
|
|
57
|
+
'is_public', is_public,
|
|
58
|
+
'account_id', account_id
|
|
59
|
+
) as properties,
|
|
60
|
+
ST_AsGeoJSON(geom)::json as geometry
|
|
61
|
+
FROM excluded_areas
|
|
62
|
+
WHERE {}"""
|
|
63
|
+
).format(SQL(" AND ").join(where)),
|
|
64
|
+
row_factory=class_row(ExcludedAreaFeature),
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
return ExcludedAreaFeatureCollection(type="FeatureCollection", features=areas)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def delete_excluded_area(areaId: UUID, accountId: Optional[UUID] = None):
|
|
71
|
+
where = [SQL("id = {}").format(Literal(areaId))]
|
|
72
|
+
if accountId is not None:
|
|
73
|
+
where.append(SQL("account_id = {}").format(accountId))
|
|
74
|
+
|
|
75
|
+
with db.execute(
|
|
76
|
+
current_app,
|
|
77
|
+
SQL("DELETE FROM excluded_areas WHERE {}").format(SQL(" AND ").join(where)),
|
|
78
|
+
) as res:
|
|
79
|
+
area_deleted = res.rowcount
|
|
80
|
+
|
|
81
|
+
if not area_deleted:
|
|
82
|
+
raise InvalidAPIUsage(_("Impossible to find excluded area"), status_code=404)
|
|
83
|
+
return "", 204
|
geovisio/utils/extent.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
from typing import List, Optional
|
|
2
|
+
from datetime import datetime
|
|
3
|
+
from pydantic import BaseModel
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class Temporal(BaseModel):
|
|
7
|
+
"""Temporal extent"""
|
|
8
|
+
|
|
9
|
+
interval: List[List[datetime]]
|
|
10
|
+
"""Interval"""
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class Spatial(BaseModel):
|
|
14
|
+
"""Spatial extent"""
|
|
15
|
+
|
|
16
|
+
bbox: List[List[float]]
|
|
17
|
+
"""Bounding box"""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class Extent(BaseModel):
|
|
21
|
+
"""Spatio-temporal extents"""
|
|
22
|
+
|
|
23
|
+
temporal: Optional[Temporal]
|
|
24
|
+
spatial: Optional[Spatial]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class TemporalExtent(BaseModel):
|
|
28
|
+
"""Temporal extents (without spatial extent)"""
|
|
29
|
+
|
|
30
|
+
temporal: Optional[Temporal]
|
geovisio/utils/fields.py
CHANGED
geovisio/utils/filesystems.py
CHANGED
geovisio/utils/link.py
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
from pydantic import BaseModel
|
|
2
|
+
from typing import Optional
|
|
3
|
+
from flask import url_for
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class Link(BaseModel):
|
|
7
|
+
rel: str
|
|
8
|
+
type: str
|
|
9
|
+
title: Optional[str]
|
|
10
|
+
href: str
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def make_link(rel: str, route: str, title: Optional[str] = None, type: str = "application/json", **args):
|
|
14
|
+
return Link(rel=rel, type=type, title=title, href=url_for(route, **args, _external=True))
|
geovisio/utils/params.py
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
from pydantic import ValidationError
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def validation_error(e: ValidationError):
|
|
5
|
+
"""Transform a pydantic error to user friendly error, meant to be used as `payload` of a geovisio.error"""
|
|
6
|
+
|
|
7
|
+
details = []
|
|
8
|
+
for d in e.errors():
|
|
9
|
+
detail = {
|
|
10
|
+
"fields": d["loc"],
|
|
11
|
+
"error": d["msg"],
|
|
12
|
+
}
|
|
13
|
+
if d["input"]:
|
|
14
|
+
detail["input"] = d["input"]
|
|
15
|
+
if "user_agent" in detail["input"]:
|
|
16
|
+
del detail["input"]["user_agent"]
|
|
17
|
+
if len(detail["input"]) == 0:
|
|
18
|
+
del detail["input"]
|
|
19
|
+
details.append(detail)
|
|
20
|
+
return {"details": details}
|