geovisio 2.7.0__py3-none-any.whl → 2.8.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 (64) hide show
  1. geovisio/__init__.py +11 -3
  2. geovisio/admin_cli/__init__.py +3 -1
  3. geovisio/admin_cli/cleanup.py +2 -2
  4. geovisio/admin_cli/user.py +75 -0
  5. geovisio/config_app.py +87 -4
  6. geovisio/templates/main.html +2 -2
  7. geovisio/templates/viewer.html +3 -3
  8. geovisio/translations/da/LC_MESSAGES/messages.mo +0 -0
  9. geovisio/translations/da/LC_MESSAGES/messages.po +850 -0
  10. geovisio/translations/de/LC_MESSAGES/messages.mo +0 -0
  11. geovisio/translations/de/LC_MESSAGES/messages.po +235 -2
  12. geovisio/translations/el/LC_MESSAGES/messages.mo +0 -0
  13. geovisio/translations/el/LC_MESSAGES/messages.po +685 -0
  14. geovisio/translations/en/LC_MESSAGES/messages.mo +0 -0
  15. geovisio/translations/en/LC_MESSAGES/messages.po +244 -153
  16. geovisio/translations/eo/LC_MESSAGES/messages.mo +0 -0
  17. geovisio/translations/eo/LC_MESSAGES/messages.po +790 -0
  18. geovisio/translations/es/LC_MESSAGES/messages.mo +0 -0
  19. geovisio/translations/fi/LC_MESSAGES/messages.mo +0 -0
  20. geovisio/translations/fr/LC_MESSAGES/messages.mo +0 -0
  21. geovisio/translations/fr/LC_MESSAGES/messages.po +40 -3
  22. geovisio/translations/hu/LC_MESSAGES/messages.mo +0 -0
  23. geovisio/translations/hu/LC_MESSAGES/messages.po +773 -0
  24. geovisio/translations/it/LC_MESSAGES/messages.mo +0 -0
  25. geovisio/translations/it/LC_MESSAGES/messages.po +875 -0
  26. geovisio/translations/ja/LC_MESSAGES/messages.mo +0 -0
  27. geovisio/translations/ja/LC_MESSAGES/messages.po +719 -0
  28. geovisio/translations/ko/LC_MESSAGES/messages.mo +0 -0
  29. geovisio/translations/messages.pot +225 -148
  30. geovisio/translations/nl/LC_MESSAGES/messages.mo +0 -0
  31. geovisio/translations/nl/LC_MESSAGES/messages.po +24 -16
  32. geovisio/translations/pl/LC_MESSAGES/messages.mo +0 -0
  33. geovisio/translations/pl/LC_MESSAGES/messages.po +727 -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/model_query.py +55 -0
  39. geovisio/utils/pictures.py +29 -62
  40. geovisio/utils/semantics.py +120 -0
  41. geovisio/utils/sequences.py +30 -23
  42. geovisio/utils/tokens.py +5 -3
  43. geovisio/utils/upload_set.py +87 -64
  44. geovisio/utils/website.py +50 -0
  45. geovisio/web/annotations.py +17 -0
  46. geovisio/web/auth.py +9 -5
  47. geovisio/web/collections.py +235 -63
  48. geovisio/web/configuration.py +17 -1
  49. geovisio/web/docs.py +99 -54
  50. geovisio/web/items.py +233 -100
  51. geovisio/web/map.py +129 -31
  52. geovisio/web/pages.py +240 -0
  53. geovisio/web/params.py +17 -0
  54. geovisio/web/prepare.py +165 -0
  55. geovisio/web/stac.py +17 -4
  56. geovisio/web/tokens.py +14 -4
  57. geovisio/web/upload_set.py +19 -10
  58. geovisio/web/users.py +176 -44
  59. geovisio/workers/runner_pictures.py +75 -50
  60. {geovisio-2.7.0.dist-info → geovisio-2.8.0.dist-info}/METADATA +6 -5
  61. geovisio-2.8.0.dist-info/RECORD +89 -0
  62. {geovisio-2.7.0.dist-info → geovisio-2.8.0.dist-info}/WHEEL +1 -1
  63. geovisio-2.7.0.dist-info/RECORD +0 -66
  64. {geovisio-2.7.0.dist-info → geovisio-2.8.0.dist-info}/LICENSE +0 -0
geovisio/utils/auth.py CHANGED
@@ -1,3 +1,6 @@
1
+ from ast import Dict
2
+ from uuid import UUID
3
+ from click import Option
1
4
  import flask
2
5
  from flask import current_app, url_for, session, redirect, request
