geovisio 2.7.1__py3-none-any.whl → 2.8.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 (66) hide show
  1. geovisio/__init__.py +25 -4
  2. geovisio/admin_cli/__init__.py +3 -1
  3. geovisio/admin_cli/user.py +75 -0
  4. geovisio/config_app.py +86 -4
  5. geovisio/templates/main.html +2 -2
  6. geovisio/templates/viewer.html +3 -3
  7. geovisio/translations/br/LC_MESSAGES/messages.mo +0 -0
  8. geovisio/translations/br/LC_MESSAGES/messages.po +762 -0
  9. geovisio/translations/da/LC_MESSAGES/messages.mo +0 -0
  10. geovisio/translations/da/LC_MESSAGES/messages.po +859 -0
  11. geovisio/translations/de/LC_MESSAGES/messages.mo +0 -0
  12. geovisio/translations/de/LC_MESSAGES/messages.po +106 -1
  13. geovisio/translations/el/LC_MESSAGES/messages.mo +0 -0
  14. geovisio/translations/en/LC_MESSAGES/messages.mo +0 -0
  15. geovisio/translations/en/LC_MESSAGES/messages.po +218 -133
  16. geovisio/translations/eo/LC_MESSAGES/messages.mo +0 -0
  17. geovisio/translations/eo/LC_MESSAGES/messages.po +856 -0
  18. geovisio/translations/es/LC_MESSAGES/messages.mo +0 -0
  19. geovisio/translations/es/LC_MESSAGES/messages.po +4 -3
  20. geovisio/translations/fi/LC_MESSAGES/messages.mo +0 -0
  21. geovisio/translations/fr/LC_MESSAGES/messages.mo +0 -0
  22. geovisio/translations/fr/LC_MESSAGES/messages.po +66 -3
  23. geovisio/translations/hu/LC_MESSAGES/messages.mo +0 -0
  24. geovisio/translations/hu/LC_MESSAGES/messages.po +4 -3
  25. geovisio/translations/it/LC_MESSAGES/messages.mo +0 -0
  26. geovisio/translations/it/LC_MESSAGES/messages.po +884 -0
  27. geovisio/translations/ja/LC_MESSAGES/messages.mo +0 -0
  28. geovisio/translations/ja/LC_MESSAGES/messages.po +807 -0
  29. geovisio/translations/ko/LC_MESSAGES/messages.mo +0 -0
  30. geovisio/translations/messages.pot +191 -122
  31. geovisio/translations/nl/LC_MESSAGES/messages.mo +0 -0
  32. geovisio/translations/pl/LC_MESSAGES/messages.mo +0 -0
  33. geovisio/translations/pl/LC_MESSAGES/messages.po +728 -0
  34. geovisio/translations/zh_Hant/LC_MESSAGES/messages.mo +0 -0
  35. geovisio/translations/zh_Hant/LC_MESSAGES/messages.po +719 -0
  36. geovisio/utils/auth.py +80 -8
  37. geovisio/utils/link.py +3 -2
  38. geovisio/utils/loggers.py +14 -0
  39. geovisio/utils/model_query.py +55 -0
  40. geovisio/utils/params.py +7 -4
  41. geovisio/utils/pictures.py +12 -43
  42. geovisio/utils/semantics.py +120 -0
  43. geovisio/utils/sequences.py +10 -1
  44. geovisio/utils/tokens.py +5 -3
  45. geovisio/utils/upload_set.py +71 -22
  46. geovisio/utils/website.py +53 -0
  47. geovisio/web/annotations.py +17 -0
  48. geovisio/web/auth.py +11 -6
  49. geovisio/web/collections.py +217 -61
  50. geovisio/web/configuration.py +17 -1
  51. geovisio/web/docs.py +67 -67
  52. geovisio/web/items.py +220 -96
  53. geovisio/web/map.py +48 -18
  54. geovisio/web/pages.py +240 -0
  55. geovisio/web/params.py +17 -0
  56. geovisio/web/prepare.py +165 -0
  57. geovisio/web/stac.py +17 -4
  58. geovisio/web/tokens.py +14 -4
  59. geovisio/web/upload_set.py +108 -14
  60. geovisio/web/users.py +203 -44
  61. geovisio/workers/runner_pictures.py +61 -22
  62. {geovisio-2.7.1.dist-info → geovisio-2.8.1.dist-info}/METADATA +8 -6
  63. geovisio-2.8.1.dist-info/RECORD +92 -0
  64. {geovisio-2.7.1.dist-info → geovisio-2.8.1.dist-info}/WHEEL +1 -1
  65. geovisio-2.7.1.dist-info/RECORD +0 -70
  66. {geovisio-2.7.1.dist-info → geovisio-2.8.1.dist-info/licenses}/LICENSE +0 -0
