geovisio 2.8.1__py3-none-any.whl → 2.10.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. geovisio/__init__.py +6 -1
  2. geovisio/config_app.py +16 -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 +101 -6
  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 +421 -86
  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 +823 -0
  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 +183 -0
  36. geovisio/utils/auth.py +14 -13
  37. geovisio/utils/cql2.py +134 -0
  38. geovisio/utils/db.py +7 -0
  39. geovisio/utils/fields.py +38 -9
  40. geovisio/utils/items.py +44 -0
  41. geovisio/utils/model_query.py +4 -4
  42. geovisio/utils/pic_shape.py +63 -0
  43. geovisio/utils/pictures.py +164 -29
  44. geovisio/utils/reports.py +10 -17
  45. geovisio/utils/semantics.py +196 -57
  46. geovisio/utils/sentry.py +1 -2
  47. geovisio/utils/sequences.py +191 -93
  48. geovisio/utils/tags.py +31 -0
  49. geovisio/utils/upload_set.py +287 -209
  50. geovisio/utils/website.py +1 -1
  51. geovisio/web/annotations.py +346 -9
  52. geovisio/web/auth.py +1 -1
  53. geovisio/web/collections.py +73 -54
  54. geovisio/web/configuration.py +26 -5
  55. geovisio/web/docs.py +143 -11
  56. geovisio/web/items.py +232 -155
  57. geovisio/web/map.py +25 -13
  58. geovisio/web/params.py +55 -52
  59. geovisio/web/pictures.py +34 -0
  60. geovisio/web/stac.py +19 -12
  61. geovisio/web/tokens.py +49 -1
  62. geovisio/web/upload_set.py +148 -37
  63. geovisio/web/users.py +4 -4
  64. geovisio/web/utils.py +2 -2
  65. geovisio/workers/runner_pictures.py +190 -24
  66. {geovisio-2.8.1.dist-info → geovisio-2.10.0.dist-info}/METADATA +27 -26
  67. geovisio-2.10.0.dist-info/RECORD +105 -0
  68. {geovisio-2.8.1.dist-info → geovisio-2.10.0.dist-info}/WHEEL +1 -1
  69. geovisio-2.8.1.dist-info/RECORD +0 -92
  70. {geovisio-2.8.1.dist-info → geovisio-2.10.0.dist-info}/licenses/LICENSE +0 -0
@@ -1,5 +1,8 @@
1
+ from operator import ne
2
+ from click import Option
3
+ from numpy import sort
1
4
  import psycopg
2
- from flask import current_app, url_for
5
+ from flask import current_app, g, url_for
3
6
  from flask_babel import gettext as _
4
7
  from psycopg.types.json import Jsonb
5
8
  from psycopg.sql import SQL, Composable
@@ -11,7 +14,7 @@ from uuid import UUID
11
14
  from enum import Enum
12
15
  from geovisio.utils import db
13
16
  from geovisio.utils.auth import Account
14
- from geovisio.utils.fields import FieldMapping, SortBy, SQLDirection, BBox, Bounds
17
+ from geovisio.utils.fields import FieldMapping, SortBy, SQLDirection, BBox, Bounds, SortByField
15
18
  from geopic_tag_reader import reader
16
19
  from pathlib import PurePath
17
20
  from geovisio import errors, utils
@@ -39,6 +42,7 @@ STAC_FIELD_MAPPINGS = {
39
42
  FieldMapping(sql_column=SQL("updated_at"), stac="updated"),
40
43
  FieldMapping(sql_column=SQL("computed_capture_date"), stac="datetime"),
41
44
  FieldMapping(sql_column=SQL("status"), stac="status"),
45
+ FieldMapping(sql_column=SQL("id"), stac="id"),
42
46
  ]
43
47
  }
44
48
  STAC_FIELD_TO_SQL_FILTER = {p.stac: p.sql_filter.as_string(None) for p in STAC_FIELD_MAPPINGS.values()}
