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.
Files changed (58) hide show
  1. geovisio/__init__.py +3 -1
  2. geovisio/admin_cli/user.py +7 -2
  3. geovisio/config_app.py +21 -7
  4. geovisio/translations/be/LC_MESSAGES/messages.mo +0 -0
  5. geovisio/translations/be/LC_MESSAGES/messages.po +886 -0
  6. geovisio/translations/da/LC_MESSAGES/messages.mo +0 -0
  7. geovisio/translations/da/LC_MESSAGES/messages.po +96 -5
  8. geovisio/translations/de/LC_MESSAGES/messages.mo +0 -0
  9. geovisio/translations/de/LC_MESSAGES/messages.po +171 -132
  10. geovisio/translations/en/LC_MESSAGES/messages.mo +0 -0
  11. geovisio/translations/en/LC_MESSAGES/messages.po +169 -146
  12. geovisio/translations/eo/LC_MESSAGES/messages.mo +0 -0
  13. geovisio/translations/eo/LC_MESSAGES/messages.po +3 -2
  14. geovisio/translations/fr/LC_MESSAGES/messages.mo +0 -0
  15. geovisio/translations/fr/LC_MESSAGES/messages.po +3 -2
  16. geovisio/translations/it/LC_MESSAGES/messages.mo +0 -0
  17. geovisio/translations/it/LC_MESSAGES/messages.po +1 -1
  18. geovisio/translations/messages.pot +159 -138
  19. geovisio/translations/nl/LC_MESSAGES/messages.mo +0 -0
  20. geovisio/translations/nl/LC_MESSAGES/messages.po +44 -2
  21. geovisio/translations/oc/LC_MESSAGES/messages.mo +0 -0
  22. geovisio/translations/oc/LC_MESSAGES/messages.po +9 -6
  23. geovisio/translations/pt/LC_MESSAGES/messages.mo +0 -0
  24. geovisio/translations/pt/LC_MESSAGES/messages.po +944 -0
  25. geovisio/translations/pt_BR/LC_MESSAGES/messages.mo +0 -0
  26. geovisio/translations/pt_BR/LC_MESSAGES/messages.po +942 -0
  27. geovisio/translations/sv/LC_MESSAGES/messages.mo +0 -0
  28. geovisio/translations/sv/LC_MESSAGES/messages.po +1 -1
  29. geovisio/translations/tr/LC_MESSAGES/messages.mo +0 -0
  30. geovisio/translations/tr/LC_MESSAGES/messages.po +927 -0
  31. geovisio/translations/uk/LC_MESSAGES/messages.mo +0 -0
  32. geovisio/translations/uk/LC_MESSAGES/messages.po +920 -0
  33. geovisio/utils/annotations.py +7 -4
  34. geovisio/utils/auth.py +33 -0
  35. geovisio/utils/cql2.py +20 -3
  36. geovisio/utils/pictures.py +16 -18
  37. geovisio/utils/sequences.py +104 -75
  38. geovisio/utils/upload_set.py +20 -10
  39. geovisio/utils/users.py +18 -0
  40. geovisio/web/annotations.py +96 -3
  41. geovisio/web/collections.py +169 -76
  42. geovisio/web/configuration.py +12 -0
  43. geovisio/web/docs.py +17 -3
  44. geovisio/web/items.py +129 -72
  45. geovisio/web/map.py +92 -54
  46. geovisio/web/pages.py +48 -4
  47. geovisio/web/params.py +56 -11
  48. geovisio/web/pictures.py +3 -3
  49. geovisio/web/prepare.py +4 -2
  50. geovisio/web/queryables.py +57 -0
  51. geovisio/web/stac.py +8 -2
  52. geovisio/web/upload_set.py +83 -26
  53. geovisio/web/users.py +85 -4
  54. geovisio/web/utils.py +24 -6
  55. {geovisio-2.10.0.dist-info → geovisio-2.11.0.dist-info}/METADATA +3 -2
  56. {geovisio-2.10.0.dist-info → geovisio-2.11.0.dist-info}/RECORD +58 -46
  57. {geovisio-2.10.0.dist-info → geovisio-2.11.0.dist-info}/WHEEL +0 -0
  58. {geovisio-2.10.0.dist-info → geovisio-2.11.0.dist-info}/licenses/LICENSE +0 -0
@@ -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, annotation_id: UUID) -> None:
181
- """Delete an annotation from the database"""
182
- with conn.cursor() as cursor:
183
- cursor.execute("DELETE FROM annotations WHERE id = %(id)s", {"id": annotation_id})
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)
@@ -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
- account = utils.auth.get_current_account()
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
- SELECT
447
- p.status,
448
- (p.metadata->>'cols')::int AS cols,
449
- (p.metadata->>'rows')::int AS rows,
450
- p.metadata->>'type' AS type,
451
- p.account_id,
452
- s.status AS seq_status
453
- FROM pictures p
454
- JOIN sequences_pictures sp ON sp.pic_id = p.id
455
- JOIN sequences s ON s.id = sp.seq_id
456
- WHERE p.id = %s
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 (either hidden by admin or processing)"), status_code=403)
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:
@@ -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, g, url_for
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, SortByField
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(metadata, accountId, user_agent: Optional[str] = None) -> UUID:
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) VALUES(%s, %s, %s) RETURNING id",
29
- [accountId, Jsonb(metadata), user_agent],
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("status"), stac="status"),
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: List[Composable] = []
83
- seq_params: dict = {}
84
-
85
- # Sort-by parameter
86
- # Note for review: I'm not sure I understand this non nullity constraint, but if so, shouldn't all sortby fields be added ?
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
- if not request.userOwnsAllCollections and "'deleted'" not in user_filter_str:
105
- # if there are status filter and we ask for deleted sequence, we also include hidden one and consider them as deleted
106
- seq_filter.append(SQL("status <> 'hidden'"))
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
- status_field = None
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 logged users can see detailed status
111
- status_field = SQL("s.status AS status")
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
- # hidden sequence are marked as deleted, this way crawler can update their catalog
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
- SELECT *
208
- FROM ({base_query}) s
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 = CASE WHEN status = 'hidden' THEN 'hidden'::sequence_status ELSE 'ready'::sequence_status END, -- we don't want to change status if it's hidden
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() -> Optional[datetime.datetime]:
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.id != str(sequence[1]):
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")
@@ -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(account_id: UUID, limit: int = 100, filter: Optional[str] = None) -> UploadSets:
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"]
@@ -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