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.
- geovisio/__init__.py +6 -1
- geovisio/config_app.py +16 -5
- geovisio/translations/ar/LC_MESSAGES/messages.mo +0 -0
- geovisio/translations/ar/LC_MESSAGES/messages.po +818 -0
- geovisio/translations/br/LC_MESSAGES/messages.po +1 -1
- geovisio/translations/da/LC_MESSAGES/messages.mo +0 -0
- geovisio/translations/da/LC_MESSAGES/messages.po +4 -3
- geovisio/translations/de/LC_MESSAGES/messages.mo +0 -0
- geovisio/translations/de/LC_MESSAGES/messages.po +55 -2
- geovisio/translations/el/LC_MESSAGES/messages.po +1 -1
- geovisio/translations/en/LC_MESSAGES/messages.mo +0 -0
- geovisio/translations/en/LC_MESSAGES/messages.po +193 -139
- geovisio/translations/eo/LC_MESSAGES/messages.mo +0 -0
- geovisio/translations/eo/LC_MESSAGES/messages.po +53 -4
- geovisio/translations/es/LC_MESSAGES/messages.po +1 -1
- geovisio/translations/fi/LC_MESSAGES/messages.po +1 -1
- geovisio/translations/fr/LC_MESSAGES/messages.mo +0 -0
- geovisio/translations/fr/LC_MESSAGES/messages.po +101 -6
- geovisio/translations/hu/LC_MESSAGES/messages.po +1 -1
- geovisio/translations/it/LC_MESSAGES/messages.mo +0 -0
- geovisio/translations/it/LC_MESSAGES/messages.po +63 -3
- geovisio/translations/ja/LC_MESSAGES/messages.po +1 -1
- geovisio/translations/ko/LC_MESSAGES/messages.po +1 -1
- geovisio/translations/messages.pot +185 -129
- geovisio/translations/nl/LC_MESSAGES/messages.mo +0 -0
- geovisio/translations/nl/LC_MESSAGES/messages.po +421 -86
- geovisio/translations/oc/LC_MESSAGES/messages.mo +0 -0
- geovisio/translations/oc/LC_MESSAGES/messages.po +818 -0
- geovisio/translations/pl/LC_MESSAGES/messages.po +1 -1
- geovisio/translations/sv/LC_MESSAGES/messages.mo +0 -0
- geovisio/translations/sv/LC_MESSAGES/messages.po +823 -0
- geovisio/translations/ti/LC_MESSAGES/messages.mo +0 -0
- geovisio/translations/ti/LC_MESSAGES/messages.po +762 -0
- geovisio/translations/zh_Hant/LC_MESSAGES/messages.po +1 -1
- geovisio/utils/annotations.py +183 -0
- geovisio/utils/auth.py +14 -13
- geovisio/utils/cql2.py +134 -0
- geovisio/utils/db.py +7 -0
- geovisio/utils/fields.py +38 -9
- geovisio/utils/items.py +44 -0
- geovisio/utils/model_query.py +4 -4
- geovisio/utils/pic_shape.py +63 -0
- geovisio/utils/pictures.py +164 -29
- geovisio/utils/reports.py +10 -17
- geovisio/utils/semantics.py +196 -57
- geovisio/utils/sentry.py +1 -2
- geovisio/utils/sequences.py +191 -93
- geovisio/utils/tags.py +31 -0
- geovisio/utils/upload_set.py +287 -209
- geovisio/utils/website.py +1 -1
- geovisio/web/annotations.py +346 -9
- geovisio/web/auth.py +1 -1
- geovisio/web/collections.py +73 -54
- geovisio/web/configuration.py +26 -5
- geovisio/web/docs.py +143 -11
- geovisio/web/items.py +232 -155
- geovisio/web/map.py +25 -13
- geovisio/web/params.py +55 -52
- geovisio/web/pictures.py +34 -0
- geovisio/web/stac.py +19 -12
- geovisio/web/tokens.py +49 -1
- geovisio/web/upload_set.py +148 -37
- geovisio/web/users.py +4 -4
- geovisio/web/utils.py +2 -2
- geovisio/workers/runner_pictures.py +190 -24
- {geovisio-2.8.1.dist-info → geovisio-2.10.0.dist-info}/METADATA +27 -26
- geovisio-2.10.0.dist-info/RECORD +105 -0
- {geovisio-2.8.1.dist-info → geovisio-2.10.0.dist-info}/WHEEL +1 -1
- geovisio-2.8.1.dist-info/RECORD +0 -92
- {geovisio-2.8.1.dist-info → geovisio-2.10.0.dist-info}/licenses/LICENSE +0 -0
geovisio/utils/upload_set.py
CHANGED
|
@@ -6,7 +6,8 @@ from geovisio.utils.extent import TemporalExtent
|
|
|
6
6
|
from uuid import UUID
|
|
7
7
|
from typing import Optional, List, Dict, Any
|
|
8
8
|
from datetime import datetime, timedelta
|
|
9
|
-
from
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from geovisio.utils import cql2, db, sequences
|
|
10
11
|
from geovisio import errors
|
|
11
12
|
from geovisio.utils.link import make_link, Link
|
|
12
13
|
import psycopg
|
|
@@ -16,6 +17,7 @@ from psycopg.rows import class_row, dict_row
|
|
|
16
17
|
from flask import current_app
|
|
17
18
|
from flask_babel import gettext as _
|
|
18
19
|
from geopic_tag_reader import sequence as geopic_sequence, reader
|
|
20
|
+
from geovisio.utils.tags import SemanticTag
|
|
19
21
|
|
|
20
22
|
from geovisio.utils.loggers import getLoggerWithExtra
|
|
21
23
|
|
|
@@ -73,15 +75,21 @@ class UploadSet(BaseModel):
|
|
|
73
75
|
title: str
|
|
74
76
|
estimated_nb_files: Optional[int] = None
|
|
75
77
|
sort_method: geopic_sequence.SortMethod
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
78
|
+
no_split: Optional[bool] = None
|
|
79
|
+
split_distance: Optional[int] = None
|
|
80
|
+
split_time: Optional[timedelta] = None
|
|
81
|
+
no_deduplication: Optional[bool] = None
|
|
82
|
+
duplicate_distance: Optional[float] = None
|
|
83
|
+
duplicate_rotation: Optional[int] = None
|
|
80
84
|
metadata: Optional[Dict[str, Any]]
|
|
81
85
|
user_agent: Optional[str] = Field(exclude=True)
|
|
82
86
|
associated_collections: List[AssociatedCollection] = []
|
|
83
87
|
nb_items: int = 0
|
|
84
88
|
items_status: Optional[AggregatedStatus] = None
|
|
89
|
+
semantics: List[SemanticTag] = Field(default_factory=list)
|
|
90
|
+
"""Semantic tags associated to the upload_set"""
|
|
91
|
+
relative_heading: Optional[int] = None
|
|
92
|
+
"""The relative heading (in degrees), offset based on movement path (0° = looking forward, -90° = looking left, 90° = looking right). For single picture upload_sets, 0° is heading north). Is applied to all associated collections if set."""
|
|
85
93
|
|
|
86
94
|
@computed_field
|
|
87
95
|
@property
|
|
@@ -128,19 +136,13 @@ class FileRejectionStatus(Enum):
|
|
|
128
136
|
"""other_error means there was an error that is not related to the picture itself"""
|
|
129
137
|
|
|
130
138
|
|
|
131
|
-
class FileRejectionDetails(BaseModel):
|
|
132
|
-
|
|
133
|
-
missing_fields: List[str]
|
|
134
|
-
"""Mandatory metadata missing from the file. Metadata can be `datetime` or `location`."""
|
|
135
|
-
|
|
136
|
-
|
|
137
139
|
class FileRejection(BaseModel):
|
|
138
140
|
"""Details about a file rejection"""
|
|
139
141
|
|
|
140
142
|
reason: str
|
|
141
143
|
severity: FileRejectionStatusSeverity
|
|
142
144
|
message: Optional[str]
|
|
143
|
-
details: Optional[
|
|
145
|
+
details: Optional[Dict[str, Any]]
|
|
144
146
|
|
|
145
147
|
model_config = ConfigDict(use_enum_values=True, use_attribute_docstrings=True)
|
|
146
148
|
|
|
@@ -231,8 +233,8 @@ def get_upload_set(id: UUID) -> Optional[UploadSet]:
|
|
|
231
233
|
SQL(
|
|
232
234
|
"""WITH picture_last_job AS (
|
|
233
235
|
SELECT p.id as picture_id,
|
|
234
|
-
-- Note: to know if a picture is
|
|
235
|
-
-- If there is no finished_at, the picture is still
|
|
236
|
+
-- Note: to know if a picture is being processed, check the latest job_history entry for this picture
|
|
237
|
+
-- If there is no finished_at, the picture is still being processed
|
|
236
238
|
(MAX(ARRAY [started_at, finished_at])) AS last_job,
|
|
237
239
|
p.preparing_status,
|
|
238
240
|
p.status,
|
|
@@ -243,13 +245,13 @@ def get_upload_set(id: UUID) -> Optional[UploadSet]:
|
|
|
243
245
|
GROUP BY p.id
|
|
244
246
|
),
|
|
245
247
|
picture_statuses AS (
|
|
246
|
-
SELECT
|
|
248
|
+
SELECT
|
|
247
249
|
*,
|
|
248
250
|
(last_job[1] IS NOT NULL AND last_job[2] IS NULL) AS is_job_running
|
|
249
251
|
FROM picture_last_job psj
|
|
250
252
|
),
|
|
251
253
|
associated_collections AS (
|
|
252
|
-
SELECT
|
|
254
|
+
SELECT
|
|
253
255
|
ps.upload_set_id,
|
|
254
256
|
COUNT(ps.picture_id) FILTER (WHERE ps.preparing_status = 'broken') AS nb_broken,
|
|
255
257
|
COUNT(ps.picture_id) FILTER (WHERE ps.preparing_status = 'prepared') AS nb_prepared,
|
|
@@ -268,6 +270,15 @@ associated_collections AS (
|
|
|
268
270
|
GROUP BY ps.upload_set_id,
|
|
269
271
|
s.id
|
|
270
272
|
),
|
|
273
|
+
semantics AS (
|
|
274
|
+
SELECT upload_set_id, json_agg(json_strip_nulls(json_build_object(
|
|
275
|
+
'key', key,
|
|
276
|
+
'value', value
|
|
277
|
+
)) ORDER BY key, value) AS semantics
|
|
278
|
+
FROM upload_sets_semantics
|
|
279
|
+
WHERE upload_set_id = %(id)s
|
|
280
|
+
GROUP BY upload_set_id
|
|
281
|
+
),
|
|
271
282
|
upload_set_statuses AS (
|
|
272
283
|
SELECT ps.upload_set_id,
|
|
273
284
|
COUNT(ps.picture_id) AS nb_items,
|
|
@@ -280,13 +291,14 @@ upload_set_statuses AS (
|
|
|
280
291
|
)
|
|
281
292
|
SELECT u.*,
|
|
282
293
|
COALESCE(us.nb_items, 0) AS nb_items,
|
|
294
|
+
COALESCE(s.semantics, '[]'::json) AS semantics,
|
|
283
295
|
json_build_object(
|
|
284
296
|
'broken', COALESCE(us.nb_broken, 0),
|
|
285
297
|
'prepared', COALESCE(us.nb_prepared, 0),
|
|
286
298
|
'not_processed', COALESCE(us.nb_not_processed, 0),
|
|
287
299
|
'preparing', COALESCE(us.nb_preparing, 0),
|
|
288
300
|
'rejected', (
|
|
289
|
-
SELECT count(*) FROM files
|
|
301
|
+
SELECT count(*) FROM files
|
|
290
302
|
WHERE upload_set_id = %(id)s AND rejection_status IS NOT NULL
|
|
291
303
|
)
|
|
292
304
|
) AS items_status,
|
|
@@ -327,6 +339,7 @@ SELECT u.*,
|
|
|
327
339
|
) AS associated_collections
|
|
328
340
|
FROM upload_sets u
|
|
329
341
|
LEFT JOIN upload_set_statuses us on us.upload_set_id = u.id
|
|
342
|
+
LEFT JOIN semantics s on s.upload_set_id = u.id
|
|
330
343
|
WHERE u.id = %(id)s"""
|
|
331
344
|
),
|
|
332
345
|
{"id": id},
|
|
@@ -357,16 +370,7 @@ def _parse_filter(filter: Optional[str]) -> SQL:
|
|
|
357
370
|
"""
|
|
358
371
|
if not filter:
|
|
359
372
|
return SQL("TRUE")
|
|
360
|
-
|
|
361
|
-
from pygeofilter.parsers.cql2_text import parse as cql_parser
|
|
362
|
-
|
|
363
|
-
try:
|
|
364
|
-
filterAst = cql_parser(filter)
|
|
365
|
-
f = to_sql_where(filterAst, FIELD_TO_SQL_FILTER).replace('"', "") # type: ignore
|
|
366
|
-
return SQL(f) # type: ignore
|
|
367
|
-
except Exception:
|
|
368
|
-
logging.error(f"Unsupported filter parameter: {filter}")
|
|
369
|
-
raise errors.InvalidAPIUsage(_("Unsupported filter parameter"), status_code=400)
|
|
373
|
+
return cql2.parse_cql2_filter(filter, FIELD_TO_SQL_FILTER)
|
|
370
374
|
|
|
371
375
|
|
|
372
376
|
def list_upload_sets(account_id: UUID, limit: int = 100, filter: Optional[str] = None) -> UploadSets:
|
|
@@ -374,30 +378,23 @@ def list_upload_sets(account_id: UUID, limit: int = 100, filter: Optional[str] =
|
|
|
374
378
|
l = db.fetchall(
|
|
375
379
|
current_app,
|
|
376
380
|
SQL(
|
|
377
|
-
"""SELECT
|
|
381
|
+
"""SELECT
|
|
378
382
|
u.*,
|
|
379
383
|
COALESCE(
|
|
380
384
|
(
|
|
381
|
-
SELECT
|
|
385
|
+
SELECT
|
|
382
386
|
json_agg(json_build_object(
|
|
383
|
-
'id',
|
|
384
|
-
'nb_items',
|
|
387
|
+
'id', s.id,
|
|
388
|
+
'nb_items', s.nb_pictures
|
|
385
389
|
))
|
|
386
|
-
FROM
|
|
387
|
-
|
|
388
|
-
sp.seq_id as collection_id,
|
|
389
|
-
count(sp.pic_id) AS nb_items
|
|
390
|
-
FROM pictures p
|
|
391
|
-
JOIN sequences_pictures sp ON sp.pic_id = p.id
|
|
392
|
-
WHERE p.upload_set_id = u.id
|
|
393
|
-
GROUP BY sp.seq_id
|
|
394
|
-
) ac
|
|
390
|
+
FROM sequences s
|
|
391
|
+
WHERE s.upload_set_id = u.id
|
|
395
392
|
),
|
|
396
393
|
'[]'::json
|
|
397
394
|
) AS associated_collections,
|
|
398
395
|
(
|
|
399
396
|
SELECT count(*) AS nb
|
|
400
|
-
FROM pictures p
|
|
397
|
+
FROM pictures p
|
|
401
398
|
WHERE p.upload_set_id = u.id
|
|
402
399
|
) AS nb_items
|
|
403
400
|
FROM upload_sets u
|
|
@@ -417,7 +414,7 @@ def ask_for_dispatch(upload_set_id: UUID):
|
|
|
417
414
|
"""Add a dispatch task to the job queue for the upload set. If there is already a task, postpone it."""
|
|
418
415
|
with db.conn(current_app) as conn:
|
|
419
416
|
conn.execute(
|
|
420
|
-
"""INSERT INTO
|
|
417
|
+
"""INSERT INTO
|
|
421
418
|
job_queue(sequence_id, task)
|
|
422
419
|
VALUES (%(upload_set_id)s, 'dispatch')
|
|
423
420
|
ON CONFLICT (upload_set_id) DO UPDATE SET ts = CURRENT_TIMESTAMP""",
|
|
@@ -425,7 +422,13 @@ def ask_for_dispatch(upload_set_id: UUID):
|
|
|
425
422
|
)
|
|
426
423
|
|
|
427
424
|
|
|
428
|
-
|
|
425
|
+
@dataclass
|
|
426
|
+
class PicToDelete:
|
|
427
|
+
picture_id: UUID
|
|
428
|
+
detail: Optional[Dict] = None
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
def dispatch(conn: psycopg.Connection, upload_set_id: UUID):
|
|
429
432
|
"""Finalize an upload set.
|
|
430
433
|
|
|
431
434
|
For the moment we only create a collection around all the items of the upload set, but later we'll split the items into several collections
|
|
@@ -438,13 +441,15 @@ def dispatch(upload_set_id: UUID):
|
|
|
438
441
|
raise Exception(f"Upload set {upload_set_id} not found")
|
|
439
442
|
|
|
440
443
|
logger = getLoggerWithExtra("geovisio.upload_set", {"upload_set_id": str(upload_set_id)})
|
|
441
|
-
with
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
444
|
+
with conn.transaction(), conn.cursor(row_factory=dict_row) as cursor:
|
|
445
|
+
# we put a lock on the upload set, to avoid new semantics being added while dispatching it
|
|
446
|
+
# Note: I did not find a way to only put a lock on the upload_sets_semantics table, so we lock the whole upload_set row (and any child rows)
|
|
447
|
+
_us_lock = cursor.execute(SQL("SELECT id FROM upload_sets WHERE id = %s FOR UPDATE"), [upload_set_id])
|
|
448
|
+
|
|
449
|
+
# get all the pictures of the upload set
|
|
450
|
+
db_pics = cursor.execute(
|
|
451
|
+
SQL(
|
|
452
|
+
"""SELECT
|
|
448
453
|
p.id,
|
|
449
454
|
p.ts,
|
|
450
455
|
ST_X(p.geom) as lon,
|
|
@@ -453,132 +458,197 @@ def dispatch(upload_set_id: UUID):
|
|
|
453
458
|
p.metadata->>'originalFileName' as file_name,
|
|
454
459
|
p.metadata,
|
|
455
460
|
s.id as sequence_id,
|
|
456
|
-
f is null as has_no_file
|
|
461
|
+
f is null as has_no_file,
|
|
462
|
+
p.heading_computed
|
|
457
463
|
FROM pictures p
|
|
458
464
|
LEFT JOIN sequences_pictures sp ON sp.pic_id = p.id
|
|
459
465
|
LEFT JOIN sequences s ON s.id = sp.seq_id
|
|
460
466
|
LEFT JOIN files f ON f.picture_id = p.id
|
|
461
467
|
WHERE p.upload_set_id = %(upload_set_id)s"""
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
# there is currently a bug where 2 pictures can be uploaded for the same file, so only 1 is associated to it.
|
|
467
|
-
# we want to delete one of them
|
|
468
|
-
# Those duplicates happen when a client send an upload that timeouts, but the client retries the upload and the server is not aware of this timeout (the connection is not closed).
|
|
469
|
-
# Note: later, if we are confident the bug has been removed, we might clean this code.
|
|
470
|
-
pics_to_delete_bug = [p["id"] for p in db_pics if p["has_no_file"]]
|
|
471
|
-
db_pics = [p for p in db_pics if p["has_no_file"] is False] # pictures without files will be deleted, we don't need them
|
|
472
|
-
pics_by_filename = {p["file_name"]: p for p in db_pics}
|
|
473
|
-
|
|
474
|
-
pics = [
|
|
475
|
-
geopic_sequence.Picture(
|
|
476
|
-
p["file_name"],
|
|
477
|
-
reader.GeoPicTags(
|
|
478
|
-
lon=p["lon"],
|
|
479
|
-
lat=p["lat"],
|
|
480
|
-
ts=p["ts"],
|
|
481
|
-
type=p["metadata"]["type"],
|
|
482
|
-
heading=p["heading"],
|
|
483
|
-
make=p["metadata"]["make"],
|
|
484
|
-
model=p["metadata"]["model"],
|
|
485
|
-
focal_length=p["metadata"]["focal_length"],
|
|
486
|
-
crop=p["metadata"]["crop"],
|
|
487
|
-
exif={},
|
|
488
|
-
),
|
|
489
|
-
)
|
|
490
|
-
for p in db_pics
|
|
491
|
-
]
|
|
468
|
+
),
|
|
469
|
+
{"upload_set_id": upload_set_id},
|
|
470
|
+
).fetchall()
|
|
492
471
|
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
472
|
+
config = cursor.execute(
|
|
473
|
+
SQL(
|
|
474
|
+
"SELECT default_split_distance, default_split_time, default_duplicate_distance, default_duplicate_rotation FROM configurations"
|
|
475
|
+
)
|
|
476
|
+
).fetchone()
|
|
477
|
+
|
|
478
|
+
# there is currently a bug where 2 pictures can be uploaded for the same file, so only 1 is associated to it.
|
|
479
|
+
# we want to delete one of them
|
|
480
|
+
# Those duplicates happen when a client send an upload that timeouts, but the client retries the upload and the server is not aware of this timeout (the connection is not closed).
|
|
481
|
+
# Note: later, if we are confident the bug has been removed, we might clean this code.
|
|
482
|
+
pics_to_delete_bug = [PicToDelete(picture_id=p["id"]) for p in db_pics if p["has_no_file"]]
|
|
483
|
+
db_pics = [p for p in db_pics if p["has_no_file"] is False] # pictures without files will be deleted, we don't need them
|
|
484
|
+
pics_by_filename = {p["file_name"]: p for p in db_pics}
|
|
485
|
+
|
|
486
|
+
pics = [
|
|
487
|
+
geopic_sequence.Picture(
|
|
488
|
+
p["file_name"],
|
|
489
|
+
reader.GeoPicTags(
|
|
490
|
+
lon=p["lon"],
|
|
491
|
+
lat=p["lat"],
|
|
492
|
+
ts=p["ts"],
|
|
493
|
+
type=p["metadata"]["type"],
|
|
494
|
+
heading=p["heading"],
|
|
495
|
+
make=p["metadata"]["make"],
|
|
496
|
+
model=p["metadata"]["model"],
|
|
497
|
+
focal_length=p["metadata"]["focal_length"],
|
|
498
|
+
crop=p["metadata"]["crop"],
|
|
499
|
+
exif={},
|
|
501
500
|
),
|
|
501
|
+
heading_computed=p["heading_computed"],
|
|
502
502
|
)
|
|
503
|
-
|
|
503
|
+
for p in db_pics
|
|
504
|
+
]
|
|
504
505
|
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
if
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
)
|
|
511
|
-
|
|
506
|
+
split_params = None
|
|
507
|
+
if not db_upload_set.no_split:
|
|
508
|
+
distance = db_upload_set.split_distance if db_upload_set.split_distance is not None else config["default_split_distance"]
|
|
509
|
+
t = db_upload_set.split_time if db_upload_set.split_time is not None else config["default_split_time"]
|
|
510
|
+
if t is not None and distance is not None:
|
|
511
|
+
split_params = geopic_sequence.SplitParams(maxDistance=distance, maxTime=t.total_seconds())
|
|
512
|
+
merge_params = None
|
|
513
|
+
if not db_upload_set.no_deduplication:
|
|
514
|
+
distance = (
|
|
515
|
+
db_upload_set.duplicate_distance if db_upload_set.duplicate_distance is not None else config["default_duplicate_distance"]
|
|
516
|
+
)
|
|
517
|
+
rotation = (
|
|
518
|
+
db_upload_set.duplicate_rotation if db_upload_set.duplicate_rotation is not None else config["default_duplicate_rotation"]
|
|
519
|
+
)
|
|
520
|
+
if distance is not None and rotation is not None:
|
|
521
|
+
merge_params = geopic_sequence.MergeParams(maxDistance=distance, maxRotationAngle=rotation)
|
|
512
522
|
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
523
|
+
report = geopic_sequence.dispatch_pictures(
|
|
524
|
+
pics, mergeParams=merge_params, sortMethod=db_upload_set.sort_method, splitParams=split_params
|
|
525
|
+
)
|
|
526
|
+
reused_sequence = set()
|
|
527
|
+
|
|
528
|
+
pics_to_delete_duplicates = [
|
|
529
|
+
PicToDelete(
|
|
530
|
+
picture_id=pics_by_filename[d.picture.filename]["id"],
|
|
531
|
+
detail={
|
|
532
|
+
"duplicate_of": str(pics_by_filename[d.duplicate_of.filename]["id"]),
|
|
533
|
+
"distance": d.distance,
|
|
534
|
+
"angle": d.angle,
|
|
535
|
+
},
|
|
536
|
+
)
|
|
537
|
+
for d in report.duplicate_pictures
|
|
538
|
+
]
|
|
539
|
+
pics_to_delete = pics_to_delete_duplicates + pics_to_delete_bug
|
|
540
|
+
if pics_to_delete:
|
|
541
|
+
logger.debug(
|
|
542
|
+
f"nb duplicate pictures {len(pics_to_delete_duplicates)} {f' and {len(pics_to_delete_bug)} pictures without files' if pics_to_delete_bug else ''}"
|
|
543
|
+
)
|
|
544
|
+
logger.debug(f"duplicate pictures {[p.picture.filename for p in report.duplicate_pictures]}")
|
|
517
545
|
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
)
|
|
546
|
+
cursor.execute(SQL("CREATE TEMPORARY TABLE tmp_duplicates(picture_id UUID, details JSONB) ON COMMIT DROP"))
|
|
547
|
+
with cursor.copy("COPY tmp_duplicates(picture_id, details) FROM stdin;") as copy:
|
|
548
|
+
for p in pics_to_delete:
|
|
549
|
+
copy.write_row((p.picture_id, Jsonb(p.detail) if p.detail else None))
|
|
550
|
+
|
|
551
|
+
cursor.execute(
|
|
552
|
+
SQL(
|
|
553
|
+
"""UPDATE files SET
|
|
554
|
+
rejection_status = 'capture_duplicate', rejection_details = d.details
|
|
555
|
+
FROM tmp_duplicates d
|
|
556
|
+
WHERE d.picture_id = files.picture_id"""
|
|
522
557
|
)
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
558
|
+
)
|
|
559
|
+
# set all the pictures as waiting for deletion and add background jobs to delete them
|
|
560
|
+
# Note: we do not delte the picture's row because it can cause some deadlocks if some workers are preparing thoses pictures.
|
|
561
|
+
cursor.execute(SQL("UPDATE pictures SET status = 'waiting-for-delete' WHERE id IN (select picture_id FROM tmp_duplicates)"))
|
|
562
|
+
cursor.execute(
|
|
563
|
+
SQL(
|
|
564
|
+
"""INSERT INTO job_queue(picture_to_delete_id, task)
|
|
565
|
+
SELECT picture_id, 'delete' FROM tmp_duplicates"""
|
|
566
|
+
)
|
|
567
|
+
)
|
|
568
|
+
|
|
569
|
+
number_title = len(report.sequences) > 1
|
|
570
|
+
existing_sequences = set(p["sequence_id"] for p in db_pics if p["sequence_id"])
|
|
571
|
+
new_sequence_ids = set()
|
|
572
|
+
for i, s in enumerate(report.sequences, start=1):
|
|
573
|
+
existing_sequence = next(
|
|
574
|
+
(seq for p in s.pictures if (seq := pics_by_filename[p.filename]["sequence_id"]) not in reused_sequence),
|
|
575
|
+
None,
|
|
576
|
+
)
|
|
577
|
+
# if some of the pictures were already in a sequence, we should not create a new one
|
|
578
|
+
if existing_sequence:
|
|
579
|
+
logger.info(f"sequence {existing_sequence} already contains pictures, we will not create a new one")
|
|
580
|
+
# we should wipe the sequences_pictures though
|
|
581
|
+
seq_id = existing_sequence
|
|
582
|
+
cursor.execute(
|
|
583
|
+
SQL("DELETE FROM sequences_pictures WHERE seq_id = %(seq_id)s"),
|
|
584
|
+
{"seq_id": seq_id},
|
|
533
585
|
)
|
|
534
|
-
|
|
535
|
-
if
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
586
|
+
reused_sequence.add(seq_id)
|
|
587
|
+
# Note: we do not update the sequences_semantics if reusing a sequence, because the sequence semantics's updates are reported to the existing sequences if there are some
|
|
588
|
+
else:
|
|
589
|
+
new_title = f"{db_upload_set.title}{f'-{i}' if number_title else ''}"
|
|
590
|
+
seq_id = cursor.execute(
|
|
591
|
+
SQL(
|
|
592
|
+
"""INSERT INTO sequences(account_id, metadata, user_agent, upload_set_id)
|
|
593
|
+
VALUES (%(account_id)s, %(metadata)s, %(user_agent)s, %(upload_set_id)s)
|
|
594
|
+
RETURNING id"""
|
|
595
|
+
),
|
|
596
|
+
{
|
|
597
|
+
"account_id": db_upload_set.account_id,
|
|
598
|
+
"metadata": Jsonb({"title": new_title}),
|
|
599
|
+
"user_agent": db_upload_set.user_agent,
|
|
600
|
+
"upload_set_id": db_upload_set.id,
|
|
601
|
+
},
|
|
602
|
+
).fetchone()
|
|
603
|
+
seq_id = seq_id["id"]
|
|
604
|
+
|
|
605
|
+
# Pass all semantics to the new sequence
|
|
606
|
+
copy_upload_set_semantics_to_sequence(cursor, db_upload_set.id, seq_id)
|
|
607
|
+
new_sequence_ids.add(seq_id)
|
|
608
|
+
|
|
609
|
+
with cursor.copy("COPY sequences_pictures(seq_id, pic_id, rank) FROM stdin;") as copy:
|
|
610
|
+
for i, p in enumerate(s.pictures, 1):
|
|
611
|
+
copy.write_row(
|
|
612
|
+
(seq_id, pics_by_filename[p.filename]["id"], i),
|
|
542
613
|
)
|
|
543
|
-
reused_sequence.add(seq_id)
|
|
544
|
-
else:
|
|
545
|
-
new_title = f"{db_upload_set.title}{f'-{i}' if number_title else ''}"
|
|
546
|
-
seq_id = cursor.execute(
|
|
547
|
-
SQL(
|
|
548
|
-
"""INSERT INTO sequences(account_id, metadata, user_agent)
|
|
549
|
-
VALUES (%(account_id)s, %(metadata)s, %(user_agent)s)
|
|
550
|
-
RETURNING id"""
|
|
551
|
-
),
|
|
552
|
-
{
|
|
553
|
-
"account_id": db_upload_set.account_id,
|
|
554
|
-
"metadata": Jsonb({"title": new_title}),
|
|
555
|
-
"user_agent": db_upload_set.user_agent,
|
|
556
|
-
},
|
|
557
|
-
).fetchone()
|
|
558
|
-
seq_id = seq_id["id"]
|
|
559
|
-
|
|
560
|
-
new_sequence_ids.add(seq_id)
|
|
561
|
-
|
|
562
|
-
with cursor.copy("COPY sequences_pictures(seq_id, pic_id, rank) FROM stdin;") as copy:
|
|
563
|
-
for i, p in enumerate(s.pictures, 1):
|
|
564
|
-
copy.write_row(
|
|
565
|
-
(seq_id, pics_by_filename[p.filename]["id"], i),
|
|
566
|
-
)
|
|
567
614
|
|
|
568
|
-
|
|
615
|
+
sequences.add_finalization_job(cursor=cursor, seqId=seq_id)
|
|
569
616
|
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
SQL("UPDATE sequences SET status = 'deleted' WHERE id = ANY(%(seq_ids)s)"), {"seq_ids": list(sequences_to_delete)}
|
|
577
|
-
)
|
|
617
|
+
# we can delete all the old sequences
|
|
618
|
+
sequences_to_delete = existing_sequences - new_sequence_ids
|
|
619
|
+
if sequences_to_delete:
|
|
620
|
+
logger.debug(f"sequences to delete = {sequences_to_delete} (existing = {existing_sequences}, new = {new_sequence_ids})")
|
|
621
|
+
conn.execute(SQL("DELETE FROM sequences_pictures WHERE seq_id = ANY(%(seq_ids)s)"), {"seq_ids": list(sequences_to_delete)})
|
|
622
|
+
conn.execute(SQL("UPDATE sequences SET status = 'deleted' WHERE id = ANY(%(seq_ids)s)"), {"seq_ids": list(sequences_to_delete)})
|
|
578
623
|
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
624
|
+
for s in report.sequences_splits or []:
|
|
625
|
+
logger.debug(f"split = {s.prevPic.filename} -> {s.nextPic.filename} : {s.reason}")
|
|
626
|
+
conn.execute(SQL("UPDATE upload_sets SET dispatched = true WHERE id = %(upload_set_id)s"), {"upload_set_id": db_upload_set.id})
|
|
627
|
+
|
|
628
|
+
|
|
629
|
+
def copy_upload_set_semantics_to_sequence(cursor, db_upload_id: UUID, seq_id: UUID):
|
|
630
|
+
cursor.execute(
|
|
631
|
+
SQL(
|
|
632
|
+
"""WITH upload_set_semantics AS (
|
|
633
|
+
SELECT key, value, upload_set_id, account_id
|
|
634
|
+
FROM upload_sets_semantics
|
|
635
|
+
WHERE upload_set_id = %(upload_set_id)s
|
|
636
|
+
),
|
|
637
|
+
seq_sem AS (
|
|
638
|
+
INSERT INTO sequences_semantics(sequence_id, key, value)
|
|
639
|
+
SELECT %(seq_id)s, key, value
|
|
640
|
+
FROM upload_set_semantics
|
|
641
|
+
)
|
|
642
|
+
INSERT INTO sequences_semantics_history(sequence_id, account_id, ts, updates)
|
|
643
|
+
SELECT %(seq_id)s, account_id, NOW(), jsonb_build_object('key', key, 'value', value, 'action', 'add')
|
|
644
|
+
FROM upload_set_semantics
|
|
645
|
+
"""
|
|
646
|
+
),
|
|
647
|
+
{
|
|
648
|
+
"upload_set_id": db_upload_id,
|
|
649
|
+
"seq_id": seq_id,
|
|
650
|
+
},
|
|
651
|
+
)
|
|
582
652
|
|
|
583
653
|
|
|
584
654
|
def insertFileInDatabase(
|
|
@@ -598,51 +668,59 @@ def insertFileInDatabase(
|
|
|
598
668
|
|
|
599
669
|
# we check if there is already a file with this name in the upload set with an associated picture.
|
|
600
670
|
# If there is no picture (because the picture has been rejected), we accept that the file is overridden
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
671
|
+
with cursor.connection.transaction():
|
|
672
|
+
existing_file = cursor.execute(
|
|
673
|
+
SQL(
|
|
674
|
+
"""SELECT picture_id, rejection_status
|
|
675
|
+
FROM files
|
|
676
|
+
WHERE upload_set_id = %(upload_set_id)s AND file_name = %(file_name)s AND picture_id IS NOT NULL"""
|
|
677
|
+
),
|
|
678
|
+
params={
|
|
679
|
+
"upload_set_id": upload_set_id,
|
|
680
|
+
"file_name": file_name,
|
|
681
|
+
},
|
|
682
|
+
).fetchone()
|
|
683
|
+
if existing_file:
|
|
684
|
+
raise errors.InvalidAPIUsage(
|
|
685
|
+
_("A different picture with the same name has already been added to this uploadset"),
|
|
686
|
+
status_code=409,
|
|
687
|
+
payload={"existing_item": {"id": existing_file["picture_id"]}},
|
|
688
|
+
)
|
|
618
689
|
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
VALUES (
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
ON CONFLICT (upload_set_id, file_name)
|
|
628
|
-
DO UPDATE SET picture_id = %(picture_id)s, size = %(size)s, content_md5 = %(content_md5)s,
|
|
629
|
-
|
|
630
|
-
WHERE files.picture_id IS NULL -- check again that we do not override an existing picture
|
|
631
|
-
RETURNING *"""
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
690
|
+
f = cursor.execute(
|
|
691
|
+
SQL(
|
|
692
|
+
"""INSERT INTO files(
|
|
693
|
+
upload_set_id, picture_id, file_type, file_name,
|
|
694
|
+
size, content_md5, rejection_status, rejection_message, rejection_details)
|
|
695
|
+
VALUES (
|
|
696
|
+
%(upload_set_id)s, %(picture_id)s, %(type)s, %(file_name)s,
|
|
697
|
+
%(size)s, %(content_md5)s, %(rejection_status)s, %(rejection_message)s, %(rejection_details)s)
|
|
698
|
+
ON CONFLICT (upload_set_id, file_name)
|
|
699
|
+
DO UPDATE SET picture_id = %(picture_id)s, size = %(size)s, content_md5 = %(content_md5)s,
|
|
700
|
+
rejection_status = %(rejection_status)s, rejection_message = %(rejection_message)s, rejection_details = %(rejection_details)s
|
|
701
|
+
WHERE files.picture_id IS NULL -- check again that we do not override an existing picture
|
|
702
|
+
RETURNING *"""
|
|
703
|
+
),
|
|
704
|
+
params={
|
|
705
|
+
"upload_set_id": upload_set_id,
|
|
706
|
+
"type": file_type,
|
|
707
|
+
"picture_id": picture_id,
|
|
708
|
+
"file_name": file_name,
|
|
709
|
+
"size": size,
|
|
710
|
+
"content_md5": content_md5,
|
|
711
|
+
"rejection_status": rejection_status,
|
|
712
|
+
"rejection_message": rejection_message,
|
|
713
|
+
"rejection_details": Jsonb(rejection_details),
|
|
714
|
+
},
|
|
715
|
+
)
|
|
716
|
+
u = f.fetchone()
|
|
717
|
+
if u is None:
|
|
718
|
+
logging.error(f"Impossible to add file {file_name} to uploadset {upload_set_id}")
|
|
719
|
+
raise errors.InvalidAPIUsage(
|
|
720
|
+
_("Impossible to add the picture to this uploadset"),
|
|
721
|
+
status_code=500,
|
|
722
|
+
)
|
|
723
|
+
return UploadSetFile(**u)
|
|
646
724
|
|
|
647
725
|
|
|
648
726
|
def get_upload_set_files(upload_set_id: UUID) -> UploadSetFiles:
|
|
@@ -651,15 +729,15 @@ def get_upload_set_files(upload_set_id: UUID) -> UploadSetFiles:
|
|
|
651
729
|
current_app,
|
|
652
730
|
SQL(
|
|
653
731
|
"""SELECT
|
|
654
|
-
upload_set_id,
|
|
655
|
-
file_type,
|
|
656
|
-
file_name,
|
|
657
|
-
size,
|
|
658
|
-
content_md5,
|
|
732
|
+
upload_set_id,
|
|
733
|
+
file_type,
|
|
734
|
+
file_name,
|
|
735
|
+
size,
|
|
736
|
+
content_md5,
|
|
659
737
|
rejection_status,
|
|
660
738
|
rejection_message,
|
|
661
739
|
rejection_details,
|
|
662
|
-
picture_id,
|
|
740
|
+
picture_id,
|
|
663
741
|
inserted_at
|
|
664
742
|
FROM files
|
|
665
743
|
WHERE upload_set_id = %(upload_set_id)s
|