@@ -2,7 +2,7 @@ from copy import deepcopy
2
2
  from dataclasses import dataclass
3
3
 
4
4
  import PIL
5
- from geovisio.utils import auth
5
+ from geovisio.utils import auth, model_query
6
6
  from psycopg.rows import class_row, dict_row
7
7
  from psycopg.sql import SQL
8
8
  from flask import current_app, request, Blueprint, url_for
@@ -68,21 +68,32 @@ class UploadSetCreationParameter(BaseModel):
68
68
  model_config = ConfigDict(use_attribute_docstrings=True)
69
69
 
70
70
 
71
- def create_upload_set(params: UploadSetCreationParameter, accountId: UUID) -> UploadSet:
72
- params_as_dict = params.model_dump(exclude_none=True) | {"account_id": accountId}
71
+ class UploadSetUpdateParameter(BaseModel):
72
+ """Parameters used to update an UploadSet"""
73
+
74
+ sort_method: Optional[geopic_sequence.SortMethod] = None
75
+ """Strategy used for sorting your pictures. Either by filename or EXIF time, in ascending or descending order."""
76
+ split_distance: Optional[int] = None
77
+ """Maximum distance between two pictures to be considered in the same sequence (in meters)."""
78
+ split_time: Optional[timedelta] = None
79
+ """Maximum time interval between two pictures to be considered in the same sequence."""
80
+ duplicate_distance: Optional[float] = None
81
+ """Maximum distance between two pictures to be considered as duplicates (in meters)."""
82
+ duplicate_rotation: Optional[int] = None
83
+ """Maximum angle of rotation for two too-close-pictures to be considered as duplicates (in degrees)."""
73
84
 
74
- fields = [SQL(f) for f in params_as_dict.keys()] # type: ignore (we can ignore psycopg types there as we control those keys since they are the attributes of UploadSetCreationParameter)
75
- values = [SQL(f"%({f})s") for f in params_as_dict.keys()] # type: ignore
76
- for k, v in params_as_dict.items():
77
- if isinstance(v, Dict):
78
- params_as_dict[k] = Jsonb(v) # convert dict to jsonb in database
85
+ model_config = ConfigDict(use_attribute_docstrings=True, extra="forbid")
86
+
87
+
88
+ def create_upload_set(params: UploadSetCreationParameter, accountId: UUID) -> UploadSet:
89
+ db_params = model_query.get_db_params_and_values(params, account_id=accountId)
79
90
 
80
91
  db_upload_set = db.fetchone(
81
92
  current_app,
82
93
  SQL("INSERT INTO upload_sets({fields}) VALUES({values}) RETURNING *").format(
83
- fields=SQL(", ").join(fields), values=SQL(", ").join(values)
94
+ fields=db_params.fields(), values=db_params.placeholders()
84
95
  ),
85
- params_as_dict,
96
+ db_params.params_as_dict,
86
97
  row_factory=class_row(UploadSet),
87
98
  )
88
99
 
@@ -92,6 +103,25 @@ def create_upload_set(params: UploadSetCreationParameter, accountId: UUID) -> Up
92
103
  return db_upload_set
93
104
 
94
105
 
106
+ def update_upload_set(upload_set_id: UUID, params: UploadSetUpdateParameter) -> UploadSet:
107
+ db_params = model_query.get_db_params_and_values(params)
108
+
109
+ with db.conn(current_app) as conn, conn.transaction():
110
+ import psycopg
111
+
112
+ cur = psycopg.ClientCursor(conn)
113
+ q = SQL("UPDATE upload_sets SET {fields} WHERE id = %(upload_set_id)s").format(fields=db_params.fields_for_set())
114
+ print(cur.mogrify(q, db_params.params_as_dict | {"upload_set_id": upload_set_id}))
115
+
116
+ with db.execute(
117
+ current_app,
118
+ SQL("UPDATE upload_sets SET {fields} WHERE id = %(upload_set_id)s").format(fields=db_params.fields_for_set()),
119
+ db_params.params_as_dict | {"upload_set_id": upload_set_id},
120
+ ):
121
+ # we get a full uploadset response
122
+ return get_upload_set(upload_set_id)
123
+
124
+
95
125
  @bp.route("/upload_sets", methods=["POST"])
