geovisio 2.5.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 (59) hide show
  1. geovisio/__init__.py +38 -8
  2. geovisio/admin_cli/__init__.py +2 -2
  3. geovisio/admin_cli/db.py +8 -0
  4. geovisio/config_app.py +64 -0
  5. geovisio/db_migrations.py +24 -3
  6. geovisio/templates/main.html +14 -14
  7. geovisio/templates/viewer.html +3 -3
  8. geovisio/translations/de/LC_MESSAGES/messages.mo +0 -0
  9. geovisio/translations/de/LC_MESSAGES/messages.po +667 -0
  10. geovisio/translations/en/LC_MESSAGES/messages.mo +0 -0
  11. geovisio/translations/en/LC_MESSAGES/messages.po +730 -0
  12. geovisio/translations/es/LC_MESSAGES/messages.mo +0 -0
  13. geovisio/translations/es/LC_MESSAGES/messages.po +778 -0
  14. geovisio/translations/fi/LC_MESSAGES/messages.mo +0 -0
  15. geovisio/translations/fi/LC_MESSAGES/messages.po +589 -0
  16. geovisio/translations/fr/LC_MESSAGES/messages.mo +0 -0
  17. geovisio/translations/fr/LC_MESSAGES/messages.po +814 -0
  18. geovisio/translations/ko/LC_MESSAGES/messages.mo +0 -0
  19. geovisio/translations/ko/LC_MESSAGES/messages.po +685 -0
  20. geovisio/translations/messages.pot +686 -0
  21. geovisio/translations/nl/LC_MESSAGES/messages.mo +0 -0
  22. geovisio/translations/nl/LC_MESSAGES/messages.po +594 -0
  23. geovisio/utils/__init__.py +1 -1
  24. geovisio/utils/auth.py +50 -11
  25. geovisio/utils/db.py +65 -0
  26. geovisio/utils/excluded_areas.py +83 -0
  27. geovisio/utils/extent.py +30 -0
  28. geovisio/utils/fields.py +1 -1
  29. geovisio/utils/filesystems.py +0 -1
  30. geovisio/utils/link.py +14 -0
  31. geovisio/utils/params.py +20 -0
  32. geovisio/utils/pictures.py +94 -69
  33. geovisio/utils/reports.py +171 -0
  34. geovisio/utils/sequences.py +288 -126
  35. geovisio/utils/tokens.py +37 -42
  36. geovisio/utils/upload_set.py +654 -0
  37. geovisio/web/auth.py +50 -37
  38. geovisio/web/collections.py +305 -319
  39. geovisio/web/configuration.py +14 -0
  40. geovisio/web/docs.py +288 -12
  41. geovisio/web/excluded_areas.py +377 -0
  42. geovisio/web/items.py +203 -151
  43. geovisio/web/map.py +322 -106
  44. geovisio/web/params.py +69 -26
  45. geovisio/web/pictures.py +14 -31
  46. geovisio/web/reports.py +399 -0
  47. geovisio/web/rss.py +13 -7
  48. geovisio/web/stac.py +129 -121
  49. geovisio/web/tokens.py +105 -112
  50. geovisio/web/upload_set.py +768 -0
  51. geovisio/web/users.py +100 -73
  52. geovisio/web/utils.py +38 -9
  53. geovisio/workers/runner_pictures.py +278 -183
  54. geovisio-2.7.0.dist-info/METADATA +95 -0
  55. geovisio-2.7.0.dist-info/RECORD +66 -0
  56. geovisio-2.5.0.dist-info/METADATA +0 -115
  57. geovisio-2.5.0.dist-info/RECORD +0 -41
  58. {geovisio-2.5.0.dist-info → geovisio-2.7.0.dist-info}/LICENSE +0 -0
  59. {geovisio-2.5.0.dist-info → geovisio-2.7.0.dist-info}/WHEEL +0 -0
