geovisio 2.8.1__py3-none-any.whl → 2.10.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 (70) hide show
  1. geovisio/__init__.py +6 -1
  2. geovisio/config_app.py +16 -5
  3. geovisio/translations/ar/LC_MESSAGES/messages.mo +0 -0
  4. geovisio/translations/ar/LC_MESSAGES/messages.po +818 -0
  5. geovisio/translations/br/LC_MESSAGES/messages.po +1 -1
  6. geovisio/translations/da/LC_MESSAGES/messages.mo +0 -0
  7. geovisio/translations/da/LC_MESSAGES/messages.po +4 -3
  8. geovisio/translations/de/LC_MESSAGES/messages.mo +0 -0
  9. geovisio/translations/de/LC_MESSAGES/messages.po +55 -2
  10. geovisio/translations/el/LC_MESSAGES/messages.po +1 -1
  11. geovisio/translations/en/LC_MESSAGES/messages.mo +0 -0
  12. geovisio/translations/en/LC_MESSAGES/messages.po +193 -139
  13. geovisio/translations/eo/LC_MESSAGES/messages.mo +0 -0
  14. geovisio/translations/eo/LC_MESSAGES/messages.po +53 -4
  15. geovisio/translations/es/LC_MESSAGES/messages.po +1 -1
  16. geovisio/translations/fi/LC_MESSAGES/messages.po +1 -1
  17. geovisio/translations/fr/LC_MESSAGES/messages.mo +0 -0
  18. geovisio/translations/fr/LC_MESSAGES/messages.po +101 -6
  19. geovisio/translations/hu/LC_MESSAGES/messages.po +1 -1
  20. geovisio/translations/it/LC_MESSAGES/messages.mo +0 -0
  21. geovisio/translations/it/LC_MESSAGES/messages.po +63 -3
  22. geovisio/translations/ja/LC_MESSAGES/messages.po +1 -1
  23. geovisio/translations/ko/LC_MESSAGES/messages.po +1 -1
  24. geovisio/translations/messages.pot +185 -129
  25. geovisio/translations/nl/LC_MESSAGES/messages.mo +0 -0
  26. geovisio/translations/nl/LC_MESSAGES/messages.po +421 -86
  27. geovisio/translations/oc/LC_MESSAGES/messages.mo +0 -0
  28. geovisio/translations/oc/LC_MESSAGES/messages.po +818 -0
  29. geovisio/translations/pl/LC_MESSAGES/messages.po +1 -1
  30. geovisio/translations/sv/LC_MESSAGES/messages.mo +0 -0
  31. geovisio/translations/sv/LC_MESSAGES/messages.po +823 -0
  32. geovisio/translations/ti/LC_MESSAGES/messages.mo +0 -0
  33. geovisio/translations/ti/LC_MESSAGES/messages.po +762 -0
  34. geovisio/translations/zh_Hant/LC_MESSAGES/messages.po +1 -1
  35. geovisio/utils/annotations.py +183 -0
  36. geovisio/utils/auth.py +14 -13
  37. geovisio/utils/cql2.py +134 -0
  38. geovisio/utils/db.py +7 -0
  39. geovisio/utils/fields.py +38 -9
  40. geovisio/utils/items.py +44 -0
  41. geovisio/utils/model_query.py +4 -4
  42. geovisio/utils/pic_shape.py +63 -0
  43. geovisio/utils/pictures.py +164 -29
  44. geovisio/utils/reports.py +10 -17
  45. geovisio/utils/semantics.py +196 -57
  46. geovisio/utils/sentry.py +1 -2
  47. geovisio/utils/sequences.py +191 -93
  48. geovisio/utils/tags.py +31 -0
  49. geovisio/utils/upload_set.py +287 -209
  50. geovisio/utils/website.py +1 -1
  51. geovisio/web/annotations.py +346 -9
  52. geovisio/web/auth.py +1 -1
  53. geovisio/web/collections.py +73 -54
  54. geovisio/web/configuration.py +26 -5
  55. geovisio/web/docs.py +143 -11
  56. geovisio/web/items.py +232 -155
  57. geovisio/web/map.py +25 -13
  58. geovisio/web/params.py +55 -52
  59. geovisio/web/pictures.py +34 -0
  60. geovisio/web/stac.py +19 -12
  61. geovisio/web/tokens.py +49 -1
  62. geovisio/web/upload_set.py +148 -37
  63. geovisio/web/users.py +4 -4
  64. geovisio/web/utils.py +2 -2
  65. geovisio/workers/runner_pictures.py +190 -24
  66. {geovisio-2.8.1.dist-info → geovisio-2.10.0.dist-info}/METADATA +27 -26
  67. geovisio-2.10.0.dist-info/RECORD +105 -0
  68. {geovisio-2.8.1.dist-info → geovisio-2.10.0.dist-info}/WHEEL +1 -1
  69. geovisio-2.8.1.dist-info/RECORD +0 -92
  70. {geovisio-2.8.1.dist-info → geovisio-2.10.0.dist-info}/licenses/LICENSE +0 -0