3
6
  from flask_babel import gettext as _
@@ -8,9 +11,10 @@ from abc import ABC, abstractmethod
8
11
  from typing import Any
9
12
  from typing import Optional
10
13
  from enum import Enum
11
- from pydantic import BaseModel, ConfigDict, Field
14
+ from pydantic import BaseModel, ConfigDict, Field, ValidationError, field_validator
12
15
  import sentry_sdk
13
16
  from psycopg.rows import dict_row
17
+ from geovisio import errors
14
18
  from geovisio.utils import db
15
19
 
16
20
 
@@ -159,16 +163,30 @@ class Account(BaseModel):
159
163
  name: str
160
164
  oauth_provider: Optional[str] = None
161
165
  oauth_id: Optional[str] = None
162
-
163
- model_config = ConfigDict(extra="forbid")
166
+ tos_accepted: Optional[bool] = None
164
167
 
165
168
  def __init__(self, role: Optional[AccountRole] = None, **kwargs) -> None:
169
+ # Note: since it's a valid state for the collaborative_metadata to be None,
170
+ # we need to only set it if provided, this way we can check the `model_fields_set` to know if the collaborative_metadata is set
171
+ collaborative_metadata_set = "collaborative_metadata" in kwargs
172
+ collaborative_metadata = kwargs.pop("collaborative_metadata", None)
166
173
  super().__init__(**kwargs)
167
174
  self.role = role
175
+ if collaborative_metadata_set:
176
+ self.collaborative_metadata = collaborative_metadata
168
177
 
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
178
+ # Note: those fields are excluded since we do not want to persist it in the cookie. It will be fetched from the database if needed
171
179
  role_: Optional[AccountRole] = Field(default=None, exclude=True)
180
+ collaborative_metadata_: Optional[bool] = Field(default=None, exclude=True)
181
+
182
+ @field_validator("id", mode="before")
183
+ @classmethod
184
+ def check_id(cls, value) -> str:
185
+ if isinstance(value, UUID):
186
+ return str(value)
187
+ if isinstance(value, str):
188
+ return value
189
+ raise ValidationError("Invalid account id type")
172
190
 
173
191
  def can_check_reports(self):
174
192
  """Is account legitimate to read any report ?"""
@@ -178,17 +196,57 @@ class Account(BaseModel):
178
196
  """Is account legitimate to read and edit excluded areas ?"""
179
197
  return self.role == AccountRole.admin
180
198
 
199
+ def can_edit_pages(self):
200
+ """Is account legitimate to edit web pages ?"""
201
+ return self.role == AccountRole.admin
202
+
181
203
  @property
182
204
  def role(self) -> AccountRole:
183
205
  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"])
206
+ self._fetch_database_info()
186
207
  return self.role_
187
208
 
188
209
  @role.setter
189
- def role(self, r: AccountRole) -> None:
210
+ def role(self, r: AccountRole | str) -> None:
211
+ if isinstance(r, str):
212
+ r = AccountRole(r)
190
213
  self.role_ = r
191
214
 
215
+ @property
216
+ def collaborative_metadata(self) -> Optional[bool]:
217
+ if "collaborative_metadata_" not in self.model_fields_set:
218
+ self._fetch_database_info()
219
+ return self.collaborative_metadata_
220
+
221
+ @collaborative_metadata.setter
222
+ def collaborative_metadata(self, b: Optional[bool]) -> None:
223
+ self.collaborative_metadata_ = b
224
+
225
+ def _fetch_database_info(self):
226
+ """Fetch the missing database metadata for this account"""
227
+ r = db.fetchone(
228
+ current_app,
229
+ "SELECT role, collaborative_metadata FROM accounts WHERE id = %s",
230
+ (self.id,),
231
+ row_factory=dict_row,
232
+ )
233
+ self.role = AccountRole(r["role"])
234
+ self.collaborative_metadata = r["collaborative_metadata"]
235
+
236
+
237
+ def account_allow_collaborative_editing(account_id: str | UUID):
238
+ """An account allow collaborative editing it if has been allow at the account level else we check the instance configuration"""
239
+ r = db.fetchone(
240
+ current_app,
241
+ """SELECT COALESCE(accounts.collaborative_metadata, configurations.collaborative_metadata, true) AS collaborative_metadata
242
+ FROM accounts
243
+ JOIN configurations ON TRUE
244
+ WHERE accounts.id = %s""",
245
+ [account_id],
246
+ row_factory=dict_row,
247
+ )
248
+ return r["collaborative_metadata"]
249
+
192
250
 