@@ -51,8 +55,8 @@ class Collections:
51
55
  """
52
56
 
53
57
  collections: List[Dict[Any, Any]] = field(default_factory=lambda: [])
54
- # Bounds of the field used by the first field of the `ORDER BY` (usefull especially for pagination)
55
- query_first_order_bounds: Optional[Bounds] = None
58
+ # Bounds of the field used by the first field of the `ORDER BY` (useful especially for pagination)
59
+ query_bounds: Optional[Bounds] = None
56
60
 
57
61
 
58
62
  @dataclass
@@ -153,23 +157,24 @@ def get_collections(request: CollectionsRequest) -> Collections:
153
157
  s.min_picture_ts AS mints,
154
158
  s.max_picture_ts AS maxts,
155
159
  s.nb_pictures AS nbpic,
160
+ s.upload_set_id,
156
161
  {status},
157
162
  s.computed_capture_date AS datetime,
158
163
  s.user_agent,
159
164
  ROUND(ST_Length(s.geom::geography)) / 1000 AS length_km,
160
165
  s.computed_h_pixel_density,
161
166
  s.computed_gps_accuracy,
162
- t.semantics
167
+ COALESCE(seq_sem.semantics, '[]'::json) AS semantics
163
168
  FROM sequences s
164
169
  LEFT JOIN accounts on s.account_id = accounts.id
165
170
  LEFT JOIN (
166
- SELECT sequence_id, json_agg(json_strip_nulls(json_build_object(
167
- 'key', key,
168
- 'value', value
169
- ))) AS semantics
170
- FROM sequences_semantics
171
- GROUP BY sequence_id
172
- ) t ON t.sequence_id = s.id
171
+ SELECT sequence_id, json_agg(json_strip_nulls(json_build_object(
172
+ 'key', key,
173
+ 'value', value
174
+ )) ORDER BY key, value) AS semantics
175
+ FROM sequences_semantics
176
+ GROUP BY sequence_id
177
+ ) seq_sem ON seq_sem.sequence_id = s.id
173
178
  WHERE {filter}
174
179
  ORDER BY {order1}
175
180
  LIMIT {limit}
@@ -184,71 +189,150 @@ def get_collections(request: CollectionsRequest) -> Collections:
184
189
 
185
190
  # Different request if we want the last n sequences
186
191
  # Useful for paginating from last page to first
187
- if request.pagination_filter and (
188
- (
189
- request.sort_by.fields[0].direction == SQLDirection.ASC
190
- and request.pagination_filter.as_string(None).startswith(f"({request.sort_by.fields[0].field.sql_filter.as_string(None)} <")
191
- )
192
- or (
193
- request.sort_by.fields[0].direction == SQLDirection.DESC
194
- and request.pagination_filter.as_string(None).startswith(f"({request.sort_by.fields[0].field.sql_filter.as_string(None)} >")
195
- )
196
- ):
197
- base_query = sqlSequencesRaw.format(
198
- filter=SQL(" AND ").join(seq_filter),
199
- order1=request.sort_by.revert(),
200
- limit=request.limit,
201
- status=status_field,
202
- )
203
- sqlSequences = SQL(
192
+ if request.pagination_filter:
193
+ # note: we don't want to compare the leading parenthesis
194
+ pagination = request.pagination_filter.as_string(None).strip("(")
195
+ first_sort = request.sort_by.fields[0]
196
+ if (first_sort.direction == SQLDirection.ASC and pagination.startswith(f"{first_sort.field.sql_filter.as_string(None)} <")) or (
197
+ first_sort.direction == SQLDirection.DESC and pagination.startswith(f"{first_sort.field.sql_filter.as_string(None)} >")
198
+ ):
199
+ base_query = sqlSequencesRaw.format(
200
+ filter=SQL(" AND ").join(seq_filter),
201
+ order1=request.sort_by.revert(),
202
+ limit=request.limit,
203
+ status=status_field,
204
+ )
205
+ sqlSequences = SQL(
206
+ """
207
+ SELECT *
208
+ FROM ({base_query}) s
209
+ ORDER BY {order2}
204
210
  """
205
- SELECT *
206
- FROM ({base_query}) s
207
- ORDER BY {order2}
208
- """
209
- ).format(
210
- order2=request.sort_by.as_sql(),
211
- base_query=base_query,
212
- )
211
+ ).format(
212
+ order2=request.sort_by.as_sql(),
213
+ base_query=base_query,
214
+ )
213
215
 
214
216
  records = cursor.execute(sqlSequences, seq_params).fetchall()
215
217
 
216
218
  query_bounds = None
217
- for s in records:
218
- first_order_val = s.get(request.sort_by.fields[0].field.stac)
219
- if first_order_val is None:
220
- continue
221
- if query_bounds is None:
222
- query_bounds = Bounds(first_order_val, first_order_val)
223
- else:
224
- query_bounds.update(first_order_val)
219
+ if records:
220
+ first = [records[0].get(f.field.stac) for f in request.sort_by.fields]
221
+ last = [records[-1].get(f.field.stac) for f in request.sort_by.fields]
222
+ query_bounds = Bounds(first, last)
225
223
 
