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.
- geovisio/__init__.py +11 -3
- geovisio/admin_cli/__init__.py +3 -1
- geovisio/admin_cli/cleanup.py +2 -2
- geovisio/admin_cli/user.py +75 -0
- geovisio/config_app.py +87 -4
- geovisio/templates/main.html +2 -2
- geovisio/templates/viewer.html +3 -3
- geovisio/translations/da/LC_MESSAGES/messages.mo +0 -0
- geovisio/translations/da/LC_MESSAGES/messages.po +850 -0
- geovisio/translations/de/LC_MESSAGES/messages.mo +0 -0
- geovisio/translations/de/LC_MESSAGES/messages.po +235 -2
- geovisio/translations/el/LC_MESSAGES/messages.mo +0 -0
- geovisio/translations/el/LC_MESSAGES/messages.po +685 -0
- geovisio/translations/en/LC_MESSAGES/messages.mo +0 -0
- geovisio/translations/en/LC_MESSAGES/messages.po +244 -153
- geovisio/translations/eo/LC_MESSAGES/messages.mo +0 -0
- geovisio/translations/eo/LC_MESSAGES/messages.po +790 -0
- geovisio/translations/es/LC_MESSAGES/messages.mo +0 -0
- geovisio/translations/fi/LC_MESSAGES/messages.mo +0 -0
- geovisio/translations/fr/LC_MESSAGES/messages.mo +0 -0
- geovisio/translations/fr/LC_MESSAGES/messages.po +40 -3
- geovisio/translations/hu/LC_MESSAGES/messages.mo +0 -0
- geovisio/translations/hu/LC_MESSAGES/messages.po +773 -0
- geovisio/translations/it/LC_MESSAGES/messages.mo +0 -0
- geovisio/translations/it/LC_MESSAGES/messages.po +875 -0
- geovisio/translations/ja/LC_MESSAGES/messages.mo +0 -0
- geovisio/translations/ja/LC_MESSAGES/messages.po +719 -0
- geovisio/translations/ko/LC_MESSAGES/messages.mo +0 -0
- geovisio/translations/messages.pot +225 -148
- geovisio/translations/nl/LC_MESSAGES/messages.mo +0 -0
- geovisio/translations/nl/LC_MESSAGES/messages.po +24 -16
- geovisio/translations/pl/LC_MESSAGES/messages.mo +0 -0
- geovisio/translations/pl/LC_MESSAGES/messages.po +727 -0
- geovisio/translations/zh_Hant/LC_MESSAGES/messages.mo +0 -0
- geovisio/translations/zh_Hant/LC_MESSAGES/messages.po +719 -0
- geovisio/utils/auth.py +80 -8
- geovisio/utils/link.py +3 -2
- geovisio/utils/model_query.py +55 -0
- geovisio/utils/pictures.py +29 -62
- geovisio/utils/semantics.py +120 -0
- geovisio/utils/sequences.py +30 -23
- geovisio/utils/tokens.py +5 -3
- geovisio/utils/upload_set.py +87 -64
- geovisio/utils/website.py +50 -0
- geovisio/web/annotations.py +17 -0
- geovisio/web/auth.py +9 -5
- geovisio/web/collections.py +235 -63
- geovisio/web/configuration.py +17 -1
- geovisio/web/docs.py +99 -54
- geovisio/web/items.py +233 -100
- geovisio/web/map.py +129 -31
- geovisio/web/pages.py +240 -0
- geovisio/web/params.py +17 -0
- geovisio/web/prepare.py +165 -0
- geovisio/web/stac.py +17 -4
- geovisio/web/tokens.py +14 -4
- geovisio/web/upload_set.py +19 -10
- geovisio/web/users.py +176 -44
- geovisio/workers/runner_pictures.py +75 -50
- {geovisio-2.7.0.dist-info → geovisio-2.8.0.dist-info}/METADATA +6 -5
- geovisio-2.8.0.dist-info/RECORD +89 -0
- {geovisio-2.7.0.dist-info → geovisio-2.8.0.dist-info}/WHEEL +1 -1
- geovisio-2.7.0.dist-info/RECORD +0 -66
- {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:
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
geovisio/utils/pictures.py
CHANGED
|
@@ -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(
|
|
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
|
-
|
|
569
|
-
|
|
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
|
|
681
|
-
|
|
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
|
|
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
|
+
)
|
geovisio/utils/sequences.py
CHANGED
|
@@ -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
|
|
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
|
|
602
|
-
# - Then a query
|
|
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
|
-
#
|
|
605
|
-
#
|
|
606
|
-
|
|
607
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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=
|
|
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
|
|
68
|
+
role=auth.AccountRole(records["role"]),
|
|
69
|
+
collaborative_metadata=records["collaborative_metadata"],
|
|
70
|
+
tos_accepted=records["tos_accepted"],
|
|
69
71
|
)
|
|
70
72
|
|
|
71
73
|
|