96
126
  @auth.login_required_by_setting("API_FORCE_AUTH_ON_UPLOAD")
97
127
  def postUploadSet(account=None):
@@ -150,6 +180,64 @@ def postUploadSet(account=None):
150
180
  )
151
181
 
152
182
 
183
+ @bp.route("/upload_sets/<uuid:upload_set_id>", methods=["PATCH"])
184
+ @auth.login_required_by_setting("API_FORCE_AUTH_ON_UPLOAD")
185
+ def patchUploadSet(upload_set_id, account=None):
186
+ """Update an existing UploadSet.
187
+
188
+ Note that the upload set will not be dispatched again, so if you changed the dispatch parameters (like split_distance, split_time, duplicate_distance, duplicate_rotation, ...), you need to call the `POST /api/upload_sets/:id/complete` endpoint to dispatch the upload set afterward.
189
+ ---
190
+ tags:
191
+ - Upload
192
+ - UploadSet
193
+ parameters:
194
+ - name: upload_set_id
195
+ in: path
196
+ description: ID of the UploadSet
197
+ required: true
198
+ schema:
199
+ type: string
200
+ requestBody:
201
+ content:
202
+ application/json:
203
+ schema:
204
+ $ref: '#/components/schemas/GeoVisioUploadSet'
205
+ security:
206
+ - bearerToken: []
207
+ - cookieAuth: []
208
+ responses:
209
+ 200:
210
+ description: the UploadSet metadata
211
+ content:
212
+ application/json:
213
+ schema:
214
+ $ref: '#/components/schemas/GeoVisioUploadSet'
215
+ """
216
+
217
+ if request.is_json and request.json is not None:
218
+ try:
219
+ params = UploadSetUpdateParameter(**request.json)
220
+ except ValidationError as ve:
221
+ raise errors.InvalidAPIUsage(_("Impossible to update the UploadSet"), payload=validation_error(ve))
222
+ else:
223
+ raise errors.InvalidAPIUsage(_("Parameter for updating an UploadSet should be a valid JSON"), status_code=415)
224
+
225
+ upload_set = get_simple_upload_set(upload_set_id)
226
+ if upload_set is None:
227
+ raise errors.InvalidAPIUsage(_("UploadSet doesn't exist"), status_code=404)
228
+
229
+ if account and str(upload_set.account_id) != account.id:
230
+ raise errors.InvalidAPIUsage(_("You are not allowed to update this upload set"), status_code=403)
231
+
232
+ if not params.model_fields_set:
233
+ # nothing to update, return the upload set
234
+ upload_set = get_upload_set(upload_set_id)
235
+ else:
236
+ upload_set = update_upload_set(upload_set_id, params)
237
+
238
+ return upload_set.model_dump_json(exclude_none=True), 200, {"Content-Type": "application/json"}
239
+
240
+
153
241
  @bp.route("/upload_sets/<uuid:upload_set_id>", methods=["GET"])
154
242
  def getUploadSet(upload_set_id):
155
243
  """Get an existing UploadSet
@@ -185,8 +273,7 @@ def getUploadSet(upload_set_id):
185
273
 
186
274
 
187
275
  @bp.route("/upload_sets/<uuid:upload_set_id>/files", methods=["GET"])
188
- @auth.login_required_by_setting("API_FORCE_AUTH_ON_UPLOAD")
189
- def getUploadSetFiles(upload_set_id, account=None):
276
+ def getUploadSetFiles(upload_set_id):
190
277
  """List the files of an UploadSet
191
278
  ---
192
279
  tags:
@@ -210,13 +297,20 @@ def getUploadSetFiles(upload_set_id, account=None):
210
297
  schema:
211
298
  $ref: '#/components/schemas/GeoVisioUploadSetFiles'
