geovisio 2.6.0__py3-none-any.whl → 2.7.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 (57) hide show
  1. geovisio/__init__.py +36 -7
  2. geovisio/admin_cli/db.py +1 -4
  3. geovisio/config_app.py +40 -1
  4. geovisio/db_migrations.py +24 -3
  5. geovisio/templates/main.html +13 -13
  6. geovisio/templates/viewer.html +3 -3
  7. geovisio/translations/de/LC_MESSAGES/messages.mo +0 -0
  8. geovisio/translations/de/LC_MESSAGES/messages.po +667 -0
  9. geovisio/translations/en/LC_MESSAGES/messages.mo +0 -0
  10. geovisio/translations/en/LC_MESSAGES/messages.po +730 -0
  11. geovisio/translations/es/LC_MESSAGES/messages.mo +0 -0
  12. geovisio/translations/es/LC_MESSAGES/messages.po +778 -0
  13. geovisio/translations/fi/LC_MESSAGES/messages.mo +0 -0
  14. geovisio/translations/fi/LC_MESSAGES/messages.po +589 -0
  15. geovisio/translations/fr/LC_MESSAGES/messages.mo +0 -0
  16. geovisio/translations/fr/LC_MESSAGES/messages.po +814 -0
  17. geovisio/translations/ko/LC_MESSAGES/messages.mo +0 -0
  18. geovisio/translations/ko/LC_MESSAGES/messages.po +685 -0
  19. geovisio/translations/messages.pot +686 -0
  20. geovisio/translations/nl/LC_MESSAGES/messages.mo +0 -0
  21. geovisio/translations/nl/LC_MESSAGES/messages.po +594 -0
  22. geovisio/utils/__init__.py +1 -1
  23. geovisio/utils/auth.py +50 -11
  24. geovisio/utils/db.py +65 -0
  25. geovisio/utils/excluded_areas.py +83 -0
  26. geovisio/utils/extent.py +30 -0
  27. geovisio/utils/fields.py +1 -1
  28. geovisio/utils/filesystems.py +0 -1
  29. geovisio/utils/link.py +14 -0
  30. geovisio/utils/params.py +20 -0
  31. geovisio/utils/pictures.py +92 -68
  32. geovisio/utils/reports.py +171 -0
  33. geovisio/utils/sequences.py +264 -126
  34. geovisio/utils/tokens.py +37 -42
  35. geovisio/utils/upload_set.py +654 -0
  36. geovisio/web/auth.py +37 -37
  37. geovisio/web/collections.py +286 -302
  38. geovisio/web/configuration.py +14 -0
  39. geovisio/web/docs.py +241 -14
  40. geovisio/web/excluded_areas.py +377 -0
  41. geovisio/web/items.py +156 -108
  42. geovisio/web/map.py +20 -20
  43. geovisio/web/params.py +69 -26
  44. geovisio/web/pictures.py +14 -31
  45. geovisio/web/reports.py +399 -0
  46. geovisio/web/rss.py +13 -7
  47. geovisio/web/stac.py +129 -134
  48. geovisio/web/tokens.py +98 -109
  49. geovisio/web/upload_set.py +768 -0
  50. geovisio/web/users.py +100 -73
  51. geovisio/web/utils.py +28 -9
  52. geovisio/workers/runner_pictures.py +252 -204
  53. {geovisio-2.6.0.dist-info → geovisio-2.7.0.dist-info}/METADATA +16 -13
  54. geovisio-2.7.0.dist-info/RECORD +66 -0
  55. geovisio-2.6.0.dist-info/RECORD +0 -41
  56. {geovisio-2.6.0.dist-info → geovisio-2.7.0.dist-info}/LICENSE +0 -0
  57. {geovisio-2.6.0.dist-info → geovisio-2.7.0.dist-info}/WHEEL +0 -0
