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.
Files changed (62) hide show
  1. geovisio/__init__.py +36 -7
  2. geovisio/admin_cli/cleanup.py +2 -2
  3. geovisio/admin_cli/db.py +1 -4
  4. geovisio/config_app.py +40 -1
  5. geovisio/db_migrations.py +24 -3
  6. geovisio/templates/main.html +13 -13
  7. geovisio/templates/viewer.html +3 -3
  8. geovisio/translations/de/LC_MESSAGES/messages.mo +0 -0
  9. geovisio/translations/de/LC_MESSAGES/messages.po +804 -0
  10. geovisio/translations/el/LC_MESSAGES/messages.mo +0 -0
  11. geovisio/translations/el/LC_MESSAGES/messages.po +685 -0
  12. geovisio/translations/en/LC_MESSAGES/messages.mo +0 -0
  13. geovisio/translations/en/LC_MESSAGES/messages.po +738 -0
  14. geovisio/translations/es/LC_MESSAGES/messages.mo +0 -0
  15. geovisio/translations/es/LC_MESSAGES/messages.po +778 -0
  16. geovisio/translations/fi/LC_MESSAGES/messages.mo +0 -0
  17. geovisio/translations/fi/LC_MESSAGES/messages.po +589 -0
  18. geovisio/translations/fr/LC_MESSAGES/messages.mo +0 -0
  19. geovisio/translations/fr/LC_MESSAGES/messages.po +814 -0
  20. geovisio/translations/hu/LC_MESSAGES/messages.mo +0 -0
  21. geovisio/translations/hu/LC_MESSAGES/messages.po +773 -0
  22. geovisio/translations/ko/LC_MESSAGES/messages.mo +0 -0
  23. geovisio/translations/ko/LC_MESSAGES/messages.po +685 -0
  24. geovisio/translations/messages.pot +694 -0
  25. geovisio/translations/nl/LC_MESSAGES/messages.mo +0 -0
  26. geovisio/translations/nl/LC_MESSAGES/messages.po +602 -0
  27. geovisio/utils/__init__.py +1 -1
  28. geovisio/utils/auth.py +50 -11
  29. geovisio/utils/db.py +65 -0
  30. geovisio/utils/excluded_areas.py +83 -0
  31. geovisio/utils/extent.py +30 -0
  32. geovisio/utils/fields.py +1 -1
  33. geovisio/utils/filesystems.py +0 -1
  34. geovisio/utils/link.py +14 -0
  35. geovisio/utils/params.py +20 -0
  36. geovisio/utils/pictures.py +110 -88
  37. geovisio/utils/reports.py +171 -0
  38. geovisio/utils/sequences.py +262 -126
  39. geovisio/utils/tokens.py +37 -42
  40. geovisio/utils/upload_set.py +642 -0
  41. geovisio/web/auth.py +37 -37
  42. geovisio/web/collections.py +304 -304
  43. geovisio/web/configuration.py +14 -0
  44. geovisio/web/docs.py +276 -15
  45. geovisio/web/excluded_areas.py +377 -0
  46. geovisio/web/items.py +169 -112
  47. geovisio/web/map.py +104 -36
  48. geovisio/web/params.py +69 -26
  49. geovisio/web/pictures.py +14 -31
  50. geovisio/web/reports.py +399 -0
  51. geovisio/web/rss.py +13 -7
  52. geovisio/web/stac.py +129 -134
  53. geovisio/web/tokens.py +98 -109
  54. geovisio/web/upload_set.py +771 -0
  55. geovisio/web/users.py +100 -73
  56. geovisio/web/utils.py +28 -9
  57. geovisio/workers/runner_pictures.py +241 -207
  58. {geovisio-2.6.0.dist-info → geovisio-2.7.1.dist-info}/METADATA +17 -14
  59. geovisio-2.7.1.dist-info/RECORD +70 -0
  60. {geovisio-2.6.0.dist-info → geovisio-2.7.1.dist-info}/WHEEL +1 -1
  61. geovisio-2.6.0.dist-info/RECORD +0 -41
  62. {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
- @dataclass
148
- class Account(object):
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 = f"No account with this oauth id is know, you should login first"
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 = f"You should login to request this API"
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
- session_account = Account(**session[ACCOUNT_KEY])
300
+ a = session[ACCOUNT_KEY]
301
+ session_account = Account(**a)
263
302
 
264
- sentry_sdk.set_user(session_account.__dict__)
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.__dict__)
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
@@ -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
@@ -1,6 +1,6 @@
1
1
  from enum import Enum
2
2
  from dataclasses import dataclass, field
3
- from typing import Any, List, Dict, Optional, Generic, TypeVar, Protocol
3
+ from typing import Any, List, Generic, TypeVar, Protocol
4
4
  from psycopg import sql
5
5
 
6
6
 
@@ -1,6 +1,5 @@
1
1
  from dataclasses import dataclass
2
2
  import fs.base
3
- import logging
4
3
  from fs import open_fs
5
4
  from fs.errors import ResourceNotFound
6
5
  from fs_s3fs import S3FS
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))
@@ -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}