212
299
  """
300
+ account = utils.auth.get_current_account()
301
+
213
302
  u = get_simple_upload_set(upload_set_id)
214
303
  if u is None:
215
304
  raise errors.InvalidAPIUsage(_("UploadSet doesn't exist"), status_code=404)
216
- if account is not None and account.id != str(u.account_id):
217
- raise errors.InvalidAPIUsage(_("You're not authorized to list pictures in this upload set"), status_code=403)
218
305
 
219
306
  upload_set_files = get_upload_set_files(upload_set_id)
307
+
308
+ if account is None or account.id != str(u.account_id):
309
+ # if the user is not the owner of the upload set, we remove the picture_id since we might leak too many information
310
+ # not sure about this one, this could evolve in the future
311
+ for f in upload_set_files.files:
312
+ f.picture_id = None
313
+
220
314
  return upload_set_files.model_dump_json(exclude_none=True), 200, {"Content-Type": "application/json"}
221
315
 
222
316
 
geovisio/web/users.py CHANGED
@@ -1,43 +1,91 @@
1
+ from typing import List, Optional
2
+ from uuid import UUID
1
3
  import flask
2
- from flask import request, current_app, url_for
4
+ from flask import request, current_app, session, url_for
3
5
  from flask_babel import gettext as _
6
+ from pydantic import BaseModel, ConfigDict, ValidationError, computed_field
4
7
  from geovisio.utils import auth, db
5
8
  from geovisio import errors
6
- from psycopg.rows import dict_row
9
+ from psycopg.rows import dict_row, class_row
7
10
  from psycopg.sql import SQL
8
11
 
12
+ from geovisio.utils.link import Link, make_link
13
+ from geovisio.utils.model_query import get_db_params_and_values
14
+ from geovisio.utils.params import validation_error
9
15
  from geovisio.web import stac
16
+ from geovisio.web.auth import NEXT_URL_KEY
10
17
  from geovisio.web.utils import get_root_link
11
18
 
12
19
  bp = flask.Blueprint("user", __name__, url_prefix="/api/users")
13
20
 
14
21
 
15
- def _get_user_info(id, name):
16
- userMapUrl = (
17
- flask.url_for("map.getUserTile", userId=id, x="11111111", y="22222222", z="33333333", format="mvt", _external=True)
18
- .replace("11111111", "{x}")
19
- .replace("22222222", "{y}")
20
- .replace("33333333", "{z}")
21
- )
22
- response = {
23
- "id": id,
24
- "name": name,
25
- "links": [
26
- {"rel": "catalog", "type": "application/json", "href": flask.url_for("stac.getUserCatalog", userId=id, _external=True)},
27
- {
28
- "rel": "collection",
29
- "type": "application/json",
30
- "href": flask.url_for("stac_collections.getUserCollection", userId=id, _external=True),
31
- },
32
- {
33
- "rel": "user-xyz",
34
- "type": "application/vnd.mapbox-vector-tile",
35
- "href": userMapUrl,
36
- "title": "Pictures and sequences vector tiles for a given user",
37
- },
38
- ],
39
- }
40
- return flask.jsonify(response)
22
+ class Permissions(BaseModel):
23
+ """Role and permissions of a user"""
24
+
25
+ role: auth.AccountRole
26
+ """Role of the user"""
27
+ can_check_reports: bool
28
+ """Is account legitimate to read any report ?"""
29
+ can_edit_excluded_areas: bool
30
+ """Is account legitimate to read and edit excluded areas ?"""
31
+ can_edit_pages: bool
32
+ """Is account legitimate to edit web pages ?"""
33
+
34
+ model_config = ConfigDict(use_attribute_docstrings=True, use_enum_values=True)
35
+
36
+
37
+ class UserInfo(BaseModel):
38
+ name: str
39
+ """Name of the user"""
40
+ id: UUID
41
+ """Unique identifier of the user"""
42
+ collaborative_metadata: Optional[bool] = None
43
+ """If true, the user can edit the metadata of all sequences. If unset, default to the instance's default configuration."""
44
+
45
+ tos_accepted: Optional[bool] = None
46
+ """True means the user has accepted the terms of service (tos). Can only be seen by the user itself"""
47
+
48
+ permissions: Optional[Permissions] = None
49
+ """The user role and permissions. Can only be seen by the user itself"""
50
+
51
+ model_config = ConfigDict(use_attribute_docstrings=True)
52
+
53
+ @computed_field
54
+ @property
55
+ def links(self) -> List[Link]:
56
+ userMapUrl = (
57
+ flask.url_for("map.getUserTile", userId=self.id, x="11111111", y="22222222", z="33333333", format="mvt", _external=True)
58
+ .replace("11111111", "{x}")
59
+ .replace("22222222", "{y}")
60
+ .replace("33333333", "{z}")
61
+ )
62
+ return [
63
+ make_link(rel="catalog", route="stac.getUserCatalog", userId=self.id),
64
+ make_link(rel="collection", route="stac_collections.getUserCollection", userId=self.id),
65
+ Link(
66
+ rel="user-xyz",
67
+ type="application/vnd.mapbox-vector-tile",
68
+ title="Pictures and sequences vector tiles for a given user",
69
+ href=userMapUrl,
70
+ ),
71
+ ]
72
+
73
+
74
+ def _get_user_info(account: auth.Account):
75
+ user_info = UserInfo(id=account.id, name=account.name, collaborative_metadata=account.collaborative_metadata)
76
+ logged_account = auth.get_current_account()
77
+ if logged_account is not None and account.id == logged_account.id:
78
+ # we show the term of service acceptance only if the user is the logged user and if ToS are mandatory
79
+ if flask.current_app.config["API_ENFORCE_TOS_ACCEPTANCE"]:
80
+ user_info.tos_accepted = account.tos_accepted
81
+ user_info.permissions = Permissions(
82
+ role=account.role,
83
+ can_check_reports=account.can_check_reports(),
84
+ can_edit_excluded_areas=account.can_edit_excluded_areas(),
85
+ can_edit_pages=account.can_edit_pages(),
86
+ )
87
+
88
+ return user_info.model_dump(exclude_unset=True), 200, {"Content-Type": "application/json"}
41
89
 