@@ -1,6 +1,8 @@
1
+ import json
1
2
  import math
2
- from typing import Dict, Optional
3
+ from typing import Any, Dict, List, Optional, Tuple
3
4
  from uuid import UUID
5
+ from attr import dataclass
4
6
  from flask import current_app, redirect, send_file
5
7
  from flask_babel import gettext as _
6
8
  import os
@@ -13,14 +15,28 @@ import logging
13
15
  from dataclasses import asdict
14
16
  from fs.path import dirname
15
17
  from psycopg.errors import UniqueViolation, InvalidParameterValue
18
+ from psycopg.types.json import Jsonb
19
+ from psycopg import sql, Connection
20
+ import sentry_sdk
16
21
  from geovisio import utils, errors
17
22
  from geopic_tag_reader import reader
18
23
  import re
24
+ import multipart
25
+
26
+ from geovisio.utils import db
19
27
 
20
28
  log = logging.getLogger(__name__)
21
29
 
22
30
 
23
- def createBlurredHDPicture(fs, blurApi, pictureBytes, outputFilename):
31
+ @dataclass
32
+ class BlurredPicture:
33
+ """Blurred picture's response"""
34
+
35
+ image: Image
36
+ metadata: Dict[str, str] = {}
37
+
38
+
39
+ def createBlurredHDPicture(fs, blurApi, pictureBytes, outputFilename, keep_unblured_parts=False) -> Optional[BlurredPicture]:
24
40
  """Create the blurred version of a picture using a blurMask
25
41
 
26
42
  Parameters
@@ -39,20 +55,49 @@ def createBlurredHDPicture(fs, blurApi, pictureBytes, outputFilename):
39
55
  PIL.Image
40
56
  The blurred version of the image
41
57
  """
58
+ if blurApi is None:
59
+ return None
60
+ # Call blur API, asking for multipart response if available
61
+ pictureBytes.seek(0)
62
+ query_params = {"keep": 1} if keep_unblured_parts else {}
63
+ blurResponse = requests.post(
64
+ f"{blurApi}/blur/",
65
+ files={"picture": ("picture.jpg", pictureBytes.read(), "image/jpeg")},
66
+ headers={"Accept": "multipart/form-data"},
67
+ params=query_params,
68
+ )
69
+ blurResponse.raise_for_status()
70
+
71
+ metadata, blurred_pic = None, None
72
+ content_type, content_type_params = multipart.parse_options_header(blurResponse.headers.get("content-type", ""))
73
+ if content_type == "multipart/form-data":
74
+ # New blurring api can return multipart response, with separated blurring picture/metadata
75
+ multipart_response = multipart.MultipartParser(io.BytesIO(blurResponse.content), boundary=content_type_params["boundary"])
76
+
77
+ metadata = multipart_response.get("metadata")
78
+ if metadata:
79
+ metadata = metadata.raw
80
+ blurred_pic = multipart_response.get("image")
81
+ if blurred_pic:
82
+ blurred_pic = blurred_pic.raw
83
+ else:
84
+ # old blurring API, no multipart response, we read the `x-sgblur` header
85
+ blurred_pic = blurResponse.content
86
+ metadata = blurResponse.headers.get("x-sgblur")
42
87
 