193
251
  def login_required():
194
252
  """Check that the user is logged, and abort if it's not the case"""
@@ -221,6 +279,20 @@ def login_required_by_setting(mandatory_login_param):
221
279
  account = get_current_account()
222
280
  if not account and current_app.config[mandatory_login_param]:
223
281
  return flask.abort(flask.make_response(flask.jsonify(message="Authentication is mandatory"), 401))
282
+ if account and account.tos_accepted is False and current_app.config["API_ENFORCE_TOS_ACCEPTANCE"]:
283
+ tos_acceptance_page = current_app.config["API_WEBSITE_URL"].tos_validation_page()
284
+ raise errors.InvalidAPIUsage(
285
+ message=_(
286
+ "You need to accept the terms of service before uploading any pictures. You can do so by validating them here: %(url)s",
287
+ url=tos_acceptance_page,
288
+ ),
289
+ status_code=401,
290
+ payload={
291
+ "details": {
292
+ "validation_page": tos_acceptance_page,
293
+ }
294
+ },
295
+ )
224
296
  kwargs["account"] = account
225
297
 
226
298
  return f(*args, **kwargs)
geovisio/utils/link.py CHANGED
@@ -6,9 +6,10 @@ from flask import url_for
6
6
  class Link(BaseModel):
7
7
  rel: str
8
8
  type: str
9
- title: Optional[str]
9
+ title: Optional[str] = None
10
10
  href: str
11
11
 
12
12
 
13
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))
14
+ kwargs = {"title": title} if title else {} # do not pass none title, to know if it has been set or not
15
+ return Link(rel=rel, type=type, href=url_for(route, **args, _external=True), **kwargs)
@@ -0,0 +1,55 @@
1
+ from typing import Any, Dict, List
2
+ from pydantic import BaseModel
3
+ from psycopg.sql import SQL, Identifier, Placeholder, Composed
4
+ from psycopg.types.json import Jsonb
5
+
6
+
7
+ class ParamsAndValues:
8
+ """Simple wrapper used to help building a query with the right psycopg types"""
9
+
10
+ params_as_dict: Dict[str, Any]
11
+
12
+ def __init__(self, model: BaseModel, **kwargs):
13
+ self.params_as_dict = model.model_dump(exclude_none=True) | kwargs
14
+
15
+ for k, v in self.params_as_dict.items():
16
+ if isinstance(v, Dict):
17
+ self.params_as_dict[k] = Jsonb(v) # convert dict to jsonb in database
18
+
19
+ def has_updates(self):
20
+ return bool(self.params_as_dict)
21
+
22
+ def fields(self) -> Composed:
23
+ """Get the database fields identifiers"""
24
+ return SQL(", ").join([Identifier(f) for f in self.params_as_dict.keys()])
25
+
26
+ def placeholders(self) -> Composed:
27
+ """Get the placeholders for the query"""
28
+ return SQL(", ").join([Placeholder(f) for f in self.params_as_dict.keys()])
29
+
30
+ def fields_for_set(self) -> Composed:
31
+ """Get the fields and the placeholders formated for an update query like:
32
+ '"a" = %(a)s, "b" = %(b)s'
33
+
34
+ Can be used directly with a query like:
35
+ ```python
36
+ SQL("UPDATE some_table SET {fields}").format(fields=fields)
37
+ ```
38
+ """
39
+ return SQL(", ").join(self.fields_for_set_list())
40
+
41
+ def fields_for_set_list(self) -> List[Composed]:
42
+ """Get the fields and the placeholders formated for an update query like:
43
+ ['"a" = %(a)s', '"b" = %(b)s']
44
+
45
+ Note that the returned list should be joined with SQL(", ").join()
46
+ """
47
+ return [SQL("{f} = {p}").format(f=Identifier(f), p=Placeholder(f)) for f in self.params_as_dict.keys()]
48
+
49
+
50
+ def get_db_params_and_values(model: BaseModel, **kwargs):
51
+ """Get a simple wrapper to help building a query with the right psycopg types
52
+
53
+ check the unit tests in test_model_query.py for examples
54
+ """
55
+ return ParamsAndValues(model, **kwargs)
@@ -15,6 +15,7 @@ from fs.path import dirname
15
15
  from psycopg.errors import UniqueViolation, InvalidParameterValue
16
16
  from geovisio import utils, errors
17
17
  from geopic_tag_reader import reader
18
+ import re
18
19
 
19
20
  log = logging.getLogger(__name__)
20
21
 
@@ -504,9 +505,10 @@ class InvalidMetadataValue(Exception):
504
505
 