@@ -0,0 +1,768 @@
1
+ from dataclasses import dataclass
2
+
3
+ import PIL
4
+ from geovisio.utils import auth
5
+ from psycopg.rows import class_row, dict_row
6
+ from psycopg.sql import SQL
7
+ from flask import current_app, request, Blueprint, url_for
8
+ from flask_babel import gettext as _, get_locale
9
+ from geopic_tag_reader import sequence as geopic_sequence
10
+ from geovisio.web.utils import accountIdOrDefault
11
+ from psycopg.types.json import Jsonb
12
+ from geovisio.web.params import (
13
+ as_latitude,
14
+ as_longitude,
15
+ parse_datetime,
16
+ )
17
+ import logging
18
+ from geovisio.utils import db
19
+ from geovisio import utils
20
+ from geopic_tag_reader.writer import writePictureMetadata, PictureMetadata
21
+ from geovisio.utils.params import validation_error
22
+ from geovisio import errors
23
+ from pydantic import BaseModel, ConfigDict, ValidationError, Field, field_validator, model_validator
24
+ from uuid import UUID
25
+ from werkzeug.datastructures import FileStorage
26
+ from datetime import timedelta, datetime
27
+ from geovisio.utils.upload_set import (
28
+ FileRejectionStatus,
29
+ FileType,
30
+ UploadSet,
31
+ get_simple_upload_set,
32
+ get_upload_set,
33
+ get_upload_set_files,
34
+ list_upload_sets,
35
+ )
36
+ import os
37
+ import hashlib
38
+ import sentry_sdk
39
+ from typing import Optional, Any, Dict
40
+
41
+
42
+ bp = Blueprint("upload_set", __name__, url_prefix="/api")
43
+
44
+
45
+ class UploadSetCreationParameter(BaseModel):
46
+ """Parameters used to create an UploadSet"""
47
+
48
+ title: str
49
+ """Title of the upload. The title will be used to generate a name for the collections"""
50
+ estimated_nb_files: Optional[int] = None
51
+ """Estimated number of items that will be sent to the UploadSet"""
52
+ sort_method: Optional[geopic_sequence.SortMethod] = None
53
+ """Strategy used for sorting your pictures. Either by filename or EXIF time, in ascending or descending order."""
54
+ split_distance: Optional[int] = None
55
+ """Maximum distance between two pictures to be considered in the same sequence (in meters)."""
56
+ split_time: Optional[timedelta] = None
57
+ """Maximum time interval between two pictures to be considered in the same sequence."""
58
+ duplicate_distance: Optional[float] = None
59
+ """Maximum distance between two pictures to be considered as duplicates (in meters)."""
60
+ duplicate_rotation: Optional[int] = None
61
+ """Maximum angle of rotation for two too-close-pictures to be considered as duplicates (in degrees)."""
62
+ metadata: Optional[Dict[str, Any]] = None
63
+ """Optional metadata associated to the upload set. Can contain any key-value pair."""
64
+ user_agent: Optional[str] = None
65
+ """Software used by client to create this upload set, in HTTP Header User-Agent format"""
66
+
67
+ model_config = ConfigDict(use_attribute_docstrings=True)
68
+
69
+
70
+ def create_upload_set(params: UploadSetCreationParameter, accountId: UUID) -> UploadSet:
71
+ params_as_dict = params.model_dump(exclude_none=True) | {"account_id": accountId}
72
+
73
+ fields = [SQL(f) for f in params_as_dict.keys()] # type: ignore (we can ignore psycopg types there as we control those keys since they are the attributes of UploadSetCreationParameter)
74
+ values = [SQL(f"%({f})s") for f in params_as_dict.keys()] # type: ignore
75
+ for k, v in params_as_dict.items():
76
+ if isinstance(v, Dict):
77
+ params_as_dict[k] = Jsonb(v) # convert dict to jsonb in database
78
+
79
+ db_upload_set = db.fetchone(
80
+ current_app,
81
+ SQL("INSERT INTO upload_sets({fields}) VALUES({values}) RETURNING *").format(
82
+ fields=SQL(", ").join(fields), values=SQL(", ").join(values)
83
+ ),
84
+ params_as_dict,
85
+ row_factory=class_row(UploadSet),
86
+ )
87
+
88
+ if db_upload_set is None:
89
+ raise Exception("Impossible to insert sequence in database")
90
+
91
+ return db_upload_set
92
+
93
+
94
+ @bp.route("/upload_sets", methods=["POST"])
95
+ @auth.login_required_by_setting("API_FORCE_AUTH_ON_UPLOAD")
96
+ def postUploadSet(account=None):
97
+ """Create a new UploadSet
98
+
99
+ The UploadSet are used to group pictures during an upload.
100
+ The pictures will be dispatch to several collections when the UploadSet will be completed
101
+ ---
102
+ tags:
103
+ - Upload
104
+ - UploadSet
105
+ parameters:
106
+ - in: header
107
+ name: User-Agent
108
+ required: false
109
+ schema:
110
+ type: string
111
+ description: An explicit User-Agent value is prefered if you create a production-ready tool, formatted like "GeoVisioCLI/1.0"
112
+ requestBody:
113
+ content:
114
+ application/json:
115
+ schema:
116
+ $ref: '#/components/schemas/GeoVisioPostUploadSet'
117
+ security:
118
+ - bearerToken: []
119
+ - cookieAuth: []
120
+ responses:
121
+ 200:
122
+ description: the UploadSet metadata
123
+ content:
124
+ application/json:
125
+ schema:
126
+ $ref: '#/components/schemas/GeoVisioUploadSet'
127
+ """
128
+
129
+ if request.is_json and request.json is not None:
130
+ try:
131
+ params = UploadSetCreationParameter(user_agent=request.user_agent.string, **request.json)
132
+ except ValidationError as ve:
133
+ raise errors.InvalidAPIUsage(_("Impossible to create an UploadSet"), payload=validation_error(ve))
134
+ else:
135
+ raise errors.InvalidAPIUsage(_("Parameter for creating an UploadSet should be a valid JSON"), status_code=415)
136
+
137
+ account_id = UUID(accountIdOrDefault(account))
138
+
139
+ upload_set = create_upload_set(params, account_id)
140
+
141
+ return (
142
+ upload_set.model_dump_json(exclude_none=True),
143
+ 200,
144
+ {
145
+ "Content-Type": "application/json",
146
+ "Access-Control-Expose-Headers": "Location", # Needed for allowing web browsers access Location header
147
+ "Location": url_for("upload_set.getUploadSet", _external=True, upload_set_id=upload_set.id),
148
+ },
149
+ )
150
+
151
+
152
+ @bp.route("/upload_sets/<uuid:upload_set_id>", methods=["GET"])
153
+ def getUploadSet(upload_set_id):
154
+ """Get an existing UploadSet
155
+
156
+ The UploadSet are used to group pictures during an upload.
157
+ ---
158
+ tags:
159
+ - Upload
160
+ - UploadSet
161
+ parameters:
162
+ - name: upload_set_id
163
+ in: path
164
+ description: ID of the UploadSet to retrieve
165
+ required: true
166
+ schema:
167
+ type: string
168
+ security:
169
+ - bearerToken: []
170
+ - cookieAuth: []
171
+ responses:
172
+ 200:
173
+ description: the UploadSet metadata
174
+ content:
175
+ application/json:
176
+ schema:
177
+ $ref: '#/components/schemas/GeoVisioUploadSet'
178
+ """
179
+ upload_set = get_upload_set(upload_set_id)
180
+ if upload_set is None:
181
+ raise errors.InvalidAPIUsage(_("UploadSet doesn't exist"), status_code=404)
182
+
183
+ return upload_set.model_dump_json(exclude_none=True), 200, {"Content-Type": "application/json"}
184
+
185
+
186
+ @bp.route("/upload_sets/<uuid:upload_set_id>/files", methods=["GET"])
187
+ @auth.login_required_by_setting("API_FORCE_AUTH_ON_UPLOAD")
188
+ def getUploadSetFiles(upload_set_id, account=None):
189
+ """List the files of an UploadSet
190
+ ---
191
+ tags:
192
+ - Upload
193
+ - UploadSet
194
+ parameters:
195
+ - name: upload_set_id
196
+ in: path
197
+ description: ID of the UploadSet
198
+ required: true
199
+ schema:
200
+ type: string
201
+ security:
202
+ - bearerToken: []
203
+ - cookieAuth: []
204
+ responses:
205
+ 200:
206
+ description: the UploadSet files list
207
+ content:
208
+ application/json:
209
+ schema:
210
+ $ref: '#/components/schemas/GeoVisioUploadSetFiles'
211
+ """
212
+ u = get_simple_upload_set(upload_set_id)
213
+ if u is None:
214
+ raise errors.InvalidAPIUsage(_("UploadSet doesn't exist"), status_code=404)
215
+ if account is not None and account.id != str(u.account_id):
216
+ raise errors.InvalidAPIUsage(_("You're not authorized to list pictures in this upload set"), status_code=403)
217
+
218
+ upload_set_files = get_upload_set_files(upload_set_id)
219
+ return upload_set_files.model_dump_json(exclude_none=True), 200, {"Content-Type": "application/json"}
220
+
221
+
222
+ class ListUploadSetParameter(BaseModel):
223
+ """Parameters used to list a user's UploadSet"""
224
+
225
+ account_id: UUID
226
+ limit: int = Field(default=100, ge=0, le=1000)
227
+ filter: Optional[str] = "dispatched = FALSE"
228
+ """Filter to apply to the list of UploadSet. The filter should be a valid SQL WHERE clause"""
229
+
230
+
231
+ @bp.route("/users/me/upload_sets", methods=["GET"])
232
+ @auth.login_required_with_redirect()
233
+ def listUserUpload(account):
234
+ """List the upload of a user
235
+
236
+ The UploadSet are used to group pictures during an upload.
237
+ ---
238
+ tags:
239
+ - Upload
240
+ - UploadSet
241
+ parameters:
242
+ - $ref: '#/components/parameters/UploadSetFilter'
243
+ - name: limit
244
+ in: query
245
+ description: limit to the number of upload set to retrieve
246
+ required: true
247
+ schema:
248
+ type: integer
249
+ minimum: 1
250
+ maximum: 100
251
+ security:
252
+ - bearerToken: []
253
+ - cookieAuth: []
254
+ responses:
255
+ 200:
256
+ description: the UploadSet metadata
257
+ content:
258
+ application/json:
259
+ schema:
260
+ $ref: '#/components/schemas/GeoVisioUploadSets'
261
+ """
262
+ try:
263
+ params = ListUploadSetParameter(account_id=UUID(account.id), **request.args)
264
+ except ValidationError as ve:
265
+ raise errors.InvalidAPIUsage(_("Impossible to parse parameters"), payload=validation_error(ve))
266
+
267
+ upload_sets = list_upload_sets(account_id=params.account_id, limit=params.limit, filter=params.filter)
268
+
269
+ return upload_sets.model_dump_json(exclude_none=True), 200, {"Content-Type": "application/json"}
270
+
271
+
272
+ # Note: class used to generate documentation
273
+ class AddFileToUploadSetParameter(BaseModel):
274
+ """Parameters used to add an item to an UploadSet"""
275
+
276
+ override_capture_time: Optional[datetime] = None
277
+ """Override the capture time of the picture. The new capture time will also be persisted in the picture's exif tags"""
278
+ override_longitude: Optional[float] = None
279
+ """Override the longitude of the picture. The new longitude will also be persisted in the picture's exif tags"""
280
+ override_latitude: Optional[float] = None
281
+ """Override the latitude of the picture. The new latitude will also be persisted in the picture's exif tags"""
282
+
283
+ extra_exif: Optional[Dict[str, str]] = None
284
+ """Extra Exif metadata can be added to the picture. They need to be named `override_` and have the full exiv2 path of the tag.
285
+ For example, to override the `Exif.Image.Orientation` tag, you should use `override_Exif.Image.Orientation` as the key"""
286
+
287
+ """External metadata to add to the picture"""
288
+ isBlurred: bool = False
289
+ """True if the picture is already blurred, False otherwise"""
290
+
291
+ file: bytes
292
+ """File to upload"""
293
+
294
+ model_config = ConfigDict(use_attribute_docstrings=True)
295
+
296
+ @field_validator("override_capture_time", mode="before")
297
+ @classmethod
298
+ def parse_capture_time(cls, value):
299
+ if value is None:
300
+ return None
301
+ return parse_datetime(
302
+ value,
303
+ error=_(
304
+ "Parameter `override_capture_time` is not a valid datetime, it should be an iso formated datetime (like '2017-07-21T17:32:28Z')."
305
+ ),
306
+ )
307
+
308
+ @field_validator("override_longitude")
309
+ @classmethod
310
+ def parse_longitude(cls, value):
311
+ return as_longitude(value, error=_("For parameter `override_longitude`, `%(v)s` is not a valid longitude", v=value))
312
+
313
+ @field_validator("override_latitude")
314
+ @classmethod
315
+ def parse_latitude(cls, value):
316
+ return as_latitude(value, error=_("For parameter `override_latitude`, `%(v)s` is not a valid latitude", v=value))
317
+
318
+ @model_validator(mode="before")
319
+ @classmethod
320
+ def parse_extra_exif(cls, values: Dict) -> Dict:
321
+ # Check if others override elements were given
322
+ exif = {}
323
+ override_exif = [k for k in values.keys() if (k.startswith("override_Exif.") or k.startswith("override_Xmp."))]
324
+ for k in override_exif:
325
+ v = values.pop(k)
326
+ exif_tag = k.replace("override_", "")
327
+ exif[exif_tag] = v
328
+
329
+ values["extra_exif"] = exif
330
+
331
+ return values
332
+
333
+ @model_validator(mode="after")
334
+ def validate(self):
335
+ if self.override_latitude is None and self.override_longitude is not None:
336
+ raise errors.InvalidAPIUsage(_("Longitude cannot be overridden alone, override_latitude also needs to be set"))
337
+ if self.override_longitude is None and self.override_latitude is not None:
338
+ raise errors.InvalidAPIUsage(_("Latitude cannot be overridden alone, override_longitude also needs to be set"))
339
+ return self
340
+
341
+
342
+ # Note: class used to store parameters
343
+ @dataclass
344
+ class AddFileToUploadSetParsedParameter:
345
+ file: FileStorage
346
+ ext_mtd: Optional[PictureMetadata] = None
347
+ isBlurred: bool = False
348
+
349
+ file_type: FileType = Field(exclude=True)
350
+
351
+
352
+ class TrackedFileException(errors.InvalidAPIUsage):
353
+ def __init__(
354
+ self,
355
+ message: str,
356
+ rejection_status: FileRejectionStatus,
357
+ payload=None,
358
+ status_code: int = 400,
359
+ file: Optional[Dict[str, Any]] = None,
360
+ ):
361
+ super().__init__(message=message, status_code=status_code, payload=payload)
362
+ self.rejection_status = rejection_status
363
+ self.file = file
364
+
365
+
366
+ def _read_add_items_params(form, files) -> AddFileToUploadSetParsedParameter:
367
+
368
+ if "file" not in files:
369
+ # Note: we do not want to track this as it is a bad use of the API
370
+ raise errors.InvalidAPIUsage(_("No file was sent"), status_code=400)
371
+ # Note: for the moment we only accept `picture` in files, but later we might accept more kind of files (like gpx traces, video, ...) and autodetect them here
372
+ file_type = FileType.picture
373
+
374
+ file = files["file"]
375
+ if not (file.filename and "." in file.filename and file.filename.rsplit(".", 1)[1].lower() in ["jpg", "jpeg"]):
376
+ raise TrackedFileException(
377
+ _("Picture file is either missing or in an unsupported format (should be jpg)"),
378
+ rejection_status=FileRejectionStatus.invalid_file,
379
+ file=dict(file_name=os.path.basename(file.filename), file_type=file_type),
380
+ )
381
+
382
+ try:
383
+ params = AddFileToUploadSetParameter(file=b"", **form)
384
+ except ValidationError as ve:
385
+ raise errors.InvalidAPIUsage(_("Impossible to parse parameters"), payload=validation_error(ve))
386
+
387
+ # Check if datetime was given
388
+ if (
389
+ params.override_capture_time is not None
390
+ or params.override_latitude is not None
391
+ or params.override_longitude is not None
392
+ or params.extra_exif
393
+ ):
394
+ ext_mtd = PictureMetadata(
395
+ capture_time=params.override_capture_time,
396
+ latitude=params.override_latitude,
397
+ longitude=params.override_longitude,
398
+ additional_exif=params.extra_exif,
399
+ )
400
+ else:
401
+ ext_mtd = None
402
+
403
+ return AddFileToUploadSetParsedParameter(ext_mtd=ext_mtd, isBlurred=params.isBlurred, file=file, file_type=file_type)
404
+
405
+
406
+ def un_complete_upload_set(cursor, upload_set_id: UUID):
407
+ """Marks the upload set as uncompleted"""
408
+ cursor.execute(
409
+ "UPDATE upload_sets SET completed = FALSE WHERE id = %(id)s",
410
+ {"id": upload_set_id},
411
+ )
412
+
413
+
414
+ def mark_upload_set_completed_if_needed(cursor, upload_set_id: UUID) -> bool:
415
+ """
416
+ Marks the upload set as completed if the number of pictures in the upload set
417
+ is greater than or equal to the estimated number of files.
418
+
419
+ Args:
420
+ cursor: The database cursor object.
421
+ upload_set_id: The ID of the upload set.
422
+
423
+ Returns:
424
+ bool: True if the upload set is marked as completed, False otherwise.
425
+ """
426
+ r = cursor.execute(
427
+ """WITH nb_items AS (
428
+ SELECT count(*) AS nb, upload_set_id
429
+ FROM files f
430
+ WHERE upload_set_id = %(id)s
431
+ GROUP BY upload_set_id
432
+ )
433
+ UPDATE upload_sets
434
+ SET completed = (nb_items.nb = estimated_nb_files)
435
+ FROM nb_items
436
+ WHERE id = %(id)s AND estimated_nb_files IS NOT NULL
437
+ RETURNING completed;""",
438
+ {"id": upload_set_id},
439
+ ).fetchone()
440
+
441
+ return r is not None and r["completed"]
442
+
443
+
444
+ def handle_completion(cursor, upload_set):
445
+ """
446
+ At the end of an upload, we need to check if the upload needs to be completed or not
447
+ * If is not yet completed, we check if we received the expected number of files
448
+ * If is already completed, we mark it as uncompleted as we don't know if the client will send more pictures
449
+ """
450
+ if not upload_set["completed"]:
451
+ mark_upload_set_completed_if_needed(cursor, upload_set["id"])
452
+ else:
453
+ # if the upload set is already completed and some pictures were added, we need to mark it as uncompleted as we don't know if the client will send more pictures
454
+ un_complete_upload_set(cursor, upload_set["id"])
455
+
456
+
457
+ @bp.route("/upload_sets/<uuid:upload_set_id>/files", methods=["POST"])
458
+ @auth.login_required_by_setting("API_FORCE_AUTH_ON_UPLOAD")
459
+ def addFilesToUploadSet(upload_set_id: UUID, account=None):
460
+ """Add files to an UploadSet
461
+
462
+ ---
463
+ tags:
464
+ - Upload
465
+ - UploadSet
466
+ parameters:
467
+ - name: upload_set_id
468
+ in: path
469
+ description: ID of the UploadSet
470
+ required: true
471
+ schema:
472
+ type: string
473
+ requestBody:
474
+ content:
475
+ multipart/form-data:
476
+ schema:
477
+ $ref: '#/components/schemas/GeoVisioAddToUploadSet'
478
+ security:
479
+ - bearerToken: []
480
+ - cookieAuth: []
481
+ responses:
482
+ 202:
483
+ description: The UploadSet metadata
484
+ content:
485
+ application/json:
486
+ schema:
487
+ $ref: '#/components/schemas/GeoVisioUploadSetFile'
488
+ 400:
489
+ description: Error if the request is malformed
490
+ content:
491
+ application/json:
492
+ schema:
493
+ $ref: '#/components/schemas/GeoVisioError'
494
+ 401:
495
+ description: Error if you're not logged in
496
+ content:
497
+ application/json:
498
+ schema:
499
+ $ref: '#/components/schemas/GeoVisioError'
500
+ 403:
501
+ description: Error if you're not authorized to add picture to this upload set
502
+ content:
503
+ application/json:
504
+ schema:
505
+ $ref: '#/components/schemas/GeoVisioError'
506
+ 404:
507
+ description: Error if the UploadSet doesn't exist
508
+ content:
509
+ application/json:
510
+ schema:
511
+ $ref: '#/components/schemas/GeoVisioError'
512
+ 409:
513
+ description: Error if the item has already been added to this upload set or to another upload set
514
+ content:
515
+ application/json:
516
+ schema:
517
+ $ref: '#/components/schemas/GeoVisioError'
518
+ 415:
519
+ description: Error if the content type is not multipart/form-data
520
+ content:
521
+ application/json:
522
+ schema:
523
+ $ref: '#/components/schemas/GeoVisioError'
524
+ """
525
+
526
+ if not request.headers.get("Content-Type", "").startswith("multipart/form-data") or request.form is None:
527
+ raise errors.InvalidAPIUsage(_("Content type should be multipart/form-data"), status_code=415)
528
+
529
+ with db.conn(current_app) as conn:
530
+ try:
531
+ with conn.transaction(), conn.cursor(row_factory=dict_row) as cursor:
532
+ upload_set = cursor.execute(
533
+ "SELECT id, account_id, completed FROM upload_sets WHERE id = %s AND not deleted", [upload_set_id]
534
+ ).fetchone()
535
+ if not upload_set:
536
+ raise errors.InvalidAPIUsage(_("UploadSet %(u)s does not exist", u=upload_set_id), status_code=404)
537
+
538
+ # Account associated to uploadset doesn't match current user
539
+ if account is not None and account.id != str(upload_set["account_id"]):
540
+ raise errors.InvalidAPIUsage(_("You're not authorized to add picture to this upload set"), status_code=403)
541
+
542
+ # parse params
543
+ params = _read_add_items_params(request.form, request.files)
544
+
545
+ file: Dict[str, Any] = dict(
546
+ file_name=os.path.basename(params.file.filename or ""),
547
+ file_type=params.file_type,
548
+ )
549
+ # Compute various metadata
550
+ accountId = accountIdOrDefault(account)
551
+ raw_pic = params.file.read()
552
+ filesize = len(raw_pic)
553
+ file["size"] = filesize
554
+
555
+ with sentry_sdk.start_span(description="computing md5"):
556
+ # we save the content hash md5 as uuid since md5 is 128bit and uuid are efficiently handled in postgres
557
+ md5 = hashlib.md5(raw_pic).digest()
558
+ md5 = UUID(bytes=md5)
559
+ file["content_md5"] = md5
560
+
561
+ additionalMetadata = {
562
+ "blurredByAuthor": params.isBlurred,
563
+ "originalFileName": os.path.basename(params.file.filename), # type: ignore
564
+ "originalFileSize": filesize,
565
+ "originalContentMd5": md5,
566
+ }
567
+
568
+ # check if items already exists
569
+ same_pics = cursor.execute(
570
+ "SELECT id AS existing_item_id, upload_set_id FROM pictures WHERE original_content_md5 = %s", [md5]
571
+ ).fetchall()
572
+ if same_pics:
573
+ same_pics_in_same_upload_set = next(
574
+ (p["existing_item_id"] for p in same_pics if p["upload_set_id"] == upload_set_id), None
575
+ )
576
+ if same_pics_in_same_upload_set:
577
+ # same picture sent twice in the same upload set is likely a client error, we don't keep track of it
578
+ # it's especially important since for the moment we can't track 2 files with the same name in the same uploadset
579
+ raise errors.InvalidAPIUsage(
580
+ _("The item has already been added to this upload set"),
581
+ status_code=409,
582
+ payload={"existing_item": {"id": same_pics_in_same_upload_set}},
583
+ )
584
+ if current_app.config["API_ACCEPT_DUPLICATE"] is False:
585
+ # If the picture has been sent in another upload set, we reject it and track it as file sent (to advance the counter to the completion)
586
+ raise TrackedFileException(
587
+ _("The same picture has already been sent in a past upload"),
588
+ payload={"upload_sets": same_pics},
589
+ rejection_status=FileRejectionStatus.invalid_metadata,
590
+ status_code=409,
591
+ file=file,
592
+ )
593
+
594
+ # Update picture metadata if needed
595
+ if params.ext_mtd:
596
+ with sentry_sdk.start_span(description="overwriting metadata"):
597
+ raw_pic = writePictureMetadata(raw_pic, params.ext_mtd)
598
+
599
+ # Insert picture into database
600
+ with sentry_sdk.start_span(description="Insert picture in db"):
601
+
602
+ try:
603
+ picId = utils.pictures.insertNewPictureInDatabase(
604
+ db=conn,
605
+ sequenceId=None,
606
+ position=None,
607
+ pictureBytes=raw_pic,
608
+ associatedAccountID=accountId,
609
+ additionalMetadata=additionalMetadata,
610
+ uploadSetID=upload_set_id,
611
+ lang=get_locale().language,
612
+ )
613
+ except utils.pictures.MetadataReadingError as e:
614
+ raise TrackedFileException(
615
+ _("Impossible to parse picture metadata"),
616
+ payload={"details": {"error": e.details}},
617
+ rejection_status=FileRejectionStatus.invalid_metadata,
618
+ file=file,
619
+ )
620
+ except utils.pictures.InvalidMetadataValue as e:
621
+ raise TrackedFileException(
622
+ _("Picture has invalid metadata"),
623
+ payload={"details": {"error": e.details}},
624
+ rejection_status=FileRejectionStatus.invalid_metadata,
625
+ file=file,
626
+ )
627
+ except PIL.UnidentifiedImageError as e:
628
+ logging.warning("Impossible to open file as an image: " + str(e))
629
+ raise TrackedFileException(
630
+ _("Impossible to open file as image. The only supported image format is jpg."),
631
+ rejection_status=FileRejectionStatus.invalid_file,
632
+ file=file,
633
+ )
634
+
635
+ # persist the file in the database
636
+ file = utils.upload_set.insertFileInDatabase(
637
+ cursor=cursor,
638
+ upload_set_id=upload_set_id,
639
+ picture_id=picId,
640
+ **file,
641
+ )
642
+ # Save file into appropriate filesystem
643
+ with sentry_sdk.start_span(description="Saving picture"):
644
+ try:
645
+ utils.pictures.saveRawPicture(picId, raw_pic, params.isBlurred)
646
+ except:
647
+ logging.exception("Picture wasn't correctly saved in filesystem")
648
+ raise errors.InvalidAPIUsage(_("Picture wasn't correctly saved in filesystem"), status_code=500)
649
+
650
+ handle_completion(cursor, upload_set)
651
+ except TrackedFileException as e:
652
+ # something went wrong, we reject the file, but keep track of it
653
+ with conn.transaction(), conn.cursor(row_factory=dict_row) as cursor:
654
+ msg = e.message
655
+ if e.payload and e.payload.get("details", {}).get("error") is not None:
656
+ msg = e.payload["details"]["error"]
657
+ utils.upload_set.insertFileInDatabase(
658
+ cursor=cursor,
659
+ upload_set_id=upload_set_id,
660
+ **e.file,
661
+ rejection_status=e.rejection_status,
662
+ rejection_message=msg,
663
+ )
664
+ handle_completion(cursor, upload_set)
665
+ raise e
666
+
667
+ # prepare the picture in the background
668
+ current_app.background_processor.process_pictures() # type: ignore
669
+
670
+ # Return picture metadata
671
+ return (
672
+ file.model_dump_json(exclude_none=True),
673
+ 202,
674
+ {
675
+ "Content-Type": "application/json",
676
+ },
677
+ )
678
+
679
+
680
+ @bp.route("/upload_sets/<uuid:upload_set_id>/complete", methods=["POST"])
681
+ @auth.login_required_by_setting("API_FORCE_AUTH_ON_UPLOAD")
682
+ def completeUploadSet(upload_set_id: UUID, account=None):
683
+ """Complete an UploadSet
684
+
685
+ ---
686
+ tags:
687
+ - Upload
688
+ - UploadSet
689
+ parameters:
690
+ - name: upload_set_id
691
+ in: path
692
+ description: ID of the UploadSet
693
+ required: true
694
+ schema:
695
+ type: string
696
+ security:
697
+ - bearerToken: []
698
+ - cookieAuth: []
699
+ responses:
700
+ 200:
701
+ description: the UploadSet metadata
702
+ content:
703
+ application/json:
704
+ schema:
705
+ $ref: '#/components/schemas/GeoVisioUploadSet'
706
+ """
707
+
708
+ with db.conn(current_app) as conn:
709
+ with conn.cursor(row_factory=dict_row) as cursor:
710
+ upload_set = cursor.execute("SELECT account_id, completed FROM upload_sets WHERE id = %s", [upload_set_id]).fetchone()
711
+ if not upload_set:
712
+ raise errors.InvalidAPIUsage(_("UploadSet %(u)s does not exist", u=upload_set_id), status_code=404)
713
+
714
+ # Account associated to uploadset doesn't match current user
715
+ if account is not None and account.id != str(upload_set["account_id"]):
716
+ raise errors.InvalidAPIUsage(_("You're not authorized to complete this upload set"), status_code=403)
717
+
718
+ cursor.execute("UPDATE upload_sets SET completed = True WHERE id = %(id)s", {"id": upload_set_id})
719
+
720
+ # dispatch the upload_set in the background
721
+ current_app.background_processor.process_pictures() # type: ignore
722
+
723
+ # query again the upload set, to get the updated status
724
+ upload_set = get_upload_set(upload_set_id)
725
+ if upload_set is None:
726
+ raise errors.InvalidAPIUsage(_("UploadSet doesn't exist"), status_code=404)
727
+
728
+ return upload_set.model_dump_json(exclude_none=True), 200, {"Content-Type": "application/json"}
729
+
730
+
731
+ @bp.route("/upload_sets/<uuid:upload_set_id>", methods=["DELETE"])
732
+ @auth.login_required_by_setting("API_FORCE_AUTH_ON_UPLOAD")
733
+ def deleteUploadSet(upload_set_id: UUID, account=None):
734
+ """Delete an UploadSet
735
+
736
+ Deleting an UploadSet will delete all the pictures of the UploadSet, and all the associated collections will be marked as deleted.
737
+
738
+ ---
739
+ tags:
740
+ - Upload
741
+ - UploadSet
742
+ parameters:
743
+ - name: upload_set_id
744
+ in: path
745
+ description: ID of the UploadSet
746
+ required: true
747
+ schema:
748
+ type: string
749
+ security:
750
+ - bearerToken: []
751
+ - cookieAuth: []
752
+ responses:
753
+ 204:
754
+ description: The UploadSet has been correctly deleted
755
+ """
756
+
757
+ upload_set = get_upload_set(upload_set_id)
758
+
759
+ # Account associated to uploadset doesn't match current user
760
+ if account is not None and account.id != str(upload_set.account_id):
761
+ raise errors.InvalidAPIUsage(_("You're not authorized to delete this upload set"), status_code=403)
762
+
763
+ utils.upload_set.delete(upload_set)
764
+
765
+ # run background task to delete the associated pictures
766
+ current_app.background_processor.process_pictures() # type: ignore
767
+
768
+ return "", 204