42
90
 
43
91
  @bp.route("/me")
@@ -55,7 +103,7 @@ def getMyUserInfo(account):
55
103
  schema:
56
104
  $ref: '#/components/schemas/GeoVisioUser'
57
105
  """
58
- return _get_user_info(account.id, account.name)
106
+ return _get_user_info(account)
59
107
 
60
108
 
61
109
  @bp.route("/<uuid:userId>")
@@ -79,21 +127,29 @@ def getUserInfo(userId):
79
127
  schema:
80
128
  $ref: '#/components/schemas/GeoVisioUser'
81
129
  """
82
- account = db.fetchone(current_app, SQL("SELECT name, id FROM accounts WHERE id = %s"), [userId], row_factory=dict_row)
130
+ account = db.fetchone(
131
+ current_app,
132
+ SQL("SELECT name, id::text, collaborative_metadata, role, tos_accepted FROM accounts WHERE id = %s"),
133
+ [userId],
134
+ row_factory=class_row(auth.Account),
135
+ )
83
136
  if not account:
84
137
  raise errors.InvalidAPIUsage(_("Impossible to find user"), status_code=404)
85
138
 
86
- return _get_user_info(account["id"], account["name"])
139
+ return _get_user_info(account)
87
140
 
88
141
 
89
142
  @bp.route("/me/catalog")
90
143
  @auth.login_required_with_redirect()
91
144
  def getMyCatalog(account):
92
- """Get current logged user catalog
145
+ """Get current logged user catalog.
146
+
147
+ Note that this route is deprecated in favor of `/api/users/me/collection`. This new route provides more information and offers more filtering and sorting options.
93
148
  ---
94
149
  tags:
95
150
  - Users
96
151
  - Sequences
152
+ deprecated: true
97
153
  responses:
98
154
  200:
99
155
  description: the Catalog listing all sequences associated to given user. Note that it's similar to the user's colletion, but with less metadata since a STAC collection is an enhanced STAC catalog.
@@ -102,18 +158,37 @@ def getMyCatalog(account):
102
158
  schema:
103
159
  $ref: '#/components/schemas/GeoVisioCatalog'
104
160
  """
105
- return flask.redirect(flask.url_for("stac.getUserCatalog", userId=account.id, _external=True))
161
+ return flask.redirect(
162
+ flask.url_for(
163
+ "stac.getUserCatalog",
164
+ userId=account.id,
165
+ limit=request.args.get("limit"),
166
+ page=request.args.get("page"),
167
+ _external=True,
168
+ )
169
+ )
106
170
 
107
171
 
108
172
  @bp.route("/me/collection")
109
173
  @auth.login_required_with_redirect()
110
174
  def getMyCollection(account):
111
175
  """Get current logged user collection
176
+
177
+ Note that 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".
178
+
112
179
  ---
113
180
  tags:
114
181
  - Users
115
182
  - Sequences
116
183
  parameters:
184
+ - name: format
185
+ in: query
186
+ description: Expected output format (STAC JSON or a csv file)
187
+ required: false
188
+ schema:
189
+ type: string
190
+ enum: [csv, json]
191
+ default: json
117
192
  - $ref: '#/components/parameters/STAC_collections_limit'