43
- if blurApi is not None:
44
- # Call blur API
45
- pictureBytes.seek(0)
46
- blurResponse = requests.post(blurApi + "/blur/", files={"picture": ("picture.jpg", pictureBytes.read(), "image/jpeg")})
47
- blurResponse.raise_for_status()
48
-
49
- # Save mask to FS
50
- fs.writebytes(outputFilename, blurResponse.content)
88
+ # Save mask to FS
89
+ fs.writebytes(outputFilename, blurred_pic)
51
90
 
52
- return Image.open(io.BytesIO(blurResponse.content))
91
+ if metadata:
92
+ try:
93
+ metadata = json.loads(metadata)
94
+ except (json.decoder.JSONDecodeError, TypeError) as e:
95
+ # we skip the metadata's response if we are not able to understand it
96
+ log.warning(f"Impossible to parse blurring metadata API response: {e}")
97
+ sentry_sdk.capture_exception(e)
98
+ metadata = None
53
99
 
54
- else:
55
- return None
100
+ return BlurredPicture(image=Image.open(io.BytesIO(blurred_pic)), metadata=metadata)
56
101
 
57
102
 
58
103
  def getTileSize(imgSize):
@@ -511,6 +556,25 @@ class MetadataReadingError(Exception):
511
556
  self.missing_mandatory_tags = missing_mandatory_tags
512
557
 
513
558
 
559
+ def get_lighter_metadata(metadata):
560
+ """Create a lighter metadata field to remove duplicates fields"""
561
+ lighterMetadata = dict(
562
+ filter(
563
+ lambda v: v[0] not in ["ts", "heading", "lon", "lat", "exif", "originalContentMd5", "ts_by_source", "gps_accuracy"],
564
+ metadata.items(),
565
+ )
566
+ )
567
+ if lighterMetadata.get("tagreader_warnings") is not None and len(lighterMetadata["tagreader_warnings"]) == 0:
568
+ del lighterMetadata["tagreader_warnings"]
569
+ lighterMetadata["tz"] = metadata["ts"].tzname()
570
+ if metadata.get("ts_by_source", {}).get("gps") is not None:
571
+ lighterMetadata["ts_gps"] = metadata["ts_by_source"]["gps"].isoformat()
572
+ if metadata.get("ts_by_source", {}).get("camera") is not None:
573
+ lighterMetadata["ts_camera"] = metadata["ts_by_source"]["camera"].isoformat()
574
+
575
+ return lighterMetadata
576
+
577
+
514
578
  def insertNewPictureInDatabase(
515
579
  db, sequenceId, position, pictureBytes, associatedAccountID, additionalMetadata, uploadSetID=None, lang="en"
516
580
  ):
