geovisio 2.9.0__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 (65) hide show
  1. geovisio/__init__.py +6 -1
  2. geovisio/config_app.py +5 -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 +91 -3
  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 +292 -63
  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 +4 -3
  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 +14 -17
  36. geovisio/utils/auth.py +14 -13
  37. geovisio/utils/cql2.py +2 -2
  38. geovisio/utils/fields.py +14 -2
  39. geovisio/utils/items.py +44 -0
  40. geovisio/utils/model_query.py +2 -2
  41. geovisio/utils/pic_shape.py +1 -1
  42. geovisio/utils/pictures.py +111 -18
  43. geovisio/utils/semantics.py +32 -3
  44. geovisio/utils/sentry.py +1 -1
  45. geovisio/utils/sequences.py +51 -34
  46. geovisio/utils/upload_set.py +285 -198
  47. geovisio/utils/website.py +1 -1
  48. geovisio/web/annotations.py +209 -68
  49. geovisio/web/auth.py +1 -1
  50. geovisio/web/collections.py +26 -22
  51. geovisio/web/configuration.py +24 -4
  52. geovisio/web/docs.py +93 -11
  53. geovisio/web/items.py +197 -121
  54. geovisio/web/params.py +44 -31
  55. geovisio/web/pictures.py +34 -0
  56. geovisio/web/tokens.py +49 -1
  57. geovisio/web/upload_set.py +150 -32
  58. geovisio/web/users.py +4 -4
  59. geovisio/web/utils.py +2 -2
  60. geovisio/workers/runner_pictures.py +128 -23
  61. {geovisio-2.9.0.dist-info → geovisio-2.10.0.dist-info}/METADATA +13 -13
  62. geovisio-2.10.0.dist-info/RECORD +105 -0
  63. geovisio-2.9.0.dist-info/RECORD +0 -98
  64. {geovisio-2.9.0.dist-info → geovisio-2.10.0.dist-info}/WHEEL +0 -0
  65. {geovisio-2.9.0.dist-info → geovisio-2.10.0.dist-info}/licenses/LICENSE +0 -0
@@ -6,6 +6,7 @@ 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 dataclasses import dataclass
9
10
  from geovisio.utils import cql2, db, sequences
10
11
  from geovisio import errors
11
12
  from geovisio.utils.link import make_link, Link