@@ -0,0 +1,654 @@
1
+ from enum import Enum
2
+ import logging
3
+ import psycopg.rows
4
+ from pydantic import BaseModel, ConfigDict, computed_field, Field, field_serializer
5
+ from geovisio.utils.extent import TemporalExtent
6
+ from uuid import UUID
7
+ from typing import Optional, List, Dict, Any
8
+ from datetime import datetime, timedelta
9
+ from geovisio.utils import db, sequences
10
+ from geovisio import errors
11
+ from geovisio.utils.link import make_link, Link
12
+ import psycopg
13
+ from psycopg.types.json import Jsonb
14
+ from psycopg.sql import SQL
15
+ from psycopg.rows import class_row, dict_row
16
+ from flask import current_app
17
+ from flask_babel import gettext as _
18
+ from geopic_tag_reader import sequence as geopic_sequence, reader
19
+
20
+
21
+ class AggregatedStatus(BaseModel):
22
+ """Aggregated status"""
23
+
24
+ prepared: int
25
+ """Number of pictures successfully processed"""
26
+ preparing: Optional[int]
27
+ """Number of pictures being processed"""
28
+ broken: Optional[int]
29
+ """Number of pictures that failed to be processed. It is likely a server problem."""
30
+ rejected: Optional[int] = None
31
+ """Number of pictures that were rejected by the server. It is likely a client problem."""
32
+ not_processed: Optional[int]
33
+ """Number of pictures that have not been processed yet"""
34
+
35
+ model_config = ConfigDict(use_attribute_docstrings=True)
36
+
37
+
38
+ class AssociatedCollection(BaseModel):
39
+ """Collection associated to an UploadSet"""
40
+
41
+ id: UUID
42
+ nb_items: int
43
+ extent: Optional[TemporalExtent] = None
44
+ title: Optional[str] = None
45
+ items_status: Optional[AggregatedStatus] = None
46
+ status: Optional[str] = Field(exclude=True, default=None)
47
+
48
+ @computed_field
49
+ @property
50
+ def links(self) -> List[Link]:
51
+ return [
52
+ make_link(rel="self", route="stac_collections.getCollection", collectionId=self.id),
53
+ ]
54
+
55
+ @computed_field
56
+ @property
57
+ def ready(self) -> Optional[bool]:
58
+ if self.items_status is None:
59
+ return None
60
+ return self.items_status.not_processed == 0 and self.status == "ready"
61
+
62
+
63
+ class UploadSet(BaseModel):
64
+ """The UploadSet represent a group of files sent in one upload. Those files will be distributed among one or more collections."""
65
+
66
+ id: UUID
67
+ created_at: datetime
68
+ completed: bool
69
+ dispatched: bool
70
+ account_id: UUID
71
+ title: str
72
+ estimated_nb_files: Optional[int] = None
73
+ sort_method: geopic_sequence.SortMethod
74
+ split_distance: int
75
+ split_time: timedelta
76
+ duplicate_distance: float
77
+ duplicate_rotation: int
78
+ metadata: Optional[Dict[str, Any]]
79
+ user_agent: Optional[str] = Field(exclude=True)
80
+ associated_collections: List[AssociatedCollection] = []
81
+ nb_items: int = 0
82
+ items_status: Optional[AggregatedStatus] = None
83
+
84
+ @computed_field
85
+ @property
86
+ def links(self) -> List[Link]:
87
+ return [
88
+ make_link(rel="self", route="upload_set.getUploadSet", upload_set_id=self.id),
89
+ ]
90
+
91
+ @computed_field
92
+ @property
93
+ def ready(self) -> bool:
94
+ return self.dispatched and all(c.ready for c in self.associated_collections)
95
+
96
+ model_config = ConfigDict(use_enum_values=True, ser_json_timedelta="float", use_attribute_docstrings=True)
97
+
98
+
99
+ class UploadSets(BaseModel):
100
+ upload_sets: List[UploadSet]
101
+
102
+
103
+ class FileType(Enum):
104
+ """Type of uploadedfile"""
105
+
106
+ picture = "picture"
107
+ # Note: for the moment we only support pictures, but later we might accept more kind of files (like gpx traces, video, ...)
108
+
109
+
110
+ class FileRejectionStatusSeverity(Enum):
111
+ error = "error"
112
+ warning = "warning"
113
+ info = "info"
114
+
115
+
116
+ class FileRejectionStatus(Enum):
117
+ capture_duplicate = "capture_duplicate"
118
+ """capture duplicate means there was another picture too near (in space and time)"""
119
+ file_duplicate = "file_duplicate"
120
+ """File duplicate means the same file was already uploaded"""
121
+ invalid_file = "invalid_file"
122
+ """invalid_file means the file is not a valid jpeg"""
123
+ invalid_metadata = "invalid_metadata"
124
+ """invalid_metadata means the file has invalid metadata"""
125
+ other_error = "other_error"
126
+ """other_error means there was an error that is not related to the picture itself"""
127
+
128
+
129
+ class UploadSetFile(BaseModel):
130
+ """File uploaded in an UploadSet"""
131
+
132
+ picture_id: Optional[UUID] = None
133
+ file_name: str
134
+ content_md5: Optional[UUID] = None
135
+ inserted_at: datetime
136
+ upload_set_id: UUID = Field(..., exclude=True)
137
+ rejection_status: Optional[FileRejectionStatus] = Field(None, exclude=True)
138
+ rejection_message: Optional[str] = Field(None, exclude=True)
139
+ file_type: Optional[FileType] = None
140
+ size: Optional[int] = None
141
+
142
+ @computed_field
143
+ @property
144
+ def links(self) -> List[Link]:
145
+ return [
146
+ make_link(rel="parent", route="upload_set.getUploadSet", upload_set_id=self.upload_set_id),
147
+ ]
148
+
149
+ @computed_field
150
+ @property
151
+ def rejected(self) -> Optional[Dict[str, Any]]:
152
+ if self.rejection_status is None:
153
+ return None
154
+ msg = None
155
+ severity = FileRejectionStatusSeverity.error
156
+ if self.rejection_message is None:
157
+ if self.rejection_status == FileRejectionStatus.capture_duplicate.value:
158
+ msg = _("The picture is too similar to another one (nearby and taken almost at the same time)")
159
+ severity = FileRejectionStatusSeverity.info
160
+ if self.rejection_status == FileRejectionStatus.invalid_file.value:
161
+ msg = _("The sent file is not a valid JPEG")
162
+ severity = FileRejectionStatusSeverity.error
163
+ if self.rejection_status == FileRejectionStatus.invalid_metadata.value:
164
+ msg = _("The picture has invalid EXIF or XMP metadata, making it impossible to use")
165
+ severity = FileRejectionStatusSeverity.error
166
+ if self.rejection_status == FileRejectionStatus.other_error.value:
167
+ msg = _("Something went very wrong, but not due to the picture itself")
168
+ severity = FileRejectionStatusSeverity.error
169
+ else:
170
+ msg = self.rejection_message
171
+ return {
172
+ "reason": self.rejection_status,
173
+ "severity": severity,
174
+ "message": msg,
175
+ }
176
+
177
+ @field_serializer("content_md5")
178
+ def serialize_md5(self, md5: UUID, _info):
179
+ return md5.hex
180
+
181
+ model_config = ConfigDict(use_enum_values=True, use_attribute_docstrings=True)
182
+
183
+
184
+ class UploadSetFiles(BaseModel):
185
+ """List of files uploaded in an UploadSet"""
186
+
187
+ files: List[UploadSetFile]
188
+ upload_set_id: UUID = Field(..., exclude=True)
189
+
190
+ @computed_field
191
+ @property
192
+ def links(self) -> List[Link]:
193
+ return [
194
+ make_link(rel="self", route="upload_set.getUploadSet", upload_set_id=self.upload_set_id),
195
+ ]
196
+
197
+
198
+ def get_simple_upload_set(id: UUID) -> Optional[UploadSet]:
199
+ """Get the DB representation of an UploadSet, without associated collections and statuses"""
200
+ u = db.fetchone(
201
+ current_app,
202
+ SQL("SELECT * FROM upload_sets WHERE id = %(id)s AND not deleted"),
203
+ {"id": id},
204
+ row_factory=class_row(UploadSet),
205
+ )
206
+
207
+ return u
208
+
209
+
210
+ def get_upload_set(id: UUID) -> Optional[UploadSet]:
211
+ """Get the UploadSet corresponding to the ID"""
212
+ db_upload_set = db.fetchone(
213
+ current_app,
214
+ SQL(
215
+ """WITH picture_last_job AS (
216
+ SELECT p.id as picture_id,
217
+ -- Note: to know if a picture is beeing processed, check the latest job_history entry for this picture
218
+ -- If there is no finished_at, the picture is still beeing processed
219
+ (MAX(ARRAY [started_at, finished_at])) AS last_job,
220
+ p.preparing_status,
221
+ p.status,
222
+ p.upload_set_id
223
+ FROM pictures p
224
+ LEFT JOIN job_history ON p.id = job_history.picture_id
225
+ WHERE p.upload_set_id = %(id)s
226
+ GROUP BY p.id
227
+ ),
228
+ picture_statuses AS (
229
+ SELECT
230
+ *,
231
+ (last_job[1] IS NOT NULL AND last_job[2] IS NULL) AS is_job_running
232
+ FROM picture_last_job psj
233
+ ),
234
+ associated_collections AS (
235
+ SELECT
236
+ ps.upload_set_id,
237
+ COUNT(ps.picture_id) FILTER (WHERE ps.preparing_status = 'broken') AS nb_broken,
238
+ COUNT(ps.picture_id) FILTER (WHERE ps.preparing_status = 'prepared') AS nb_prepared,
239
+ COUNT(ps.picture_id) FILTER (WHERE ps.preparing_status = 'not-processed') AS nb_not_processed,
240
+ COUNT(ps.picture_id) FILTER (WHERE ps.is_job_running AND ps.status != 'waiting-for-delete') AS nb_preparing,
241
+ s.id as collection_id,
242
+ s.nb_pictures AS nb_items,
243
+ s.min_picture_ts AS mints,
244
+ s.max_picture_ts AS maxts,
245
+ s.metadata->>'title' AS title,
246
+ s.status AS status
247
+ FROM picture_statuses ps
248
+ JOIN sequences_pictures sp ON sp.pic_id = ps.picture_id
249
+ JOIN sequences s ON s.id = sp.seq_id
250
+ WHERE ps.upload_set_id = %(id)s AND s.status != 'deleted'
251
+ GROUP BY ps.upload_set_id,
252
+ s.id
253
+ ),
254
+ upload_set_statuses AS (
255
+ SELECT ps.upload_set_id,
256
+ COUNT(ps.picture_id) AS nb_items,
257
+ COUNT(ps.picture_id) FILTER (WHERE ps.preparing_status = 'broken') AS nb_broken,
258
+ COUNT(ps.picture_id) FILTER (WHERE ps.preparing_status = 'prepared') AS nb_prepared,
259
+ COUNT(ps.picture_id) FILTER (WHERE ps.preparing_status = 'not-processed') AS nb_not_processed,
260
+ COUNT(ps.picture_id) FILTER (WHERE ps.is_job_running) AS nb_preparing
261
+ FROM picture_statuses ps
262
+ GROUP BY ps.upload_set_id
263
+ )
264
+ SELECT u.*,
265
+ COALESCE(us.nb_items, 0) AS nb_items,
266
+ json_build_object(
267
+ 'broken', COALESCE(us.nb_broken, 0),
268
+ 'prepared', COALESCE(us.nb_prepared, 0),
269
+ 'not_processed', COALESCE(us.nb_not_processed, 0),
270
+ 'preparing', COALESCE(us.nb_preparing, 0),
271
+ 'rejected', (
272
+ SELECT count(*) FROM files
273
+ WHERE upload_set_id = %(id)s AND rejection_status IS NOT NULL
274
+ )
275
+ ) AS items_status,
276
+ COALESCE(
277
+ (
278
+ SELECT json_agg(
279
+ json_build_object(
280
+ 'id',
281
+ ac.collection_id,
282
+ 'title',
283
+ ac.title,
284
+ 'nb_items',
285
+ ac.nb_items,
286
+ 'status',
287
+ ac.status,
288
+ 'extent',
289
+ json_build_object(
290
+ 'temporal',
291
+ json_build_object(
292
+ 'interval',
293
+ json_build_array(
294
+ json_build_array(ac.mints, ac.maxts)
295
+ )
296
+ )
297
+ ),
298
+ 'items_status',
299
+ json_build_object(
300
+ 'broken', ac.nb_broken,
301
+ 'prepared', ac.nb_prepared,
302
+ 'not_processed', ac.nb_not_processed,
303
+ 'preparing', ac.nb_preparing
304
+ )
305
+ )
306
+ )
307
+ FROM associated_collections ac
308
+ ),
309
+ '[]'::json
310
+ ) AS associated_collections
311
+ FROM upload_sets u
312
+ LEFT JOIN upload_set_statuses us on us.upload_set_id = u.id
313
+ WHERE u.id = %(id)s AND not deleted"""
314
+ ),
315
+ {"id": id},
316
+ row_factory=class_row(UploadSet),
317
+ )
318
+
319
+ return db_upload_set
320
+
321
+
322
+ FIELD_TO_SQL_FILTER = {
323
+ "completed": "completed",
324
+ "dispatched": "dispatched",
325
+ }
326
+
327
+
328
+ def _parse_filter(filter: Optional[str]) -> SQL:
329
+ """
330
+ Parse a filter string and return a SQL expression
331
+
332
+ >>> _parse_filter('')
333
+ SQL('TRUE')
334
+ >>> _parse_filter(None)
335
+ SQL('TRUE')
336
+ >>> _parse_filter('completed = TRUE')
337
+ SQL('(completed = True)')
338
+ >>> _parse_filter('completed = TRUE AND dispatched = FALSE')
339
+ SQL('((completed = True) AND (dispatched = False))')
340
+ """
341
+ if not filter:
342
+ return SQL("TRUE")
343
+ from pygeofilter.backends.sql import to_sql_where
344
+ from pygeofilter.parsers.cql2_text import parse as cql_parser
345
+
346
+ try:
347
+ filterAst = cql_parser(filter)
348
+ f = to_sql_where(filterAst, FIELD_TO_SQL_FILTER).replace('"', "") # type: ignore
349
+ return SQL(f) # type: ignore
350
+ except Exception:
351
+ logging.error(f"Unsupported filter parameter: {filter}")
352
+ raise errors.InvalidAPIUsage(_("Unsupported filter parameter"), status_code=400)
353
+
354
+
355
+ def list_upload_sets(account_id: UUID, limit: int = 100, filter: Optional[str] = None) -> UploadSets:
356
+ filter_sql = _parse_filter(filter)
357
+ l = db.fetchall(
358
+ current_app,
359
+ SQL(
360
+ """SELECT
361
+ u.*,
362
+ COALESCE(
363
+ (
364
+ SELECT
365
+ json_agg(json_build_object(
366
+ 'id', ac.collection_id,
367
+ 'nb_items', ac.nb_items
368
+ ))
369
+ FROM (
370
+ SELECT
371
+ sp.seq_id as collection_id,
372
+ count(sp.pic_id) AS nb_items
373
+ FROM pictures p
374
+ JOIN sequences_pictures sp ON sp.pic_id = p.id
375
+ WHERE p.upload_set_id = u.id
376
+ GROUP BY sp.seq_id
377
+ ) ac
378
+ ),
379
+ '[]'::json
380
+ ) AS associated_collections,
381
+ (
382
+ SELECT count(*) AS nb
383
+ FROM pictures p
384
+ WHERE p.upload_set_id = u.id
385
+ ) AS nb_items
386
+ FROM upload_sets u
387
+ WHERE account_id = %(account_id)s AND not deleted AND {filter}
388
+ ORDER BY created_at ASC
389
+ LIMIT %(limit)s
390
+ """
391
+ ).format(filter=filter_sql),
392
+ {"account_id": account_id, "limit": limit},
393
+ row_factory=class_row(UploadSet),
394
+ )
395
+
396
+ return UploadSets(upload_sets=l)
397
+
398
+
399
+ def ask_for_dispatch(upload_set_id: UUID):
400
+ """Add a dispatch task to the job queue for the upload set. If there is already a task, postpone it."""
401
+ with db.conn(current_app) as conn:
402
+ conn.execute(
403
+ """INSERT INTO
404
+ job_queue(sequence_id, task)
405
+ VALUES (%(upload_set_id)s, 'dispatch')
406
+ ON CONFLICT (upload_set_id) DO UPDATE SET ts = CURRENT_TIMESTAMP""",
407
+ {"upload_set_id": upload_set_id},
408
+ )
409
+
410
+
411
+ def dispatch(upload_set_id: UUID):
412
+ """Finalize an upload set.
413
+
414
+ 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
415
+
416
+ Note: even if all pictures are not prepared, it's not a problem as we only need the pictures metadata for distributing them in collections
417
+ """
418
+
419
+ db_upload_set = get_simple_upload_set(upload_set_id)
420
+ if not db_upload_set:
421
+ raise Exception(f"Upload set {upload_set_id} not found")
422
+
423
+ with db.conn(current_app) as conn:
424
+ with conn.transaction(), conn.cursor(row_factory=dict_row) as cursor:
425
+
426
+ # get all the pictures of the upload set
427
+ db_pics = cursor.execute(
428
+ SQL(
429
+ """SELECT
430
+ p.id,
431
+ p.ts,
432
+ ST_X(p.geom) as lon,
433
+ ST_Y(p.geom) as lat,
434
+ p.heading as heading,
435
+ p.metadata->>'originalFileName' as file_name,
436
+ p.metadata,
437
+ s.id as sequence_id
438
+ FROM pictures p
439
+ LEFT JOIN sequences_pictures sp ON sp.pic_id = p.id
440
+ LEFT JOIN sequences s ON s.id = sp.seq_id
441
+ WHERE p.upload_set_id = %(upload_set_id)s"""
442
+ ),
443
+ {"upload_set_id": upload_set_id},
444
+ ).fetchall()
445
+
446
+ pics_by_filename = {p["file_name"]: p for p in db_pics}
447
+ pics = [
448
+ geopic_sequence.Picture(
449
+ p["file_name"],
450
+ reader.GeoPicTags(
451
+ lon=p["lon"],
452
+ lat=p["lat"],
453
+ ts=p["ts"],
454
+ type=p["metadata"]["type"],
455
+ heading=p["heading"],
456
+ make=p["metadata"]["make"],
457
+ model=p["metadata"]["model"],
458
+ focal_length=p["metadata"]["focal_length"],
459
+ crop=p["metadata"]["crop"],
460
+ exif={},
461
+ ),
462
+ )
463
+ for p in db_pics
464
+ ]
465
+
466
+ report = geopic_sequence.dispatch_pictures(
467
+ pics,
468
+ mergeParams=geopic_sequence.MergeParams(
469
+ maxDistance=db_upload_set.duplicate_distance, maxRotationAngle=db_upload_set.duplicate_rotation
470
+ ),
471
+ sortMethod=db_upload_set.sort_method,
472
+ splitParams=geopic_sequence.SplitParams(maxDistance=db_upload_set.split_distance, maxTime=db_upload_set.split_time.seconds),
473
+ )
474
+ reused_sequence = set()
475
+
476
+ pics_to_delete = [pics_by_filename[p.filename]["id"] for p in report.duplicate_pictures or []]
477
+ if pics_to_delete:
478
+ logging.debug(f"For uploadset '{upload_set_id}', nb duplicate pictures {len(pics_to_delete)}")
479
+ logging.debug(
480
+ f"For uploadset '{upload_set_id}', duplicate pictures {[p.filename for p in report.duplicate_pictures or []]}"
481
+ )
482
+
483
+ cursor.execute(SQL("CREATE TEMPORARY TABLE tmp_duplicates(picture_id UUID) ON COMMIT DROP"))
484
+ with cursor.copy("COPY tmp_duplicates(picture_id) FROM stdin;") as copy:
485
+ for p in pics_to_delete:
486
+ copy.write_row((p,))
487
+
488
+ cursor.execute(
489
+ SQL(
490
+ "UPDATE files SET rejection_status = 'capture_duplicate' WHERE picture_id IN (select picture_id from tmp_duplicates)"
491
+ )
492
+ )
493
+ cursor.execute(
494
+ SQL(
495
+ """INSERT INTO job_queue (picture_id, task)
496
+ SELECT picture_id, 'delete'
497
+ FROM tmp_duplicates
498
+ ON CONFLICT(picture_id) DO UPDATE SET task = 'delete'"""
499
+ )
500
+ )
501
+
502
+ # ask for deletion of the pictures
503
+
504
+ for s in report.sequences:
505
+ existing_sequence = next(
506
+ (seq for p in s.pictures if (seq := pics_by_filename[p.filename]["sequence_id"]) not in reused_sequence),
507
+ None,
508
+ )
509
+ # if some of the pictures were already in a sequence, we should not create a new one
510
+ if existing_sequence:
511
+ logging.info(
512
+ f"For uploadset '{upload_set_id}', sequence {existing_sequence} already contains pictures, we will not create a new one"
513
+ )
514
+ # we should wipe the sequences_pictures though
515
+ seq_id = existing_sequence
516
+ cursor.execute(
517
+ SQL("DELETE FROM sequences_pictures WHERE seq_id = %(seq_id)s"),
518
+ {"seq_id": seq_id},
519
+ )
520
+ reused_sequence.add(seq_id)
521
+ else:
522
+ seq_id = cursor.execute(
523
+ SQL(
524
+ """INSERT INTO sequences(account_id, metadata, user_agent)
525
+ VALUES (%(account_id)s, %(metadata)s, %(user_agent)s)
526
+ RETURNING id"""
527
+ ),
528
+ {
529
+ "account_id": db_upload_set.account_id,
530
+ "metadata": Jsonb({"title": db_upload_set.title}),
531
+ "user_agent": db_upload_set.user_agent,
532
+ },
533
+ ).fetchone()
534
+ seq_id = seq_id["id"]
535
+
536
+ with cursor.copy("COPY sequences_pictures(seq_id, pic_id, rank) FROM stdin;") as copy:
537
+ for i, p in enumerate(s.pictures, 1):
538
+ copy.write_row(
539
+ (seq_id, pics_by_filename[p.filename]["id"], i),
540
+ )
541
+
542
+ sequences.add_finalization_job(cursor=cursor, seqId=seq_id)
543
+
544
+ for s in report.sequences_splits or []:
545
+ logging.debug(f"For uploadset '{upload_set_id}', split = {s.prevPic.filename} -> {s.nextPic.filename} : {s.reason}")
546
+ conn.execute(SQL("UPDATE upload_sets SET dispatched = true WHERE id = %(upload_set_id)s"), {"upload_set_id": db_upload_set.id})
547
+
548
+
549
+ def insertFileInDatabase(
550
+ *,
551
+ cursor: psycopg.Cursor[psycopg.rows.DictRow],
552
+ upload_set_id: UUID,
553
+ file_name: str,
554
+ content_md5: Optional[str] = None,
555
+ size: Optional[int] = None,
556
+ file_type: Optional[FileType] = None,
557
+ picture_id: Optional[UUID] = None,
558
+ rejection_status: Optional[FileRejectionStatus] = None,
559
+ rejection_message: Optional[str] = None,
560
+ ) -> UploadSetFile:
561
+ """Insert a file linked to an UploadSet into the database"""
562
+
563
+ f = cursor.execute(
564
+ SQL(
565
+ """
566
+ INSERT INTO files(
567
+ upload_set_id, picture_id, file_type, file_name,
568
+ size, content_md5, rejection_status, rejection_message)
569
+ VALUES (
570
+ %(upload_set_id)s, %(picture_id)s, %(type)s, %(file_name)s,
571
+ %(size)s, %(content_md5)s, %(rejection_status)s, %(rejection_message)s)
572
+ ON CONFLICT (upload_set_id, file_name)
573
+ DO UPDATE SET picture_id = %(picture_id)s, size = %(size)s, content_md5 = %(content_md5)s,
574
+ rejection_status = %(rejection_status)s, rejection_message = %(rejection_message)s
575
+ RETURNING *
576
+ """
577
+ ),
578
+ params={
579
+ "upload_set_id": upload_set_id,
580
+ "type": file_type,
581
+ "picture_id": picture_id,
582
+ "file_name": file_name,
583
+ "size": size,
584
+ "content_md5": content_md5,
585
+ "rejection_status": rejection_status,
586
+ "rejection_message": rejection_message,
587
+ },
588
+ )
589
+ return UploadSetFile(**f.fetchone())
590
+
591
+
592
+ def get_upload_set_files(upload_set_id: UUID) -> UploadSetFiles:
593
+ """Get the files of an UploadSet"""
594
+ files = db.fetchall(
595
+ current_app,
596
+ SQL(
597
+ """SELECT
598
+ upload_set_id,
599
+ file_type,
600
+ file_name,
601
+ size,
602
+ content_md5,
603
+ rejection_status,
604
+ rejection_message,
605
+ picture_id,
606
+ inserted_at
607
+ FROM files
608
+ WHERE upload_set_id = %(upload_set_id)s
609
+ ORDER BY inserted_at"""
610
+ ),
611
+ {"upload_set_id": upload_set_id},
612
+ row_factory=dict_row,
613
+ )
614
+ return UploadSetFiles(files=files, upload_set_id=upload_set_id)
615
+
616
+
617
+ def delete(upload_set: UploadSet):
618
+ """Delete an UploadSet"""
619
+ logging.info(f"Asking for deletion of uploadset {upload_set.id}")
620
+ with db.conn(current_app) as conn:
621
+ with conn.transaction(), conn.cursor() as cursor:
622
+ for c in upload_set.associated_collections:
623
+ # Mark all collections as deleted, but do not delete them
624
+ # Note: we do not use utils.sequences.delete_collection here, since we also want to remove the pictures not associated to any collection
625
+ cursor.execute(SQL("DELETE FROM job_queue WHERE sequence_id = %s"), [c.id])
626
+ cursor.execute(SQL("UPDATE sequences SET status = 'deleted' WHERE id = %s"), [c.id])
627
+
628
+ # check utils.sequences.delete_collection to see why picture removal is done in 2 steps
629
+ cursor.execute(
630
+ """
631
+ INSERT INTO job_queue(picture_id, task)
632
+ SELECT id, 'delete'
633
+ FROM pictures
634
+ WHERE upload_set_id = %(upload_set_id)s
635
+ ON CONFLICT (picture_id) DO UPDATE SET task = 'delete'""",
636
+ {"upload_set_id": upload_set.id},
637
+ )
638
+
639
+ # after the task have been added to the queue, we mark all picture for deletion
640
+ cursor.execute(
641
+ SQL(
642
+ "UPDATE pictures SET status = 'waiting-for-delete' WHERE id IN (SELECT id FROM pictures WHERE upload_set_id = %(upload_set_id)s)"
643
+ ),
644
+ {"upload_set_id": upload_set.id},
645
+ )
646
+ # we insert the upload set deletion task in the queue after the rest, it will be done once all pictures are deleted
647
+ cursor.execute(
648
+ """INSERT INTO job_queue(upload_set_id, task) VALUES (%(upload_set_id)s, 'delete')
649
+ ON CONFLICT (upload_set_id) DO UPDATE SET task = 'delete'""",
650
+ {"upload_set_id": upload_set.id},
651
+ )
652
+
653
+ # and we mark it as deleted so it will disapear from the responses even if all the pictures are not yet deleted
654
+ cursor.execute("UPDATE upload_sets SET deleted = true WHERE id = %(upload_set_id)s", {"upload_set_id": upload_set.id})