@@ -537,11 +601,10 @@ def insertNewPictureInDatabase(
537
601
  -------
538
602
  uuid : The uuid of the new picture entry in the database
539
603
  """
540
- from psycopg.types.json import Jsonb
541
604
 
542
605
  # Create a fully-featured metadata object
543
- picturePillow = Image.open(io.BytesIO(pictureBytes))
544
- metadata = readPictureMetadata(pictureBytes, lang) | utils.pictures.getPictureSizing(picturePillow) | additionalMetadata
606
+ with Image.open(io.BytesIO(pictureBytes)) as picturePillow:
607
+ metadata = readPictureMetadata(pictureBytes, lang) | utils.pictures.getPictureSizing(picturePillow) | additionalMetadata
545
608
 
546
609
  # Remove cols/rows information for flat pictures
547
610
  if metadata["type"] == "flat":
@@ -549,19 +612,7 @@ def insertNewPictureInDatabase(
549
612
  metadata.pop("rows")
550
613
 
551
614
  # Create a lighter metadata field to remove duplicates fields
552
- lighterMetadata = dict(
553
- filter(
554
- lambda v: v[0] not in ["ts", "heading", "lon", "lat", "exif", "originalContentMd5", "ts_by_source", "gps_accuracy"],
555
- metadata.items(),
556
- )
557
- )
558
- if lighterMetadata.get("tagreader_warnings") is not None and len(lighterMetadata["tagreader_warnings"]) == 0:
559
- del lighterMetadata["tagreader_warnings"]
560
- lighterMetadata["tz"] = metadata["ts"].tzname()
561
- if metadata.get("ts_by_source", {}).get("gps") is not None:
562
- lighterMetadata["ts_gps"] = metadata["ts_by_source"]["gps"].isoformat()
563
- if metadata.get("ts_by_source", {}).get("camera") is not None:
564
- lighterMetadata["ts_camera"] = metadata["ts_by_source"]["camera"].isoformat()
615
+ lighterMetadata = get_lighter_metadata(metadata)
565
616
 
566
617
  exif = cleanupExif(metadata["exif"])
567
618
 
@@ -597,6 +648,90 @@ def insertNewPictureInDatabase(
597
648
  return picId
598
649
 
599
650
 
651
+ def _get_metadata_to_update(db_picture: Dict, new_reader_metadata: reader.GeoPicTags) -> Tuple[List[str], Dict[str, Any]]:
652
+ fields_to_update = []
653
+ params = {}
654
+
655
+ if new_reader_metadata.ts != db_picture["ts"]:
656
+ fields_to_update.append(sql.SQL("ts = %(ts)s"))
657
+ params["ts"] = new_reader_metadata.ts.isoformat()
658
+ if db_picture["heading_computed"] is False and new_reader_metadata.heading != db_picture["heading"]:
659
+ fields_to_update.append(sql.SQL("heading = %(heading)s"))
660
+ params["heading"] = new_reader_metadata.heading
661
+ if new_reader_metadata.gps_accuracy != db_picture["gps_accuracy_m"]:
662
+ fields_to_update.append(sql.SQL("gps_accuracy_m = %(gps_accuracy_m)s"))
663
+ params["gps_accuracy_m"] = new_reader_metadata.gps_accuracy
664
+
665
+ # Note: The db metadata can have more stuff (like originalFileName, size, ...), we so only check if the new value is different from the old one
666
+ # we cannot check directly for dict equality
667
+ new_lighterMetadata = get_lighter_metadata(asdict(new_reader_metadata))
668
+ metadata_updates = {}
669
+ for k, v in new_lighterMetadata.items():
670
+ if v != db_picture["metadata"].get(k):
671
+ metadata_updates[k] = v
672
+
673
+ # if the position has been updated (by more than ~10cm)
674
+ lon, lat = db_picture["lon"], db_picture["lat"]
675
+ new_lon, new_lat = new_reader_metadata.lon, new_reader_metadata.lat
676
+ if not math.isclose(lon, new_lon, abs_tol=0.0000001) or not math.isclose(lat, new_lat, abs_tol=0.0000001):
677
+ fields_to_update.append(sql.SQL("geom = ST_SetSRID(ST_MakePoint(%(lon)s, %(lat)s), 4326)"))
678
+ params["lon"] = new_reader_metadata.lon
679
+ params["lat"] = new_reader_metadata.lat
680
+
681
+ if metadata_updates:
682
+ fields_to_update.append(sql.SQL("metadata = metadata || %(new_metadata)s"))
683
+ params["new_metadata"] = Jsonb(metadata_updates)
684
+
685
+ return fields_to_update, params
686
+
687
+
688
+ def ask_for_metadata_update(picture_id: UUID, read_file=False):
689
+ """Enqueue an async job to reread the picture's metadata"""
690
+ args = Jsonb({"read_file": True}) if read_file else None
691
+ with db.conn(current_app) as conn:
692
+ conn.execute(
693
+ "INSERT INTO job_queue(picture_id, task, args) VALUES (%s, 'read_metadata', %s)",
694
+ [picture_id, args],
695
+ )
696
+
697
+
698
+ def update_picture_metadata(conn: Connection, picture_id: UUID, read_file=False) -> bool:
699
+ """Update picture metadata in database, using either the stored metadata or the original file
700
+
701
+ Only updates metadata that have changed.
702
+ Returns True if some metadata have been updated, False otherwise
703
+ """
704
+
705
+ with conn.cursor(row_factory=dict_row) as cursor:
706
+ db_picture = cursor.execute(
707
+ "SELECT ts, heading, metadata, ST_X(geom) as lon, ST_Y(geom) as lat, account_id, exif, gps_accuracy_m, heading_computed FROM pictures WHERE id = %s",
708
+ [picture_id],
709
+ ).fetchone()
710
+ if db_picture is None:
711
+ raise Exception(f"Picture {picture_id} not found")
712
+
713
+ if read_file:
714
+ pic_path = getHDPicturePath(picture_id)
715
+
716
+ with current_app.config["FILESYSTEMS"].permanent.openbin(pic_path) as picture_bytes:
717
+ new_metadata = reader.readPictureMetadata(picture_bytes.read())
718
+ else:
719
+ new_metadata = reader.getPictureMetadata(db_picture["exif"], db_picture["metadata"]["width"], db_picture["metadata"]["height"])
720
+
721
+ # we want to only updates values that have changed
722
+ fields_to_update, params = _get_metadata_to_update(db_picture, new_metadata)
723
+
724
+ if not fields_to_update:
725
+ logging.debug(f"No metadata update needed for picture {picture_id}")
726
+ return False
727
+
728
+ conn.execute(
729
+ sql.SQL("UPDATE pictures SET {f} WHERE id = %(pic_id)s").format(f=sql.SQL(", ").join(fields_to_update)),
730
+ params | {"pic_id": picture_id},
731
+ )
732
+ return True
733
+
734
+
600
735
  # 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)
601
736
  # This list has been queried from real data (cf [this comment](https://gitlab.com/panoramax/server/api/-/merge_requests/241#note_1790580636)).
602
737
  # Update this list (and do a sql migration) if new binary fields are added
geovisio/utils/reports.py CHANGED
@@ -4,7 +4,7 @@ from typing import Optional, List
4
4
  from typing_extensions import Self
5
5
  from datetime import datetime
6
6
  from pydantic import BaseModel, ConfigDict
7
- from geovisio.utils import db
7
+ from geovisio.utils import cql2, db
8
8
  from geovisio.errors import InvalidAPIUsage
9
9
  from flask import current_app
10
10
  from psycopg.sql import SQL
@@ -103,6 +103,13 @@ def is_picture_owner(report: Report, account_id: UUID):
103
103
  return isOwner
104
104
 
105
105
 
106
+ REPORT_FILTER_TO_DB_FIELDS = {
107
+ "status": "r.status",
108
+ "reporter": "reporter_account_id",
109
+ "owner": "COALESCE(p.account_id, s.account_id)",
110
+ }
111
+
112
+
106
113
  def _parse_filter(filter: Optional[str]) -> SQL:
107
114
  """
108
115
  Parse a filter string and return a SQL expression
@@ -124,22 +131,8 @@ def _parse_filter(filter: Optional[str]) -> SQL:
124
131
  """
125
132
  if not filter:
126
133
  return SQL("TRUE")
127
- from pygeofilter.backends.sql import to_sql_where
128
- from pygeofilter.parsers.ecql import parse as ecql_parser
129
-
130
- try:
131
- filterAst = ecql_parser(filter)
132
- fieldsToFilter = {
133
- "status": "r.status",
134
- "reporter": "reporter_account_id",
135
- "owner": "COALESCE(p.account_id, s.account_id)",
136
- }
137
-
138
- f = to_sql_where(filterAst, fieldsToFilter).replace('"', "").replace("'me'", "%(account_id)s") # type: ignore
139
- return SQL(f) # type: ignore
140
- except Exception as e:
141
- print(e)
142
- raise InvalidAPIUsage(_("Unsupported filter parameter"), status_code=400)
134
+
135
+ return cql2.parse_cql2_filter(filter, REPORT_FILTER_TO_DB_FIELDS)
143
136
 
144
137
 
145
138
  def list_reports(account_id: UUID, limit: int = 100, filter: Optional[str] = None, forceAccount: bool = True) -> Reports:
@@ -1,42 +1,20 @@
1
+ from ast import Dict
2
+ from collections import defaultdict
1
3
  from dataclasses import dataclass
4
+ import re
2
5
  from uuid import UUID
3
- from psycopg import Cursor
4
- from psycopg.sql import SQL, Identifier
6
+ from psycopg import Connection, Cursor
7
+ from psycopg.sql import SQL, Identifier, Placeholder
5
8
  from psycopg.types.json import Jsonb
6
9
  from psycopg.errors import UniqueViolation
7
- from pydantic import BaseModel, ConfigDict, Field
8
- from typing import List
10
+ from psycopg.rows import dict_row
11
+ from typing import Generator, List, Optional
9
12
  from enum import Enum
10
13
 
11
14
  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"""
15
+ from geovisio.utils.annotations import Annotation, get_picture_annotations
16
+ from geovisio.utils.pic_shape import Geometry
17
+ from geovisio.utils.tags import SemanticTag, SemanticTagUpdate, TagAction
40
18
 
41
19
 
42
20
  class EntityType(Enum):
@@ -44,9 +22,7 @@ class EntityType(Enum):
44
22
  pic = "picture_id"
45
23
  seq = "sequence_id"
46
24
  annotation = "annotation_id"
47
-
48
- def entitiy_id_field(self) -> Identifier:
49
- return Identifier(self.value)
25
+ upload_set = "upload_set_id"
50
26
 
51
27
 
52
28
  @dataclass
@@ -62,59 +38,222 @@ class Entity:
62
38
  return Identifier("sequences_semantics")
63
39
  case EntityType.annotation:
64
40
  return Identifier("annotations_semantics")
41
+ case EntityType.upload_set:
42
+ return Identifier("upload_sets_semantics")
65
43
  case _:
66
44
  raise ValueError(f"Unknown entity type: {self.type}")
67
45
 
68
- def get_history_table(self) -> Identifier:
46
+ def get_history_table(self) -> Optional[Identifier]:
69
47
  match self.type:
70
48
  case EntityType.pic:
71
49
  return Identifier("pictures_semantics_history")
72
50
  case EntityType.seq:
73
51
  return Identifier("sequences_semantics_history")
74
52
  case EntityType.annotation:
75
- return Identifier("annotations_semantics_history")
53
+ return Identifier("pictures_semantics_history")
54
+ case EntityType.upload_set:
55
+ return None
76
56
  case _:
77
57
  raise ValueError(f"Unknown entity type: {self.type}")
78
58
 
79
59
 
80
- def update_tags(cursor: Cursor, entity: Entity, actions: List[SemanticTagUpdate], account: UUID) -> SemanticTag:
60
+ def update_tags(cursor: Cursor, entity: Entity, actions: List[SemanticTagUpdate], account: UUID, annotation=None):
81
61
  """Update tags for an entity
82
62
  Note: this should be done inside an autocommit transaction
83
63
  """
84
64
  table_name = entity.get_table()
85
- fields = [entity.type.entitiy_id_field(), Identifier("key"), Identifier("value")]
86
65
  tag_to_add = [t for t in actions if t.action == TagAction.add]
87
66
  tag_to_delete = [t for t in actions if t.action == TagAction.delete]
88
67
  try:
89
68
  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))
69
+ filter_query = []
70
+ params = [entity.id]
71
+ for tag in tag_to_delete:
72
+ filter_query.append(SQL("(key = %s AND value = %s)"))
73
+ params.append(tag.key)
74
+ params.append(tag.value)
75
+
94
76
  cursor.execute(
95
77
  SQL(
96
78
  """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]},