505
506
 
506
507
  class MetadataReadingError(Exception):
507
- def __init__(self, details):
508
+ def __init__(self, details, missing_mandatory_tags=[]):
508
509
  super().__init__()
509
510
  self.details = details
511
+ self.missing_mandatory_tags = missing_mandatory_tags
510
512
 
511
513
 
512
514
  def insertNewPictureInDatabase(
@@ -548,7 +550,10 @@ def insertNewPictureInDatabase(
548
550
 
549
551
  # Create a lighter metadata field to remove duplicates fields
550
552
  lighterMetadata = dict(
551
- filter(lambda v: v[0] not in ["ts", "heading", "lon", "lat", "exif", "originalContentMd5", "ts_by_source"], metadata.items())
553
+ filter(
554
+ lambda v: v[0] not in ["ts", "heading", "lon", "lat", "exif", "originalContentMd5", "ts_by_source", "gps_accuracy"],
555
+ metadata.items(),
556
+ )
552
557
  )
553
558
  if lighterMetadata.get("tagreader_warnings") is not None and len(lighterMetadata["tagreader_warnings"]) == 0:
554
559
  del lighterMetadata["tagreader_warnings"]
@@ -564,11 +569,9 @@ def insertNewPictureInDatabase(
564
569
  # Add picture metadata to database
565
570
  try:
566
571
  picId = db.execute(
567
- """
568
- INSERT INTO pictures (ts, heading, metadata, geom, account_id, exif, original_content_md5, upload_set_id)
569
- VALUES (%s, %s, %s, ST_SetSRID(ST_MakePoint(%s, %s), 4326), %s, %s, %s, %s)
570
- RETURNING id
571
- """,
572
+ """INSERT INTO pictures (ts, heading, metadata, geom, account_id, exif, original_content_md5, upload_set_id, gps_accuracy_m)
573
+ VALUES (%s, %s, %s, ST_SetSRID(ST_MakePoint(%s, %s), 4326), %s, %s, %s, %s, %s)
574
+ RETURNING id""",
572
575
  (
573
576
  metadata["ts"].isoformat(),
574
577
  metadata["heading"],
@@ -579,45 +582,12 @@ def insertNewPictureInDatabase(
579
582
  Jsonb(exif),
580
583
  metadata.get("originalContentMd5"),
581
584
  uploadSetID,
585
+ metadata.get("gps_accuracy"),
582
586
  ),
583
587
  ).fetchone()[0]
584
588
  except InvalidParameterValue as e:
585
589
  raise InvalidMetadataValue(e.diag.message_primary) from e
586
590
 
587
- # Process field of view for each pictures
588
- # Flat pictures = variable fov
589
- if metadata["type"] == "flat":
590
- make, model = metadata.get("make"), metadata.get("model")
591
- if make is not None and model is not None:
592
- db.execute("SET pg_trgm.similarity_threshold = 0.9")
593
- db.execute(
594
- """
595
- UPDATE pictures
596
- SET metadata = jsonb_set(metadata, '{field_of_view}'::text[], COALESCE(
597
- (
598
- SELECT ROUND(DEGREES(2 * ATAN(sensor_width / (2 * (metadata->>'focal_length')::float))))::varchar
599
- FROM cameras
600
- WHERE model %% CONCAT(%(make)s::text, ' ', %(model)s::text)
601
- ORDER BY model <-> CONCAT(%(make)s::text, ' ', %(model)s::text)
602
- LIMIT 1
603
- ),
604
- 'null'
605
- )::jsonb)
606
- WHERE id = %(id)s
607
- """,
608
- {"id": picId, "make": make, "model": model},
609
- )
610
-
611
- # 360 pictures = 360° fov
612
- else:
613
- db.execute(
614
- """
615
- UPDATE pictures
616
- SET metadata = jsonb_set(metadata, '{field_of_view}'::text[], '360'::jsonb)
617
- WHERE id = %s
618
- """,
619
- [picId],
620
- )
621
591
  if sequenceId is not None:
622
592
  try:
623
593
  db.execute("INSERT INTO sequences_pictures(seq_id, rank, pic_id) VALUES(%s, %s, %s)", [sequenceId, position, picId])
@@ -630,31 +600,21 @@ def insertNewPictureInDatabase(
630
600
  # Note: we don't want to store and expose exif binary fields as they are difficult to use and take a lot of storage in the database (~20% for maker notes only)
631
601
  # This list has been queried from real data (cf [this comment](https://gitlab.com/panoramax/server/api/-/merge_requests/241#note_1790580636)).
632
602
  # Update this list (and do a sql migration) if new binary fields are added
603
+ # Note that tags ending in ".0xXXXX" are automatically striped by a regex
633
604
  BLACK_LISTED_BINARY_EXIF_FIELDS = set(
634
605
  [
635
606
  "Exif.Photo.MakerNote",
636
- "Exif.Photo.0xea1c",
637
- "Exif.Image.0xea1c",
638
607
  "Exif.Canon.CameraInfo",
639
608
  "Exif.Image.PrintImageMatching",
640
- "Exif.Image.0xc6d3",
641
609
  "Exif.Panasonic.FaceDetInfo",
642
610
  "Exif.Panasonic.DataDump",
643
- "Exif.Image.0xc6d2",
644
611
  "Exif.Canon.CustomFunctions",
645
612
  "Exif.Canon.AFInfo",
646
- "Exif.Canon.0x4011",
647
- "Exif.Canon.0x4019",
648
613
  "Exif.Canon.ColorData",
649
614
  "Exif.Canon.DustRemovalData",
650
615
  "Exif.Canon.VignettingCorr",
651
616
  "Exif.Canon.AFInfo3",
652
- "Exif.Canon.0x001f",
653
- "Exif.Canon.0x0018",
654
617
  "Exif.Canon.ContrastInfo",
655
- "Exif.Canon.0x002e",
656
- "Exif.Canon.0x0022",
657
- "Exif.Photo.0x9aaa",
658
618
  ]
659
619
  )
660
620
 
@@ -677,22 +637,23 @@ def readPictureMetadata(picture: bytes, lang: Optional[str] = "en") -> dict:
677
637
 
678
638
  try:
679
639
  metadata = asdict(reader.readPictureMetadata(picture, lang))
680
- except Exception as e:
681
- raise MetadataReadingError(details=str(e))
640
+ except reader.PartialExifException as e:
641
+ tags = [t for t in e.missing_mandatory_tags if t not in ("lon", "lat")]
642
+ if "lon" in e.missing_mandatory_tags or "lat" in e.missing_mandatory_tags:
643
+ tags.append("location") # lat/lon is too much detail for missing metadatas, we replace those by 'location'
644
+ raise MetadataReadingError(details=str(e), missing_mandatory_tags=tags)
682
645
 
683
646
  # Cleanup raw EXIF tags to avoid SQL issues
684
647
  cleanedExif = {}
685
- for k, v in metadata["exif"].items():
686
- if k in BLACK_LISTED_BINARY_EXIF_FIELDS:
687
- continue
648
+ for k, v in cleanupExif(metadata["exif"]).items():
688
649
  try:
689
650
  if isinstance(v, bytes):
690
651
  try:
691
- cleanedExif[k] = v.decode("utf-8").replace("\x00", "")
652
+ cleanedExif[k] = v.decode("utf-8").replace("\x00", "").replace("\u0000", "")
692
653
  except UnicodeDecodeError:
693
- cleanedExif[k] = str(v).replace("\x00", "")
654
+ cleanedExif[k] = str(v).replace("\x00", "").replace("\u0000", "")
694
655
  elif isinstance(v, str):
695
- cleanedExif[k] = v.replace("\x00", "")
656
+ cleanedExif[k] = v.replace("\x00", "").replace("\u0000", "")
696
657
  else:
697
658
  try:
698
659
  cleanedExif[k] = str(v)
@@ -701,18 +662,24 @@ def readPictureMetadata(picture: bytes, lang: Optional[str] = "en") -> dict:
701
662
  except:
702
663
  logging.exception("Can't read EXIF tag: " + k + " " + str(type(v)))
703
664
 
665
+ metadata["exif"] = cleanedExif
704
666
  return metadata
705
667
 
706
668
 
669
+ EXIF_KEY_HEX_RGX = r"\.0x[0-9a-fA-F]+$"
670
+
671
+
707
672
  def cleanupExif(exif: Optional[Dict[str, str]]) -> Optional[Dict[str, str]]:
708
- """Removes binary fields from exif
673
+ """Removes binary or undocumented fields from EXIF tags
709
674
  >>> cleanupExif({'A': 'B', 'Exif.Canon.AFInfo': 'Blablabla'})
710
675
  {'A': 'B'}
711
676
  >>> cleanupExif({'A': 'B', 'Exif.Photo.MakerNote': 'Blablabla'})
712
677
  {'A': 'B'}
678
+ >>> cleanupExif({'A': 'B', 'Exif.Sony.0x1234': 'Blablabla'})
679
+ {'A': 'B'}
713
680
  """
714
681
 
715
682
  if exif is None:
716
683
  return None
717
684
 
718
- return {k: v for k, v in exif.items() if k not in BLACK_LISTED_BINARY_EXIF_FIELDS}
685
+ return {k: v for k, v in exif.items() if not re.search(EXIF_KEY_HEX_RGX, k) and k not in BLACK_LISTED_BINARY_EXIF_FIELDS}
@@ -0,0 +1,120 @@
1
+ from dataclasses import dataclass
2
+ from uuid import UUID
3
+ from psycopg import Cursor
4
+ from psycopg.sql import SQL, Identifier
5
+ from psycopg.types.json import Jsonb
6
+ from psycopg.errors import UniqueViolation
7
+ from pydantic import BaseModel, ConfigDict, Field
8
+ from typing import List
9
+ from enum import Enum
10
+
11
+ from geovisio import errors
12
+
13
+
14
+ class TagAction(str, Enum):
15
+ """Actions to perform on a tag list"""
16
+
17
+ add = "add"
18
+ delete = "delete"
19
+
20
+
21
+ class SemanticTagUpdate(BaseModel):
22
+ """Parameters used to update a tag list"""
23
+
24
+ action: TagAction = Field(default=TagAction.add)
25
+ """Action to perform on the tag list. The default action is `add` which will add the given tag to the list.
26
+ The action can also be to `delete` the key/value"""
27
+ key: str = Field(max_length=256)
28
+ """Key of the tag to update limited to 256 characters"""
29
+ value: str = Field(max_length=2048)
30
+ """Value of the tag to update limited ot 2048 characters"""
31
+
32
+ model_config = ConfigDict(use_attribute_docstrings=True)
33
+
34
+
35
+ class SemanticTag(BaseModel):
36
+ key: str
37
+ """Key of the tag"""
38
+ value: str
39
+ """Value of the tag"""
40
+
41
+
42
+ class EntityType(Enum):
43
+
44
+ pic = "picture_id"
45
+ seq = "sequence_id"
46
+ annotation = "annotation_id"
47
+
48
+ def entitiy_id_field(self) -> Identifier:
49
+ return Identifier(self.value)
50
+
51
+
52
+ @dataclass
53
+ class Entity:
54
+ type: EntityType
55
+ id: UUID
56
+
57
+ def get_table(self) -> Identifier:
58
+ match self.type:
59
+ case EntityType.pic:
60
+ return Identifier("pictures_semantics")
61
+ case EntityType.seq:
62
+ return Identifier("sequences_semantics")
63
+ case EntityType.annotation:
64
+ return Identifier("annotations_semantics")
65
+ case _:
66
+ raise ValueError(f"Unknown entity type: {self.type}")
67
+
68
+ def get_history_table(self) -> Identifier:
69
+ match self.type:
70
+ case EntityType.pic:
71
+ return Identifier("pictures_semantics_history")
72
+ case EntityType.seq:
73
+ return Identifier("sequences_semantics_history")
74
+ case EntityType.annotation:
75
+ return Identifier("annotations_semantics_history")
76
+ case _:
77
+ raise ValueError(f"Unknown entity type: {self.type}")
78
+
79
+
80
+ def update_tags(cursor: Cursor, entity: Entity, actions: List[SemanticTagUpdate], account: UUID) -> SemanticTag:
81
+ """Update tags for an entity
82
+ Note: this should be done inside an autocommit transaction
83
+ """
84
+ table_name = entity.get_table()
85
+ fields = [entity.type.entitiy_id_field(), Identifier("key"), Identifier("value")]
86
+ tag_to_add = [t for t in actions if t.action == TagAction.add]
87
+ tag_to_delete = [t for t in actions if t.action == TagAction.delete]
88
+ try:
89
+ if tag_to_delete:
90
+ cursor.execute(SQL("CREATE TEMPORARY TABLE tags_to_delete(key TEXT, value TEXT) ON COMMIT DROP"))
91
+ with cursor.copy(SQL("COPY tags_to_delete (key, value) FROM STDIN")) as copy:
92
+ for tag in tag_to_delete:
93
+ copy.write_row((tag.key, tag.value))
94
+ cursor.execute(
95
+ SQL(
96
+ """DELETE FROM {table}
97
+ WHERE {entity_id} = %(entity)s
98
+ AND (key, value) IN (
99
+ SELECT key, value FROM tags_to_delete
100
+ )"""
101
+ ).format(table=table_name, entity_id=entity.type.entitiy_id_field()),
102
+ {"entity": entity.id, "key_values": [(t.key, t.value) for t in tag_to_delete]},
103
+ )
104
+ if tag_to_add:
105
+ with cursor.copy(SQL("COPY {table} ({fields}) FROM STDIN").format(table=table_name, fields=SQL(",").join(fields))) as copy:
106
+ for tag in tag_to_add:
107
+ copy.write_row((entity.id, tag.key, tag.value))
108
+ if tag_to_add or tag_to_delete:
109
+ # we track the history changes of the semantic tags
110
+ cursor.execute(
111
+ SQL("INSERT INTO {history_table} ({entity_id_field}, account_id, updates) VALUES (%(id)s, %(account)s, %(tags)s)").format(
112
+ history_table=entity.get_history_table(), entity_id_field=entity.type.entitiy_id_field()
113
+ ),
114
+ {"id": entity.id, "account": account, "tags": Jsonb([t.model_dump() for t in tag_to_add + tag_to_delete])},
115
+ )
116
+ except UniqueViolation as e:
117
+ # if the tag already exists, we don't want to add it again
118
+ raise errors.InvalidAPIUsage(
119
+ "Impossible to add semantic tags because of duplicates", payload={"details": {"duplicate": e.diag.message_detail}}
120
+ )
@@ -156,9 +156,20 @@ def get_collections(request: CollectionsRequest) -> Collections:
156
156
  {status},
157
157
  s.computed_capture_date AS datetime,
158
158
  s.user_agent,
159
- ROUND(ST_Length(s.geom::geography)) / 1000 AS length_km
159
+ ROUND(ST_Length(s.geom::geography)) / 1000 AS length_km,
160
+ s.computed_h_pixel_density,
161
+ s.computed_gps_accuracy,
162
+ t.semantics
160
163
  FROM sequences s
161
164
  LEFT JOIN accounts on s.account_id = accounts.id
165
+ LEFT JOIN (
166
+ SELECT sequence_id, json_agg(json_strip_nulls(json_build_object(
167
+ 'key', key,
168
+ 'value', value
169
+ ))) AS semantics
170
+ FROM sequences_semantics
171
+ GROUP BY sequence_id
172
+ ) t ON t.sequence_id = s.id
162
173
  WHERE {filter}
163
174
  ORDER BY {order1}
164
175
  LIMIT {limit}
@@ -516,7 +527,9 @@ SELECT
516
527
  ARRAY_AGG(DISTINCT TRIM(
517
528
  CONCAT(p.metadata->>'make', ' ', p.metadata->>'model')
518
529
  )) AS models,
519
- ARRAY_AGG(DISTINCT p.metadata->>'type') AS types
530
+ ARRAY_AGG(DISTINCT p.metadata->>'type') AS types,
531
+ ARRAY_AGG(DISTINCT p.h_pixel_density) AS reshpd,
532
+ PERCENTILE_CONT(0.9) WITHIN GROUP(ORDER BY p.gps_accuracy_m) AS gpsacc
520
533
  FROM sequences_pictures sp
521
534
  JOIN pictures p ON sp.pic_id = p.id
522
535
  WHERE sp.seq_id = %(seq)s
@@ -529,7 +542,9 @@ geom = compute_sequence_geom(id),
529
542
  bbox = compute_sequence_bbox(id),
530
543
  computed_type = CASE WHEN array_length(types, 1) = 1 THEN types[1] ELSE NULL END,
531
544
  computed_model = CASE WHEN array_length(models, 1) = 1 THEN models[1] ELSE NULL END,
532
- computed_capture_date = day
545
+ computed_capture_date = day,
546
+ computed_h_pixel_density = CASE WHEN array_length(reshpd, 1) = 1 THEN reshpd[1] ELSE NULL END,
547
+ computed_gps_accuracy = gpsacc
533
548
  FROM aggregated_pictures
534
549
  WHERE id = %(seq)s
535
550
  """,
@@ -595,19 +610,16 @@ def delete_collection(collectionId: UUID, account: Optional[Account]) -> int:
595
610
 
596
611
  logging.info(f"Asking for deletion of sequence {collectionId} and all its pictures")
597
612
 
598
- # mark all the pictures as waiting for deletion for async removal as this can be quite long if the storage is slow if there are lots of pictures
613
+ # mark all the pictures as waiting for deletion for async removal as this can be quite long if the storage is slow and there are lots of pictures
599
614
  # Note: To avoid a deadlock if some workers are currently also working on those picture to prepare them,
600
615
  # the SQL queries are split in 2:
601
- # - First a query to add the async deletion task to the queue.
602
- # - Then a query changing the status of the picture to `waiting-for-delete`
616
+ # - First a query to remove jobs preparing those pictures
617
+ # - Then a query deleting those pictures from the database (and a trigger will add async deletion tasks to the queue)
603
618
  #
604
- # The trick there is that there can only be one task for a given picture (either preparing or deleting it)
605
- # And the first query do a `ON CONFLICT DO UPDATE` to change the remaining `prepare` task to `delete`.
606
- # So at the end of this query, we know that there are no more workers working on those pictures, so we can change their status
607
- # without fearing a deadlock.
608
- nb_updated = cursor.execute(
609
- """
610
- WITH pic2rm AS (
619
+ # Since the workers lock their job_queue row when working, at the end of this query, we know that there are no more workers working on those pictures,
620
+ # so we can delete them without fearing a deadlock.
621
+ cursor.execute(
622
+ """WITH pic2rm AS (
611
623
  SELECT pic_id FROM sequences_pictures WHERE seq_id = %(seq)s
612
624
  ),
613
625
  picWithoutOtherSeq AS (
@@ -615,19 +627,15 @@ def delete_collection(collectionId: UUID, account: Optional[Account]) -> int:
615
627
  EXCEPT
616
628
  SELECT pic_id FROM sequences_pictures WHERE pic_id IN (SELECT pic_id FROM pic2rm) AND seq_id != %(seq)s
617
629
  )
618
- INSERT INTO job_queue(picture_id, task)
619
- SELECT pic_id, 'delete' FROM picWithoutOtherSeq
620
- ON CONFLICT (picture_id) DO UPDATE SET task = 'delete'
621
- """,
630
+ DELETE FROM job_queue WHERE picture_id IN (SELECT pic_id FROM picWithoutOtherSeq)""",
622
631
  {"seq": collectionId},
623
632
  ).rowcount
624
633
  # if there was a finalize task for this collection in the queue, we remove it, it's useless
625
634
  cursor.execute("""DELETE FROM job_queue WHERE sequence_id = %(seq)s""", {"seq": collectionId})
626
635
 
627
- # after the task have been added to the queue, we mark all picture for deletion
628
- cursor.execute(
629
- """
630
- WITH pic2rm AS (
636
+ # after the task have been added to the queue, delete the pictures, and db triggers will ensure the correct deletion jobs are added
637
+ nb_updated = cursor.execute(
638
+ """WITH pic2rm AS (
631
639
  SELECT pic_id FROM sequences_pictures WHERE seq_id = %(seq)s
632
640
  ),
633
641
  picWithoutOtherSeq AS (
@@ -635,8 +643,7 @@ def delete_collection(collectionId: UUID, account: Optional[Account]) -> int:
635
643
  EXCEPT
636
644
  SELECT pic_id FROM sequences_pictures WHERE pic_id IN (SELECT pic_id FROM pic2rm) AND seq_id != %(seq)s
637
645
  )
638
- UPDATE pictures SET status = 'waiting-for-delete' WHERE id IN (SELECT pic_id FROM picWithoutOtherSeq)
639
- """,
646
+ DELETE FROM pictures WHERE id IN (SELECT pic_id FROM picWithoutOtherSeq)""",
640
647
  {"seq": collectionId},
641
648
  ).rowcount
642
649
 
geovisio/utils/tokens.py CHANGED
@@ -46,7 +46,7 @@ def get_account_from_jwt_token(jwt_token: str) -> auth.Account:
46
46
  # check token existence
47
47
  records = cursor.execute(
48
48
  """SELECT
49
- t.account_id AS id, a.name, a.oauth_provider, a.oauth_id, a.role
49
+ t.account_id AS id, a.name, a.oauth_provider, a.oauth_id, a.role, a.collaborative_metadata, a.tos_accepted
50
50
  FROM tokens t
51
51
  LEFT OUTER JOIN accounts a ON t.account_id = a.id
52
52
  WHERE t.id = %(token)s""",
@@ -61,11 +61,13 @@ WHERE t.id = %(token)s""",
61
61
  )
62
62
 
63
63
  return auth.Account(
64
- id=str(records["id"]),
64
+ id=records["id"],
65
65
  name=records["name"],
66
66
  oauth_provider=records["oauth_provider"],
67
67
  oauth_id=records["oauth_id"],
68
- role=auth.AccountRole[records["role"]],
68
+ role=auth.AccountRole(records["role"]),
69
+ collaborative_metadata=records["collaborative_metadata"],
70
+ tos_accepted=records["tos_accepted"],
69
71
  )
70
72
 
71
73