118
193
  - $ref: '#/components/parameters/STAC_collections_filter'
119
194
  - $ref: '#/components/parameters/STAC_bbox'
@@ -125,19 +200,14 @@ def getMyCollection(account):
125
200
  application/json:
126
201
  schema:
127
202
  $ref: '#/components/schemas/GeoVisioCollectionOfCollection'
203
+
204
+ text/csv:
205
+ schema:
206
+ $ref: '#/components/schemas/GeoVisioCSVCollections'
128
207
  """
208
+ from geovisio.web.collections import getUserCollection
129
209
 
130
- return flask.redirect(
131
- flask.url_for(
132
- "stac_collections.getUserCollection",
133
- userId=account.id,
134
- filter=request.args.get("filter"),
135
- limit=request.args.get("limit"),
136
- sortby=request.args.get("sortby"),
137
- bbox=request.args.get("bbox"),
138
- _external=True,
139
- )
140
- )
210
+ return getUserCollection(userId=account.id, userIdMatchesAccount=True)
141
211
 
142
212
 
143
213
  @bp.route("/search")
@@ -272,3 +342,92 @@ LIMIT {limit};"""
272
342
  if r["has_seq"]
273
343
  ],
274
344
  }
345
+
346
+
347
+ class UserConfiguration(BaseModel):
348
+ collaborative_metadata: Optional[bool] = None
349
+ """If true, all sequences's metadata will be, by default, editable by all users.
350
+
351
+ If not set, it will default to the instance default collaborative editing policy."""
352
+
353
+ def has_override(self) -> bool:
354
+ return bool(self.model_fields_set)
355
+
356
+
357
+ @bp.route("/me", methods=["PATCH"])
358
+ @auth.login_required()
359
+ def patchUserConfiguration(account):
360
+ """Edit the current user configuration
361
+
362
+ ---
363
+ tags:
364
+ - Users
365
+ requestBody:
366
+ content:
367
+ application/json:
368
+ schema:
369
+ $ref: '#/components/schemas/GeoVisioUserConfiguration'
370
+ security:
371
+ - bearerToken: []
372
+ - cookieAuth: []
373
+ responses:
374
+ 200:
375
+ description: the user configuration
376
+ content:
377
+ application/json:
378
+ schema:
379
+ $ref: '#/components/schemas/GeoVisioUser'
380
+ """
381
+ metadata = None
382
+ try:
383
+ if request.is_json and request.json:
384
+ metadata = UserConfiguration(**request.json)
385
+ except ValidationError as ve:
386
+ raise errors.InvalidAPIUsage(_("Impossible to parse parameters"), payload=validation_error(ve))
387
+
388
+ if not metadata:
389
+ return _get_user_info(account)
390
+ params = get_db_params_and_values(metadata)
391
+ if metadata.has_override():
392
+
393
+ fields = params.fields_for_set_list()
394
+
395
+ account = db.fetchone(
396
+ current_app,
397
+ SQL("UPDATE accounts SET {fields} WHERE id = %(account_id)s RETURNING *").format(fields=SQL(", ").join(fields)),
398
+ params.params_as_dict | {"account_id": account.id},
399
+ row_factory=class_row(auth.Account),
400
+ )
401
+
402
+ return _get_user_info(account)
403
+
404
+
405
+ @bp.route("/me/accept_tos", methods=["POST"])
406
+ @auth.login_required()
407
+ def accept_tos(account: auth.Account):
408
+ """
409
+ Accept the terms of service for the current user
410
+ ---
411
+ tags:
412
+ - Auth
413
+ responses:
414
+ 200:
415
+ description: the user configuration
416
+ content:
417
+ application/json:
418
+ schema:
419
+ $ref: '#/components/schemas/GeoVisioUser'
420
+ """
421
+ # Note: accepting twice does not change the accepted_at date
422
+ account = db.fetchone(
423
+ current_app,
424
+ SQL("UPDATE accounts SET tos_accepted_at = COALESCE(tos_accepted_at, NOW()) WHERE id = %(account_id)s RETURNING *"),
425
+ {"account_id": account.id},
426
+ row_factory=class_row(auth.Account),
427
+ )
428
+
429
+ # we persist in the cookie the fact that the tos have been accepted
430
+ session[auth.ACCOUNT_KEY] = account.model_dump(exclude_none=True)
431
+ session.permanent = True
432
+
433
+ return _get_user_info(account)