79
+ WHERE {entity_id} = %s
80
+ AND ({filter})"""
81
+ ).format(table=table_name, entity_id=Identifier(entity.type.value), filter=SQL(" OR ").join(filter_query)),
82
+ params,
103
83
  )
104
84
  if tag_to_add:
105
- with cursor.copy(SQL("COPY {table} ({fields}) FROM STDIN").format(table=table_name, fields=SQL(",").join(fields))) as copy:
85
+ fields = [Identifier(entity.type.value), Identifier("key"), Identifier("value")]
86
+ if entity.type == EntityType.upload_set:
87
+ # upload_set semantics have no history, the account is directly stored in the table
88
+ fields.append(Identifier("account_id"))
89
+
90
+ with cursor.copy(
91
+ SQL("COPY {table} ({fields}) FROM STDIN").format(
92
+ table=table_name,
93
+ fields=SQL(",").join(fields),
94
+ )
95
+ ) as copy:
106
96
  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
97
+ row = [entity.id, tag.key, tag.value]
98
+ if entity.type == EntityType.upload_set:
99
+ row.append(account)
100
+ copy.write_row(row)
101
+ if tag_to_delete and entity.type == EntityType.annotation and not tag_to_add:
102
+ # if tags have been deleted, we check if some annotations are now empty and need to be deleted
110
103
  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])},
104
+ """DELETE FROM annotations
105
+ WHERE id = %(annotation_id)s AND
106
+ (
107
+ SELECT count(*) AS nb_semantics
108
+ FROM annotations_semantics
109
+ WHERE annotation_id = %(annotation_id)s
110
+ ) = 0""",
111
+ {"annotation_id": entity.id},
115
112
  )
113
+ if tag_to_add or tag_to_delete:
114
+ # we track the history changes of the semantic tags
115
+ track_semantic_history(cursor, entity, actions, account, annotation)
116
116
  except UniqueViolation as e:
117
117
  # if the tag already exists, we don't want to add it again
118
118
  raise errors.InvalidAPIUsage(
119
119
  "Impossible to add semantic tags because of duplicates", payload={"details": {"duplicate": e.diag.message_detail}}
120
120
  )
121
+
122
+
123
+ class SemanticTagUpdateOnAnnotation(SemanticTagUpdate):
124
+ annotation_shape: Geometry
125
+
126
+
127
+ def track_semantic_history(cursor: Cursor, entity: Entity, actions: List[SemanticTagUpdate], account: UUID, annotation):
128
+ history_table = entity.get_history_table()
129
+ if history_table is None:
130
+ # no history for upload_set semantics
131
+ return
132
+ params = {
133
+ "account_id": account,
134
+ }
135
+ if annotation is not None:
136
+ # the annotations are historized in the pictures_semantics_history table
137
+ # and additional information about the annotation.
138
+ # This makes it easier to track annotations deletions
139
+ params["picture_id"] = annotation.picture_id
140
+
141
+ params["updates"] = Jsonb(
142
+ [
143
+ SemanticTagUpdateOnAnnotation(action=t.action, key=t.key, value=t.value, annotation_shape=annotation.shape).model_dump()
144
+ for t in actions
145
+ ]
146
+ )
147
+ else:
148
+ params[entity.type.value] = entity.id
149
+ params["updates"] = Jsonb([t.model_dump() for t in actions])
150
+
151
+ sql = SQL("INSERT INTO {history_table} ({fields}) VALUES ({values})").format(
152
+ history_table=entity.get_history_table(),
153
+ fields=SQL(", ").join([Identifier(k) for k in params.keys()]),
154
+ values=SQL(", ").join([Placeholder(k) for k in params.keys()]),
155
+ )
156
+ cursor.execute(sql, params)
157
+
158
+
159
+ def delete_annotation_tags_from_service(conn: Connection, picture_id: UUID, service_name: str, account: UUID) -> List[Dict]:
160
+ """Delete all tags from a blurring service on a given picture"""
161
+ annotations_tags = list(get_annotation_tags_from_service(conn, picture_id, service_name))
162
+
163
+ with conn.transaction(), conn.cursor(row_factory=dict_row) as cursor:
164
+ for a in annotations_tags:
165
+ actions = [SemanticTagUpdate(action=TagAction.delete, key=t.key, value=t.value) for t in a.semantics]
166
+ entity = Entity(id=a.id, type=EntityType.annotation)
167
+ update_tags(cursor, entity, actions, account, annotation=a)
168
+
169
+ return annotations_tags
170
+
171
+
172
+ QUALIFIER_REGEXP = re.compile(r"^(?P<qualifier>[^\[]*)\[(?P<key>[^=]+)(=(?P<value>.*))?\]$")
173
+
174
+
175
+ @dataclass
176
+ class QualifierSemantic:
177
+ qualifier: str
178
+ associated_key: str
179
+ associated_value: Optional[str]
180
+ raw_tag: SemanticTag
181
+
182
+ def qualifies(self, semantic_tag: SemanticTag) -> bool:
183
+ """Check if a semantic tag is qualified by the qualifier"""
184
+ if semantic_tag.key != self.associated_key:
185
+ return False
186
+ if self.associated_value is None or self.associated_value == "*":
187
+ return True
188
+ return semantic_tag.value == self.associated_value
189
+
190
+
191
+ def as_qualifier(s: SemanticTag) -> Optional[QualifierSemantic]:
192
+ """Try to convert a semantic tag into a qualifier"""
193
+ m = QUALIFIER_REGEXP.search(s.key)
194
+ if m:
195
+ return QualifierSemantic(
196
+ qualifier=m.group("qualifier"),
197
+ associated_key=m.group("key"),
198
+ associated_value=m.group("value"),
199
+ raw_tag=s,
200
+ )
201
+
202
+
203
+ def get_qualifiers(semantics: List[SemanticTag]) -> List[QualifierSemantic]:
204
+ """Find all qualifiers in a list of semantic tags"""
205
+ res = []
206
+ for s in semantics:
207
+ q = as_qualifier(s)
208
+ if q is not None:
209
+ res.append(q)
210
+ return res
211
+
212
+
213
+ def find_detection_model_tags(qualifiers: List[QualifierSemantic], service_name: str) -> List[QualifierSemantic]:
214
+ """Find all detection models associated to a picture, from a given service"""
215
+ res = []
216
+ for q in qualifiers:
217
+ if not q.raw_tag.value.startswith(f"{service_name}-"):
218
+ continue
219
+ if q.qualifier != "detection_model":
220
+ continue
221
+ res.append(q)
222
+ return res
223
+
224
+
225
+ def find_semantics_from_service(annotation, service_name: str) -> Generator[SemanticTag, None, None]:
226
+ """Find all semantics tags related from a given bluring service
227
+
228
+ The blurring service will add a `detection_model` qualifier with a value starting by its name (like `SGBlur-yolo11n/0.1.0` for `SGBlur`)
229
+
230
+ This method will return all linked semantics tags, and all their qualifiers.
231
+ """
232
+ qualifiers = get_qualifiers(annotation.semantics)
233
+ detection_model_tags = find_detection_model_tags(qualifiers, service_name)
234
+ qualified_tags = []
235
+ for s in annotation.semantics:
236
+ for qualifier_tag in detection_model_tags:
237
+ if qualifier_tag.qualifies(s):
238
+ qualified_tags.append(s)
239
+ break
240
+
241
+ # we then have to get all qualifiers on those tags
242
+ related_qualifiers = []
243
+ for q in qualifiers:
244
+ for t in qualified_tags:
245
+ if q.qualifies(t):
246
+ related_qualifiers.append(q.raw_tag)
247
+ break
248
+
249
+ return qualified_tags + related_qualifiers
250
+
251
+
252
+ def get_annotation_tags_from_service(conn: Connection, picture_id: UUID, service_name: str) -> Generator[Annotation, None, None]:
253
+ """Get all annotations semantics from a blurring service"""
254
+
255
+ annotations = get_picture_annotations(conn, picture_id)
256
+
257
+ for a in annotations:
258
+ semantics = [s for s in find_semantics_from_service(a, service_name)]
259
+ yield Annotation(id=a.id, picture_id=a.picture_id, shape=a.shape, semantics=semantics)
geovisio/utils/sentry.py CHANGED
@@ -60,7 +60,6 @@ def _wrap_cursor_execute(f):
60
60
  return f(self, query, params, prepare=prepare, binary=binary)
61
61
 
62
62
  with record_sql_queries(
63
- hub=hub,
64
63
  cursor=self,
65
64
  query=query,
66
65
  params_list=params,
@@ -74,7 +73,7 @@ def _wrap_cursor_execute(f):
74
73
 
75
74
 
76
75
  class FileSystemIntegration(Integration):
77
- """Add metrics to the 2 most usefull filesystem, the 'os file' filesystem and the s3 filesystem"""
76
+ """Add metrics to the 2 most useful filesystem, the 'os file' filesystem and the s3 filesystem"""
78
77
 
79
78
  identifier = "filesystem"
80
79