@@ -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
- split_distance: int
77
- split_time: timedelta
78
- duplicate_distance: float
79
- duplicate_rotation: int
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[FileRejectionDetails]
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 beeing processed, check the latest job_history entry for this picture
235
- -- If there is no finished_at, the picture is still beeing processed
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},
@@ -365,30 +378,23 @@ def list_upload_sets(account_id: UUID, limit: int = 100, filter: Optional[str] =
365
378
  l = db.fetchall(
366
379
  current_app,
367
380
  SQL(
368
- """SELECT
381
+ """SELECT
369
382
  u.*,
370
383
  COALESCE(
371
384
  (
372
- SELECT
385
+ SELECT
373
386
  json_agg(json_build_object(
374
- 'id', ac.collection_id,
375
- 'nb_items', ac.nb_items
387
+ 'id', s.id,
388
+ 'nb_items', s.nb_pictures
376
389
  ))
377
- FROM (
378
- SELECT
379
- sp.seq_id as collection_id,
380
- count(sp.pic_id) AS nb_items
381
- FROM pictures p
382
- JOIN sequences_pictures sp ON sp.pic_id = p.id
383
- WHERE p.upload_set_id = u.id
384
- GROUP BY sp.seq_id
385
- ) ac
390
+ FROM sequences s
391
+ WHERE s.upload_set_id = u.id
386
392
  ),
387
393
  '[]'::json
388
394
  ) AS associated_collections,
389
395
  (
390
396
  SELECT count(*) AS nb
391
- FROM pictures p
397
+ FROM pictures p
392
398
  WHERE p.upload_set_id = u.id
393
399
  ) AS nb_items
394
400
  FROM upload_sets u
@@ -408,7 +414,7 @@ def ask_for_dispatch(upload_set_id: UUID):
408
414
  """Add a dispatch task to the job queue for the upload set. If there is already a task, postpone it."""
409
415
  with db.conn(current_app) as conn:
410
416
  conn.execute(
411
- """INSERT INTO
417
+ """INSERT INTO
412
418
  job_queue(sequence_id, task)
413
419
  VALUES (%(upload_set_id)s, 'dispatch')
414
420
  ON CONFLICT (upload_set_id) DO UPDATE SET ts = CURRENT_TIMESTAMP""",
@@ -416,7 +422,13 @@ def ask_for_dispatch(upload_set_id: UUID):
416
422
  )
417
423
 
418
424
 
419
- def dispatch(upload_set_id: UUID):
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):
420
432
  """Finalize an upload set.
421
433
 
422
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
@@ -429,13 +441,15 @@ def dispatch(upload_set_id: UUID):
429
441
  raise Exception(f"Upload set {upload_set_id} not found")
430
442
 
431
443
  logger = getLoggerWithExtra("geovisio.upload_set", {"upload_set_id": str(upload_set_id)})
432
- with db.conn(current_app) as conn:
433
- with conn.transaction(), conn.cursor(row_factory=dict_row) as cursor:
434
-
435
- # get all the pictures of the upload set
436
- db_pics = cursor.execute(
437
- SQL(
438
- """SELECT
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
439
453
  p.id,
440
454
  p.ts,
441
455
  ST_X(p.geom) as lon,
@@ -444,132 +458,197 @@ def dispatch(upload_set_id: UUID):
444
458
  p.metadata->>'originalFileName' as file_name,
445
459
  p.metadata,
446
460
  s.id as sequence_id,
447
- f is null as has_no_file
461
+ f is null as has_no_file,
462
+ p.heading_computed
448
463
  FROM pictures p
449
464
  LEFT JOIN sequences_pictures sp ON sp.pic_id = p.id
450
465
  LEFT JOIN sequences s ON s.id = sp.seq_id
451
466
  LEFT JOIN files f ON f.picture_id = p.id
452
467
  WHERE p.upload_set_id = %(upload_set_id)s"""
453
- ),
454
- {"upload_set_id": upload_set_id},
455
- ).fetchall()
456
-
457
- # there is currently a bug where 2 pictures can be uploaded for the same file, so only 1 is associated to it.
458
- # we want to delete one of them
459
- # 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).
460
- # Note: later, if we are confident the bug has been removed, we might clean this code.
461
- pics_to_delete_bug = [p["id"] for p in db_pics if p["has_no_file"]]
462
- 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
463
- pics_by_filename = {p["file_name"]: p for p in db_pics}
464
-
465
- pics = [
466
- geopic_sequence.Picture(
467
- p["file_name"],
468
- reader.GeoPicTags(
469
- lon=p["lon"],
470
- lat=p["lat"],
471
- ts=p["ts"],
472
- type=p["metadata"]["type"],
473
- heading=p["heading"],
474
- make=p["metadata"]["make"],
475
- model=p["metadata"]["model"],
476
- focal_length=p["metadata"]["focal_length"],
477
- crop=p["metadata"]["crop"],
478
- exif={},
479
- ),
480
- )
481
- for p in db_pics
482
- ]
468
+ ),
469
+ {"upload_set_id": upload_set_id},
470
+ ).fetchall()
483
471
 
484
- report = geopic_sequence.dispatch_pictures(
485
- pics,
486
- mergeParams=geopic_sequence.MergeParams(
487
- maxDistance=db_upload_set.duplicate_distance, maxRotationAngle=db_upload_set.duplicate_rotation
488
- ),
489
- sortMethod=db_upload_set.sort_method,
490
- splitParams=geopic_sequence.SplitParams(
491
- maxDistance=db_upload_set.split_distance, maxTime=db_upload_set.split_time.total_seconds()
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={},
492
500
  ),
501
+ heading_computed=p["heading_computed"],
493
502
  )
494
- reused_sequence = set()
503
+ for p in db_pics
504
+ ]
495
505
 
496
- pics_to_delete_duplicates = [pics_by_filename[p.filename]["id"] for p in report.duplicate_pictures or []]
497
- pics_to_delete = pics_to_delete_duplicates + pics_to_delete_bug
498
- if pics_to_delete:
499
- logger.debug(
500
- 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 ''}"
501
- )
502
- logger.debug(f"duplicate pictures {[p.filename for p in report.duplicate_pictures or []]}")
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)
503
522
 
