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.
- geovisio/__init__.py +36 -7
- geovisio/admin_cli/db.py +1 -4
- geovisio/config_app.py +40 -1
- geovisio/db_migrations.py +24 -3
- geovisio/templates/main.html +13 -13
- geovisio/templates/viewer.html +3 -3
- geovisio/translations/de/LC_MESSAGES/messages.mo +0 -0
- geovisio/translations/de/LC_MESSAGES/messages.po +667 -0
- geovisio/translations/en/LC_MESSAGES/messages.mo +0 -0
- geovisio/translations/en/LC_MESSAGES/messages.po +730 -0
- geovisio/translations/es/LC_MESSAGES/messages.mo +0 -0
- geovisio/translations/es/LC_MESSAGES/messages.po +778 -0
- geovisio/translations/fi/LC_MESSAGES/messages.mo +0 -0
- geovisio/translations/fi/LC_MESSAGES/messages.po +589 -0
- geovisio/translations/fr/LC_MESSAGES/messages.mo +0 -0
- geovisio/translations/fr/LC_MESSAGES/messages.po +814 -0
- geovisio/translations/ko/LC_MESSAGES/messages.mo +0 -0
- geovisio/translations/ko/LC_MESSAGES/messages.po +685 -0
- geovisio/translations/messages.pot +686 -0
- geovisio/translations/nl/LC_MESSAGES/messages.mo +0 -0
- geovisio/translations/nl/LC_MESSAGES/messages.po +594 -0
- geovisio/utils/__init__.py +1 -1
- geovisio/utils/auth.py +50 -11
- geovisio/utils/db.py +65 -0
- geovisio/utils/excluded_areas.py +83 -0
- geovisio/utils/extent.py +30 -0
- geovisio/utils/fields.py +1 -1
- geovisio/utils/filesystems.py +0 -1
- geovisio/utils/link.py +14 -0
- geovisio/utils/params.py +20 -0
- geovisio/utils/pictures.py +92 -68
- geovisio/utils/reports.py +171 -0
- geovisio/utils/sequences.py +264 -126
- geovisio/utils/tokens.py +37 -42
- geovisio/utils/upload_set.py +654 -0
- geovisio/web/auth.py +37 -37
- geovisio/web/collections.py +286 -302
- geovisio/web/configuration.py +14 -0
- geovisio/web/docs.py +241 -14
- geovisio/web/excluded_areas.py +377 -0
- geovisio/web/items.py +156 -108
- geovisio/web/map.py +20 -20
- geovisio/web/params.py +69 -26
- geovisio/web/pictures.py +14 -31
- geovisio/web/reports.py +399 -0
- geovisio/web/rss.py +13 -7
- geovisio/web/stac.py +129 -134
- geovisio/web/tokens.py +98 -109
- geovisio/web/upload_set.py +768 -0
- geovisio/web/users.py +100 -73
- geovisio/web/utils.py +28 -9
- geovisio/workers/runner_pictures.py +252 -204
- {geovisio-2.6.0.dist-info → geovisio-2.7.0.dist-info}/METADATA +16 -13
- geovisio-2.7.0.dist-info/RECORD +66 -0
- geovisio-2.6.0.dist-info/RECORD +0 -41
- {geovisio-2.6.0.dist-info → geovisio-2.7.0.dist-info}/LICENSE +0 -0
- {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})
|