226
224
  return Collections(
227
225
  collections=records,
228
- query_first_order_bounds=query_bounds,
226
+ query_bounds=query_bounds,
229
227
  )
230
228
 
231
229
 
230
+ def get_pagination_stac_filter(sortBy: SortBy, dataBounds: Optional[Bounds[List[Any]]], next: bool) -> str:
231
+ """Create a pagination API filters, using the sorts and the bounds of the current query"""
232
+ filters = []
233
+ bounds = dataBounds.last if next else dataBounds.first
234
+ for i, f in enumerate(sortBy.fields):
235
+ direction = f.direction
236
+ # bounds is a list of values, for all sorty_by fields
237
+ if (next and direction == SQLDirection.ASC) or (not next and direction == SQLDirection.DESC):
238
+ cmp = ">"
239
+ else:
240
+ cmp = "<"
241
+ field_pagination = f"{f.field.stac} {cmp} '{bounds[i]}'"
242
+
243
+ previous_filters = sortBy.fields[:i]
244
+ if previous_filters:
245
+ prev_fields = " AND ".join([f"{f.field.stac} = '{bounds[prev_i]}'" for prev_i, f in enumerate(previous_filters)]) + " AND "
246
+ filters.append(f"({prev_fields}{field_pagination})")
247
+ else:
248
+ filters.append(field_pagination)
249
+ return " OR ".join(filters)
250
+
251
+
252
+ def get_dataset_bounds(
253
+ conn: psycopg.Connection,
254
+ sortBy: SortBy,
255
+ additional_filters: Optional[SQL] = None,
256
+ additional_filters_params: Optional[Dict[str, Any]] = None,
257
+ ) -> Optional[Bounds]:
258
+ """Computes the dataset bounds from the sortBy field (using lexicographic order)
259
+
260
+ if there are several sort-by fields like (inserted_at, updated_at), this will return a bound with minimum (resp maximum)
261
+ inserted_at value, and for this value, the minimum (resp maximum) updated_at value.
262
+ """
263
+ with conn.cursor() as cursor:
264
+
265
+ sql_bounds = cursor.execute(
266
+ SQL(
267
+ """WITH min_bounds AS (
268
+ SELECT {fields} from sequences s WHERE {filters} ORDER BY {ordered_fields} LIMIT 1
269
+ ),
270
+ max_bounds AS (
271
+ SELECT {fields} from sequences s WHERE {filters} ORDER BY {reverse_fields} LIMIT 1
272
+ )
273
+ SELECT * FROM min_bounds, max_bounds;
274
+ """
275
+ ).format(
276
+ fields=SQL(", ").join([f.field.sql_column for f in sortBy.fields]),
277
+ ordered_fields=sortBy.as_non_aliased_sql(),
278
+ reverse_fields=sortBy.revert_non_aliased_sql(),
279
+ filters=additional_filters or SQL("TRUE"),
280
+ ),
281
+ params=additional_filters_params or {},
282
+ ).fetchone()
283
+ if not sql_bounds:
284
+ return None
285
+ min = [sql_bounds[i] for i, f in enumerate(sortBy.fields)]
286
+ max = [sql_bounds[i + len(sortBy.fields)] for i, f in enumerate(sortBy.fields)]
287
+ return Bounds(first=min, last=max)
288
+
289
+
290
+ def has_previous_results(sortBy: SortBy, datasetBounds: Bounds, dataBounds: Bounds) -> bool:
291
+ """Check if there are results in the database before the one returned by the queries
292
+ To do this, we do a lexicographic comparison of the bounds, using the fields direction
293
+
294
+ Note: the bounds are reversed for the DESC direction, so the bounds.min >= bounds.max for the DESC direction
295
+ """
296
+ for i, f in enumerate(sortBy.fields):
297
+ if dataBounds.first[i] is None or datasetBounds.first[i] is None:
298
+ continue
299
+ if (f.direction == SQLDirection.ASC and dataBounds.first[i] > datasetBounds.first[i]) or (
300
+ f.direction == SQLDirection.DESC and datasetBounds.first[i] > dataBounds.first[i]
301
+ ):
302
+ return True
303
+ return False
304
+
305
+
306
+ def has_next_results(sortBy: SortBy, datasetBounds: Bounds, dataBounds: Bounds) -> bool:
307
+ """Check if there are results in the database after the one returned by the queries
308
+ To do this, we do a lexicographic comparison of the bounds, using the fields direction"""
309
+ for i, f in enumerate(sortBy.fields):
310
+ if dataBounds.last[i] is None or datasetBounds.last[i] is None:
311
+ continue
312
+ if (f.direction == SQLDirection.ASC and dataBounds.last[i] < datasetBounds.last[i]) or (
313
+ f.direction == SQLDirection.DESC and datasetBounds.last[i] < dataBounds.last[i]
314
+ ):
315
+ return True
316
+ return False
317
+
318
+
232
319
  def get_pagination_links(
233
320
  route: str,
234
321
  routeArgs: dict,
235
- field: str,
236
- direction: SQLDirection,
322
+ sortBy: SortBy,
237
323
  datasetBounds: Bounds,
238
324
  dataBounds: Optional[Bounds],
239
325
  additional_filters: Optional[str],
240
326
  ) -> List:
241
327
  """Computes STAC links to handle pagination"""
242
328
 
243
- sortby = f"{'+' if direction == SQLDirection.ASC else '-'}{field}"
329
+ sortby = sortBy.as_stac()
244
330
  links = []
245
- if dataBounds is None:
331
+ if dataBounds is None or datasetBounds is None:
246
332
  return links
247
333
 
248
334
  # Check if first/prev links are necessary
249
- if (direction == SQLDirection.ASC and datasetBounds.min < dataBounds.min) or (
250
- direction == SQLDirection.DESC and dataBounds.max < datasetBounds.max
251
- ):
335
+ if has_previous_results(sortBy, datasetBounds=datasetBounds, dataBounds=dataBounds):
252
336
  links.append(
253
337
  {
254
338
  "rel": "first",
@@ -256,7 +340,9 @@ def get_pagination_links(
256
340
  "href": url_for(route, _external=True, **routeArgs, filter=additional_filters, sortby=sortby),
257
341
  }
258
342
  )
259
- page_filter = f"{field} {'<' if direction == SQLDirection.ASC else '>'} '{dataBounds.min if direction == SQLDirection.ASC else dataBounds.max}'"
343
+
344
+ page_filter = get_pagination_stac_filter(sortBy, dataBounds, next=False)
345
+
260
346
  links.append(
261
347
  {
262
348
  "rel": "prev",
@@ -273,11 +359,8 @@ def get_pagination_links(
273
359
  )
274
360
 
275
361
  # Check if next/last links are required
276
- if (direction == SQLDirection.ASC and dataBounds.max < datasetBounds.max) or (
277
- direction == SQLDirection.DESC and datasetBounds.min < dataBounds.min
278
- ):
279
- next_filter = f"{field} {'>' if direction == SQLDirection.ASC else '<'} '{dataBounds.max if direction == SQLDirection.ASC else dataBounds.min}'"
280
- last_filter = f"{field} {'<=' if direction == SQLDirection.ASC else '>='} '{datasetBounds.max if direction == SQLDirection.ASC else datasetBounds.min}'"
362
+ if has_next_results(sortBy, datasetBounds=datasetBounds, dataBounds=dataBounds):
363
+ next_filter = get_pagination_stac_filter(sortBy, dataBounds, next=True)
281
364
  links.append(
282
365
  {
283
366
  "rel": "next",
@@ -292,6 +375,10 @@ def get_pagination_links(
292
375
  ),
293
376
  }
294
377
  )
378
+ # for last, we only consider the first field used for sorting, the rest are useless
379
+ # Note: we compare to the datasetBounds last since it depends on the sort direction (so, for DESC, it last<first)
380
+ f = sortBy.fields[0]
381
+ last_filter = f"{f.field.stac} {'<=' if f.direction == SQLDirection.ASC else '>='} '{datasetBounds.last[0]}'"
295
382
  links.append(
296
383
  {
297
384
  "rel": "last",
@@ -341,7 +428,7 @@ def sort_collection(db, collectionId: UUID, sortby: CollectionSort):
341
428
  """
342
429
  Sort a collection by a given parameter
343
430
 
344
- Note: the transaction is not commited at the end, you need to commit it or use an autocommit connection
431
+ Note: the transaction is not committed at the end, you need to commit it or use an autocommit connection
345
432
  """
346
433
 
347
434
  # Remove existing order, and keep list of pictures IDs
@@ -423,23 +510,18 @@ def sort_collection(db, collectionId: UUID, sortby: CollectionSort):
423
510
  picForDb = [(collectionId, i + 1, p["id"]) for i, p in enumerate(picMetas)]
424
511
 
425
512
  # Inject back pictures in sequence
426
- db.executemany(
427
- SQL(
428
- """
429
- INSERT INTO sequences_pictures(seq_id, rank, pic_id)
430
- VALUES (%s, %s, %s)
431
- """
432
- ),
433
- picForDb,
434
- )
513
+ db.executemany(SQL("INSERT INTO sequences_pictures(seq_id, rank, pic_id) VALUES (%s, %s, %s)"), picForDb)
514
+
515
+ # we update the geometry of the sequence after this (the other computed fields have no need for an update)
516
+ db.execute(SQL("UPDATE sequences SET geom = compute_sequence_geom(id) WHERE id = %s"), [collectionId])
435
517
 
436
518
 
437
519
  def update_headings(
438
520
  db,
439
521
  sequenceId: UUID,
440
522
  editingAccount: Optional[UUID] = None,
441
- relativeHeading: int = 0,
442
- updateOnlyMissing: bool = True,
523
+ relativeHeading: Optional[int] = None,
524
+ updateOnlyMissing: Optional[bool] = None,
443
525
  ):
444
526
  """Defines pictures heading according to sequence path.
445
527
  Database is not committed in this function, to make entry definitively stored
@@ -451,27 +533,42 @@ def update_headings(
451
533
  Database connection
452
534
  sequenceId : uuid
453
535
  The sequence's uuid, as stored in the database
454
- relativeHeading : int
536
+ relativeHeading : Optional[int]
455
537
  Camera relative orientation compared to path, in degrees clockwise.
456
538
  Example: 0° = looking forward, 90° = looking to right, 180° = looking backward, -90° = looking left.
457
- updateOnlyMissing : bool
539
+ If not provided, will first use the relative_heading stored in the sequence's metadata, then the relative_heading of its upload_set (if if none is set, default to 0).
540
+ updateOnlyMissing : Optional[bool]
458
541
  If true, doesn't change existing heading values in database
542
+ if not provided, we check if some relative heading has been set (either in the sequence or in its upload_set), and if so, we recompute all
459
543
  """
460
-
461
544
  db.execute(
462
545
  SQL(
463
- """
464
- WITH h AS (
546
+ """WITH
547
+ relative_heading AS (
548
+ SELECT COALESCE(
549
+ %(relativeHeading)s,
550
+ (SELECT (metadata->>'relative_heading')::int FROM sequences WHERE id = %(seq)s),
551
+ (SELECT upload_sets.relative_heading FROM sequences JOIN upload_sets ON sequences.upload_set_id = upload_sets.id WHERE sequences.id = %(seq)s),
552
+ 0
553
+ ) AS heading,
554
+ COALESCE(
555
+ %(update_only_missing)s,
556
+ (SELECT metadata->'relative_heading' IS NULL FROM sequences WHERE id = %(seq)s and metadata ? 'relative_heading'),
557
+ (SELECT upload_sets.relative_heading IS NULL FROM sequences JOIN upload_sets ON sequences.upload_set_id = upload_sets.id WHERE sequences.id = %(seq)s)
558
+ ) AS update_only_missing
559
+ )
560
+ , h AS (
465
561
  SELECT
466
562
  p.id,
467
563
  p.heading AS old_heading,
468
564
  CASE
469
565
  WHEN LEAD(sp.rank) OVER othpics IS NULL AND LAG(sp.rank) OVER othpics IS NULL
470
- THEN NULL
566
+ -- if there is a single picture, we take the relative heading directly
567
+ THEN (SELECT heading FROM relative_heading)
471
568
  WHEN LEAD(sp.rank) OVER othpics IS NULL
472
- THEN (360 + FLOOR(DEGREES(ST_Azimuth(LAG(p.geom) OVER othpics, p.geom)))::int + (%(diff)s %% 360)) %% 360
569
+ THEN (360 + FLOOR(DEGREES(ST_Azimuth(LAG(p.geom) OVER othpics, p.geom)))::int + ((SELECT heading FROM relative_heading) %% 360)) %% 360
473
570
  ELSE
474
- (360 + FLOOR(DEGREES(ST_Azimuth(p.geom, LEAD(p.geom) OVER othpics)))::int + (%(diff)s %% 360)) %% 360
571
+ (360 + FLOOR(DEGREES(ST_Azimuth(p.geom, LEAD(p.geom) OVER othpics)))::int + ((SELECT heading FROM relative_heading) %% 360)) %% 360
475
572
  END AS heading
476
573
  FROM pictures p
477
574
  JOIN sequences_pictures sp ON sp.pic_id = p.id AND sp.seq_id = %(seq)s
@@ -480,13 +577,15 @@ def update_headings(
480
577
  UPDATE pictures p
481
578
  SET heading = h.heading, heading_computed = true {editing_account}
482
579
  FROM h
483
- WHERE h.id = p.id {update_missing}
580
+ WHERE h.id = p.id AND (
581
+ (SELECT NOT update_only_missing FROM relative_heading)
582
+ OR (p.heading IS NULL OR p.heading = 0 OR p.heading_computed) -- # lots of camera have heading set to 0 for unset heading, so we recompute the heading when it's 0 too, even if this could be a valid value
583
+ )
484
584
  """
485
585
  ).format(
486
- update_missing=SQL(" AND (p.heading IS NULL OR p.heading = 0 OR p.heading_computed)") if updateOnlyMissing else SQL(""),
487
586
  editing_account=SQL(", last_account_to_edit = %(account)s") if editingAccount is not None else SQL(""),
488
- ), # lots of camera have heading set to 0 for unset heading, so we recompute the heading when it's 0 too, even if this could be a valid value
489
- {"seq": sequenceId, "diff": relativeHeading, "account": editingAccount},
587
+ ),
588
+ {"seq": sequenceId, "relativeHeading": relativeHeading, "account": editingAccount, "update_only_missing": updateOnlyMissing},
490
589
  )
491
590
 
492
591
 
@@ -512,14 +611,13 @@ def finalize(cursor, seqId: UUID, logger: logging.Logger = logging.getLogger()):
512
611
  span.set_data("sequence_id", seqId)
513
612
  logger.debug(f"Finalizing sequence {seqId}")
514
613
 
515
- with utils.time.log_elapsed(f"Finalizing sequence {seqId}"):
516
- # Complete missing headings in pictures
517
- update_headings(cursor, seqId)
614
+ # Complete missing headings in pictures
615
+ update_headings(cursor, seqId)
518
616
 
519
- # Change sequence database status in DB
520
- # Also generates data in computed columns
521
- cursor.execute(
522
- """WITH
617
+ # Change sequence database status in DB
618
+ # Also generates data in computed columns
619
+ cursor.execute(
620
+ """WITH
523
621
  aggregated_pictures AS (
524
622
  SELECT
525
623
  sp.seq_id,
@@ -548,10 +646,10 @@ computed_gps_accuracy = gpsacc
548
646
  FROM aggregated_pictures
549
647
  WHERE id = %(seq)s
550
648
  """,
551
- {"seq": seqId},
552
- )
649
+ {"seq": seqId},
650
+ )
553
651
 
554
- logger.info(f"Sequence {seqId} is ready")
652
+ logger.info(f"Sequence {seqId} is ready")
555
653
 
556
654
 
557
655
  def update_pictures_grid() -> Optional[datetime.datetime]:
geovisio/utils/tags.py ADDED
@@ -0,0 +1,31 @@
1
+ from enum import Enum
2
+
3
+ from pydantic import BaseModel, ConfigDict, Field
4
+
5
+
6
+ class TagAction(str, Enum):
7
+ """Actions to perform on a tag list"""
8
+
9
+ add = "add"
10
+ delete = "delete"
11
+
12
+
13
+ class SemanticTagUpdate(BaseModel):
14
+ """Parameters used to update a tag list"""
15
+
16
+ action: TagAction = Field(default=TagAction.add)
17
+ """Action to perform on the tag list. The default action is `add` which will add the given tag to the list.
18
+ The action can also be to `delete` the key/value"""
19
+ key: str = Field(max_length=256)
20
+ """Key of the tag to update limited to 256 characters"""
21
+ value: str = Field(max_length=2048)
22
+ """Value of the tag to update limited ot 2048 characters"""
23
+
24
+ model_config = ConfigDict(use_attribute_docstrings=True)
25
+
26
+
27
+ class SemanticTag(BaseModel):
28
+ key: str
29
+ """Key of the tag"""
30
+ value: str
31
+ """Value of the tag"""