504
- cursor.execute(SQL("CREATE TEMPORARY TABLE tmp_duplicates(picture_id UUID) ON COMMIT DROP"))
505
- with cursor.copy("COPY tmp_duplicates(picture_id) FROM stdin;") as copy:
506
- for p in pics_to_delete:
507
- copy.write_row((p,))
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]}")
508
545
 
509
- cursor.execute(
510
- SQL(
511
- "UPDATE files SET rejection_status = 'capture_duplicate' WHERE picture_id IN (select picture_id from tmp_duplicates)"
512
- )
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"""
513
557
  )
514
- # delete all pictures (the DB triggers will also add background jobs to delete the associated files)
515
- cursor.execute(SQL("DELETE FROM pictures WHERE id IN (select picture_id FROM tmp_duplicates)"))
516
-
517
- number_title = len(report.sequences) > 1
518
- existing_sequences = set(p["sequence_id"] for p in db_pics if p["sequence_id"])
519
- new_sequence_ids = set()
520
- for i, s in enumerate(report.sequences, start=1):
521
- existing_sequence = next(
522
- (seq for p in s.pictures if (seq := pics_by_filename[p.filename]["sequence_id"]) not in reused_sequence),
523
- None,
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},
524
585
  )
525
- # if some of the pictures were already in a sequence, we should not create a new one
526
- if existing_sequence:
527
- logger.info(f"sequence {existing_sequence} already contains pictures, we will not create a new one")
528
- # we should wipe the sequences_pictures though
529
- seq_id = existing_sequence
530
- cursor.execute(
531
- SQL("DELETE FROM sequences_pictures WHERE seq_id = %(seq_id)s"),
532
- {"seq_id": seq_id},
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),
533
613
  )
534
- reused_sequence.add(seq_id)
535
- else:
536
- new_title = f"{db_upload_set.title}{f'-{i}' if number_title else ''}"
537
- seq_id = cursor.execute(
538
- SQL(
539
- """INSERT INTO sequences(account_id, metadata, user_agent)
540
- VALUES (%(account_id)s, %(metadata)s, %(user_agent)s)
541
- RETURNING id"""
542
- ),
543
- {
544
- "account_id": db_upload_set.account_id,
545
- "metadata": Jsonb({"title": new_title}),
546
- "user_agent": db_upload_set.user_agent,
547
- },
548
- ).fetchone()
549
- seq_id = seq_id["id"]
550
-
551
- new_sequence_ids.add(seq_id)
552
-
553
- with cursor.copy("COPY sequences_pictures(seq_id, pic_id, rank) FROM stdin;") as copy:
554
- for i, p in enumerate(s.pictures, 1):
555
- copy.write_row(
556
- (seq_id, pics_by_filename[p.filename]["id"], i),
557
- )
558
614
 
559
- sequences.add_finalization_job(cursor=cursor, seqId=seq_id)
615
+ sequences.add_finalization_job(cursor=cursor, seqId=seq_id)
560
616
 
561
- # we can delete all the old sequences
562
- sequences_to_delete = existing_sequences - new_sequence_ids
563
- if sequences_to_delete:
564
- logger.debug(f"sequences to delete = {sequences_to_delete} (existing = {existing_sequences}, new = {new_sequence_ids})")
565
- conn.execute(SQL("DELETE FROM sequences_pictures WHERE seq_id = ANY(%(seq_ids)s)"), {"seq_ids": list(sequences_to_delete)})
566
- conn.execute(
567
- SQL("UPDATE sequences SET status = 'deleted' WHERE id = ANY(%(seq_ids)s)"), {"seq_ids": list(sequences_to_delete)}
568
- )
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)})
569
623
 
570
- for s in report.sequences_splits or []:
571
- logger.debug(f"split = {s.prevPic.filename} -> {s.nextPic.filename} : {s.reason}")
572
- conn.execute(SQL("UPDATE upload_sets SET dispatched = true WHERE id = %(upload_set_id)s"), {"upload_set_id": db_upload_set.id})
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
+ )
573
652
 
574
653
 
575
654
  def insertFileInDatabase(
@@ -589,51 +668,59 @@ def insertFileInDatabase(
589
668
 
590
669
  # we check if there is already a file with this name in the upload set with an associated picture.
591
670
  # If there is no picture (because the picture has been rejected), we accept that the file is overridden
592
- existing_file = cursor.execute(
593
- SQL(
594
- """SELECT picture_id, rejection_status
595
- FROM files
596
- WHERE upload_set_id = %(upload_set_id)s AND file_name = %(file_name)s AND picture_id IS NOT NULL"""
597
- ),
598
- params={
599
- "upload_set_id": upload_set_id,
600
- "file_name": file_name,
601
- },
602
- ).fetchone()
603
- if existing_file:
604
- raise errors.InvalidAPIUsage(
605
- _("A different picture with the same name has already been added to this uploadset"),
606
- status_code=409,
607
- payload={"existing_item": {"id": existing_file["picture_id"]}},
608
- )
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
+ )
609
689
 
610
- f = cursor.execute(
611
- SQL(
612
- """INSERT INTO files(
613
- upload_set_id, picture_id, file_type, file_name,
614
- size, content_md5, rejection_status, rejection_message, rejection_details)
615
- VALUES (
616
- %(upload_set_id)s, %(picture_id)s, %(type)s, %(file_name)s,
617
- %(size)s, %(content_md5)s, %(rejection_status)s, %(rejection_message)s, %(rejection_details)s)
618
- ON CONFLICT (upload_set_id, file_name)
619
- DO UPDATE SET picture_id = %(picture_id)s, size = %(size)s, content_md5 = %(content_md5)s,
620
- rejection_status = %(rejection_status)s, rejection_message = %(rejection_message)s, rejection_details = %(rejection_details)s
621
- WHERE files.picture_id IS NULL -- check again that we do not override an existing picture
622
- RETURNING *"""
623
- ),
624
- params={
625
- "upload_set_id": upload_set_id,
626
- "type": file_type,
627
- "picture_id": picture_id,
628
- "file_name": file_name,
629
- "size": size,
630
- "content_md5": content_md5,
631
- "rejection_status": rejection_status,
632
- "rejection_message": rejection_message,
633
- "rejection_details": Jsonb(rejection_details),
634
- },
635
- )
636
- return UploadSetFile(**f.fetchone())
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)
637
724
 
638
725
 
639
726
  def get_upload_set_files(upload_set_id: UUID) -> UploadSetFiles:
@@ -642,15 +729,15 @@ def get_upload_set_files(upload_set_id: UUID) -> UploadSetFiles:
642
729
  current_app,
643
730
  SQL(
644
731
  """SELECT
645
- upload_set_id,
646
- file_type,
647
- file_name,
648
- size,
649
- content_md5,
732
+ upload_set_id,
733
+ file_type,
734
+ file_name,
735
+ size,
736
+ content_md5,
650
737
  rejection_status,
651
738
  rejection_message,
652
739
  rejection_details,
653
- picture_id,
740
+ picture_id,
654
741
  inserted_at
655
742
  FROM files
656
743
  WHERE upload_set_id = %(upload_set_id)s
geovisio/utils/website.py CHANGED
@@ -14,7 +14,7 @@ class Website:
14
14
  """Website associated to the API.
15
15
  This wrapper will define the routes we expect from the website.
16
16
 
17
- We should limit the interraction from the api to the website, but for some flow (especially auth flows), it's can be useful to redirect to website's page
17
+ We should limit the interaction from the api to the website, but for some flow (especially auth flows), it's can be useful to redirect to website's page
18
18
 
19
19
  If the url is:
20
20
  * set to `false`, there is no associated website