geovisio 2.4.0__py3-none-any.whl → 2.6.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  """GeoVisio API - Main"""
2
2
 
3
- __version__ = "2.4.0"
3
+ __version__ = "2.6.0"
4
4
 
5
5
  import os
6
6
  from flask import Flask, jsonify, stream_template, send_from_directory, redirect
@@ -60,7 +60,8 @@ def create_app(test_config=None, app=None):
60
60
  app.config["FILESYSTEMS"] = filesystems.openFilesystemsFromConfig(app.config)
61
61
 
62
62
  # Check database connection and update its schema if needed
63
- db_migrations.update_db_schema(app.config["DB_URL"])
63
+ if app.config.get("DB_CHECK_SCHEMA"):
64
+ db_migrations.update_db_schema(app.config["DB_URL"])
64
65
 
65
66
  if app.config.get("OAUTH_PROVIDER"):
66
67
  utils.auth.make_auth(app)
@@ -58,8 +58,8 @@ def cleanup_cmd(sequencesids, full, database, cache, permanent_pictures):
58
58
  @bp.cli.command("process-sequences")
59
59
  @with_appcontext
60
60
  def process_sequences():
61
- """Deprecated entry point, use https://gitlab.com/geovisio/cli to upload a sequence instead"""
62
- logging.error("This function has been deprecated, use https://gitlab.com/geovisio/cli to upload a sequence instead.")
61
+ """Deprecated entry point, use https://gitlab.com/panoramax/clients/cli to upload a sequence instead"""
62
+ logging.error("This function has been deprecated, use https://gitlab.com/panoramax/clients/cli to upload a sequence instead.")
63
63
  logging.error(
64
64
  "To upload a sequence with this tool, install it with `pip install geovisio_cli`, then run:\ngeovisio upload --path <directory> --api-url <api-url>"
65
65
  )
geovisio/admin_cli/db.py CHANGED
@@ -1,7 +1,9 @@
1
1
  from flask import Blueprint, current_app
2
2
  from flask.cli import with_appcontext
3
3
  import click
4
+ import psycopg
4
5
  from geovisio import db_migrations
6
+ from geovisio.utils import sequences
5
7
 
6
8
  bp = Blueprint("db", __name__)
7
9
 
@@ -25,3 +27,12 @@ def upgrade():
25
27
  def rollback(all):
26
28
  """Rollbacks the latest database migration"""
27
29
  db_migrations.rollback_db_schema(current_app.config["DB_URL"], all)
30
+
31
+
32
+ @bp.cli.command("refresh")
33
+ @with_appcontext
34
+ def refresh():
35
+ """Refresh cached data (pictures_grid)"""
36
+ with psycopg.connect(current_app.config["DB_URL"]) as db:
37
+ sequences.update_pictures_grid(db)
38
+ db.commit()
@@ -1,7 +1,7 @@
1
1
  import logging
2
2
  import psycopg
3
3
  from flask import current_app
4
- from geovisio.workers.runner_pictures import updateSequenceHeadings
4
+ from geovisio.utils.sequences import update_headings
5
5
 
6
6
  log = logging.getLogger("geovisio.cli.sequence_heading")
7
7
 
@@ -13,6 +13,6 @@ def setSequencesHeadings(sequences, value, overwrite):
13
13
  sequences = [r[0] for r in db.execute("SELECT id FROM sequences").fetchall()]
14
14
 
15
15
  for seq in sequences:
16
- updateSequenceHeadings(db, seq, value, not overwrite)
16
+ update_headings(db, seq, value, not overwrite)
17
17
 
18
18
  log.info("Done processing %s sequences" % len(sequences))
geovisio/config_app.py CHANGED
@@ -3,6 +3,8 @@ import os.path
3
3
  from urllib.parse import urlparse
4
4
  import datetime
5
5
  import logging
6
+ from typing import Optional
7
+ import croniter
6
8
 
7
9
 
8
10
  class DefaultConfig:
@@ -14,9 +16,14 @@ class DefaultConfig:
14
16
  PICTURE_PROCESS_DERIVATES_STRATEGY = "ON_DEMAND"
15
17
  API_BLUR_URL = None
16
18
  PICTURE_PROCESS_THREADS_LIMIT = 1
19
+ DB_CHECK_SCHEMA = True # If True check the database schema, and do not start the api if not up to date
17
20
  API_PICTURES_LICENSE_SPDX_ID = None
18
21
  API_PICTURES_LICENSE_URL = None
19
22
  DEBUG_PICTURES_SKIP_FS_CHECKS_WITH_PUBLIC_URL = False
23
+ SESSION_COOKIE_HTTPONLY = False
24
+ PICTURE_PROCESS_REFRESH_CRON = (
25
+ "0 2 * * *" # Background worker will refresh by default some stats at 2 o'clock in the night (local time of the server)
26
+ )
20
27
 
21
28
 
22
29
  def read_config(app, test_config):
@@ -38,6 +45,7 @@ def read_config(app, test_config):
38
45
  "DB_USERNAME",
39
46
  "DB_PASSWORD",
40
47
  "DB_NAME",
48
+ "DB_CHECK_SCHEMA",
41
49
  # API
42
50
  "API_BLUR_URL",
43
51
  "API_VIEWER_PAGE",
@@ -51,6 +59,7 @@ def read_config(app, test_config):
51
59
  # Picture process
52
60
  "PICTURE_PROCESS_DERIVATES_STRATEGY",
53
61
  "PICTURE_PROCESS_THREADS_LIMIT",
62
+ "PICTURE_PROCESS_REFRESH_CRON",
54
63
  # OAUTH
55
64
  "OAUTH_PROVIDER",
56
65
  "OAUTH_OIDC_URL",
@@ -106,6 +115,8 @@ def read_config(app, test_config):
106
115
 
107
116
  app.config["DB_URL"] = f"postgres://{username}:{passw}@{host}:{port}/{dbname}"
108
117
 
118
+ app.config["DB_CHECK_SCHEMA"] = _read_bool(app.config, "DB_CHECK_SCHEMA")
119
+
109
120
  if app.config.get("API_BLUR_URL") is not None and len(app.config.get("API_BLUR_URL")) > 0:
110
121
  try:
111
122
  urlparse(app.config.get("API_BLUR_URL"))
@@ -155,6 +166,9 @@ def read_config(app, test_config):
155
166
  if app.config.get("API_PICTURES_LICENSE_SPDX_ID") is None:
156
167
  app.config["API_PICTURES_LICENSE_SPDX_ID"] = "proprietary"
157
168
 
169
+ cron_val = app.config["PICTURE_PROCESS_REFRESH_CRON"]
170
+ if not croniter.croniter.is_valid(cron_val):
171
+ raise Exception(f"PICTURE_PROCESS_REFRESH_CRON should be a valid cron syntax, got '{cron_val}'")
158
172
  #
159
173
  # Add generated config vars
160
174
  #
@@ -164,6 +178,17 @@ def read_config(app, test_config):
164
178
  app.config["EXECUTOR_PROPAGATE_EXCEPTIONS"] = True # propagate the excecutor's exceptions, to be able to trace them
165
179
 
166
180
 
181
+ def _read_bool(config, value_name: str) -> Optional[bool]:
182
+ value = config.get(value_name)
183
+ if value is None:
184
+ return value
185
+ if type(value) == bool:
186
+ return value
187
+ if type(value) == str:
188
+ return value.lower() == "true"
189
+ raise Exception(f"Configuration {value_name} should either be a boolean or a string, got '{value}'")
190
+
191
+
167
192
  def _get_threads_limit(param: str) -> int:
168
193
  """Computes maximum thread limit depending on environment variables and available CPU.
169
194
 
@@ -67,7 +67,7 @@
67
67
  +'<div id="viewer" style="width: 500px; height: 300px"></div>\n\n'
68
68
  +'&lt;script>\n'
69
69
  +'\t// All options available are listed here\n'
70
- +'\t// https://gitlab.com/geovisio/web-viewer/-/blob/develop/docs/02_Usage.md\n'
70
+ +'\t// https://gitlab.com/panoramax/clients/web-viewer/-/blob/develop/docs/02_Usage.md\n'
71
71
  +'\tvar instance = new GeoVisio.default(\n'
72
72
  +'\t\t"viewer",\n'
73
73
  +'\t\t"'+baseUrl+'/api",\n'
@@ -85,7 +85,7 @@
85
85
  -
86
86
  <a href="/api/docs/swagger">API docs</a>
87
87
  -
88
- <a href="https://gitlab.com/geovisio/web-viewer/-/tree/develop/docs">JS library docs</a>
88
+ <a href="https://gitlab.com/panoramax/clients/web-viewer/-/tree/develop/docs">JS library docs</a>
89
89
  -
90
90
  <a href="https://gitlab.com/geovisio">Repositories</a>
91
91
  </p>
@@ -1,5 +1,5 @@
1
1
  import math
2
- from typing import Optional
2
+ from typing import Dict, Optional
3
3
  from flask import current_app, redirect, send_file
4
4
  import os
5
5
  import psycopg
@@ -526,7 +526,7 @@ def insertNewPictureInDatabase(db, sequenceId, position, pictureBytes, associate
526
526
 
527
527
  # Create a fully-featured metadata object
528
528
  picturePillow = Image.open(io.BytesIO(pictureBytes))
529
- metadata = readPictureMetadata(pictureBytes, True) | utils.pictures.getPictureSizing(picturePillow) | addtionalMetadata
529
+ metadata = readPictureMetadata(pictureBytes) | utils.pictures.getPictureSizing(picturePillow) | addtionalMetadata
530
530
 
531
531
  # Remove cols/rows information for flat pictures
532
532
  if metadata["type"] == "flat":
@@ -537,23 +537,26 @@ def insertNewPictureInDatabase(db, sequenceId, position, pictureBytes, associate
537
537
  lighterMetadata = dict(filter(lambda v: v[0] not in ["ts", "heading", "lon", "lat", "exif"], metadata.items()))
538
538
  if lighterMetadata.get("tagreader_warnings") is not None and len(lighterMetadata["tagreader_warnings"]) == 0:
539
539
  del lighterMetadata["tagreader_warnings"]
540
+ lighterMetadata["tz"] = metadata["ts"].tzname()
541
+
542
+ exif = cleanupExif(metadata["exif"])
540
543
 
541
544
  with db.transaction():
542
545
  # Add picture metadata to database
543
546
  picId = db.execute(
544
547
  """
545
548
  INSERT INTO pictures (ts, heading, metadata, geom, account_id, exif)
546
- VALUES (to_timestamp(%s), %s, %s, ST_SetSRID(ST_MakePoint(%s, %s), 4326), %s, %s)
549
+ VALUES (%s, %s, %s, ST_SetSRID(ST_MakePoint(%s, %s), 4326), %s, %s)
547
550
  RETURNING id
548
551
  """,
549
552
  (
550
- metadata["ts"],
553
+ metadata["ts"].isoformat(),
551
554
  metadata["heading"],
552
555
  Jsonb(lighterMetadata),
553
556
  metadata["lon"],
554
557
  metadata["lat"],
555
558
  associatedAccountID,
556
- Jsonb(metadata["exif"]),
559
+ Jsonb(exif),
557
560
  ),
558
561
  ).fetchone()[0]
559
562
 
@@ -600,7 +603,39 @@ def insertNewPictureInDatabase(db, sequenceId, position, pictureBytes, associate
600
603
  return picId
601
604
 
602
605
 
603
- def readPictureMetadata(picture: bytes, fullExif=False) -> dict:
606
+ # Note: we don't want to store and expose exif binary fields as they are difficult to use and take a lot of storage in the database (~20% for maker notes only)
607
+ # This list has been queried from real data (cf [this comment](https://gitlab.com/panoramax/server/api/-/merge_requests/241#note_1790580636)).
608
+ # Update this list (and do a sql migration) if new binary fields are added
609
+ BLACK_LISTED_BINARY_EXIF_FIELDS = set(
610
+ [
611
+ "Exif.Photo.MakerNote",
612
+ "Exif.Photo.0xea1c",
613
+ "Exif.Image.0xea1c",
614
+ "Exif.Canon.CameraInfo",
615
+ "Exif.Image.PrintImageMatching",
616
+ "Exif.Image.0xc6d3",
617
+ "Exif.Panasonic.FaceDetInfo",
618
+ "Exif.Panasonic.DataDump",
619
+ "Exif.Image.0xc6d2",
620
+ "Exif.Canon.CustomFunctions",
621
+ "Exif.Canon.AFInfo",
622
+ "Exif.Canon.0x4011",
623
+ "Exif.Canon.0x4019",
624
+ "Exif.Canon.ColorData",
625
+ "Exif.Canon.DustRemovalData",
626
+ "Exif.Canon.VignettingCorr",
627
+ "Exif.Canon.AFInfo3",
628
+ "Exif.Canon.0x001f",
629
+ "Exif.Canon.0x0018",
630
+ "Exif.Canon.ContrastInfo",
631
+ "Exif.Canon.0x002e",
632
+ "Exif.Canon.0x0022",
633
+ "Exif.Photo.0x9aaa",
634
+ ]
635
+ )
636
+
637
+
638
+ def readPictureMetadata(picture: bytes) -> dict:
604
639
  """Extracts metadata from picture file
605
640
 
606
641
  Parameters
@@ -621,29 +656,39 @@ def readPictureMetadata(picture: bytes, fullExif=False) -> dict:
621
656
  except Exception as e:
622
657
  raise MetadataReadingError(details=str(e))
623
658
 
624
- if not fullExif:
625
- metadata.pop("exif")
626
- else:
627
- # Cleanup raw EXIF tags to avoid SQL issues
628
- cleanedExif = dict()
629
-
630
- for k, v in metadata["exif"].items():
631
- try:
632
- if isinstance(v, bytes):
633
- try:
634
- cleanedExif[k] = v.decode("utf-8").replace("\x00", "")
635
- except UnicodeDecodeError:
636
- cleanedExif[k] = str(v).replace("\x00", "")
637
- elif isinstance(v, str):
638
- cleanedExif[k] = v.replace("\x00", "")
639
- else:
640
- try:
641
- cleanedExif[k] = str(v)
642
- except:
643
- logging.warning("Unsupported EXIF tag conversion: " + k + " " + str(type(v)))
644
- except:
645
- logging.exception("Can't read EXIF tag: " + k + " " + str(type(v)))
646
-
647
- metadata["exif"] = cleanedExif
659
+ # Cleanup raw EXIF tags to avoid SQL issues
660
+ cleanedExif = {}
661
+ for k, v in metadata["exif"].items():
662
+ if k in BLACK_LISTED_BINARY_EXIF_FIELDS:
663
+ continue
664
+ try:
665
+ if isinstance(v, bytes):
666
+ try:
667
+ cleanedExif[k] = v.decode("utf-8").replace("\x00", "")
668
+ except UnicodeDecodeError:
669
+ cleanedExif[k] = str(v).replace("\x00", "")
670
+ elif isinstance(v, str):
671
+ cleanedExif[k] = v.replace("\x00", "")
672
+ else:
673
+ try:
674
+ cleanedExif[k] = str(v)
675
+ except:
676
+ logging.warning("Unsupported EXIF tag conversion: " + k + " " + str(type(v)))
677
+ except:
678
+ logging.exception("Can't read EXIF tag: " + k + " " + str(type(v)))
648
679
 
649
680
  return metadata
681
+
682
+
683
+ def cleanupExif(exif: Optional[Dict[str, str]]) -> Optional[Dict[str, str]]:
684
+ """Removes binary fields from exif
685
+ >>> cleanupExif({'A': 'B', 'Exif.Canon.AFInfo': 'Blablabla'})
686
+ {'A': 'B'}
687
+ >>> cleanupExif({'A': 'B', 'Exif.Photo.MakerNote': 'Blablabla'})
688
+ {'A': 'B'}
689
+ """
690
+
691
+ if exif is None:
692
+ return None
693
+
694
+ return {k: v for k, v in exif.items() if k not in BLACK_LISTED_BINARY_EXIF_FIELDS}
@@ -5,10 +5,16 @@ from psycopg import sql
5
5
  from psycopg.sql import SQL
6
6
  from psycopg.rows import dict_row
7
7
  from dataclasses import dataclass, field
8
- from typing import Any, List, Dict, Optional, Generic, TypeVar, Protocol
8
+ from typing import Any, List, Dict, Optional
9
9
  import datetime
10
10
  from uuid import UUID
11
- from geovisio.utils.fields import FieldMapping, SortBy, SortByField, SQLDirection, BBox, Bounds
11
+ from enum import Enum
12
+ from geovisio.utils.fields import FieldMapping, SortBy, SQLDirection, BBox, Bounds
13
+ from geopic_tag_reader import reader
14
+ from pathlib import PurePath
15
+ from geovisio import errors, utils
16
+ import logging
17
+ import sentry_sdk
12
18
 
13
19
 
14
20
  def createSequence(metadata, accountId) -> str:
@@ -115,7 +121,7 @@ def get_collections(request: CollectionsRequest) -> Collections:
115
121
  seq_params["cmaxdate"] = request.max_dt
116
122
 
117
123
  if request.bbox is not None:
118
- seq_filter.append(SQL("s.geom && ST_MakeEnvelope(%(minx)s, %(miny)s, %(maxx)s, %(maxy)s, 4326)"))
124
+ seq_filter.append(SQL("ST_Intersects(s.geom, ST_MakeEnvelope(%(minx)s, %(miny)s, %(maxx)s, %(maxy)s, 4326))"))
119
125
  seq_params["minx"] = request.bbox.minx
120
126
  seq_params["miny"] = request.bbox.miny
121
127
  seq_params["maxx"] = request.bbox.maxx
@@ -134,37 +140,29 @@ def get_collections(request: CollectionsRequest) -> Collections:
134
140
  with conn.cursor() as cursor:
135
141
  sqlSequencesRaw = SQL(
136
142
  """
137
- SELECT * FROM (
138
- SELECT
139
- s.id,
140
- s.status,
141
- s.metadata->>'title' AS name,
142
- s.inserted_at AS created,
143
- s.updated_at AS updated,
144
- ST_XMin(s.geom) AS minx,
145
- ST_YMin(s.geom) AS miny,
146
- ST_XMax(s.geom) AS maxx,
147
- ST_YMax(s.geom) AS maxy,
148
- accounts.name AS account_name,
149
- ST_X(ST_PointN(s.geom, 1)) AS x1,
150
- ST_Y(ST_PointN(s.geom, 1)) AS y1,
151
- {status},
152
- s.computed_capture_date AS datetime
153
- FROM sequences s
154
- LEFT JOIN accounts on s.account_id = accounts.id
155
- WHERE {filter}
156
- ORDER BY {order1}
157
- LIMIT {limit}
158
- ) s
159
- LEFT JOIN LATERAL (
160
- SELECT MIN(p.ts) as mints,
161
- MAX(p.ts) as maxts,
162
- COUNT(p.*) AS nbpic
163
- FROM sequences_pictures sp
164
- JOIN pictures p ON sp.pic_id = p.id
165
- WHERE {pic_filter}
166
- GROUP BY sp.seq_id
167
- ) sub ON true
143
+ SELECT
144
+ s.id,
145
+ s.status,
146
+ s.metadata->>'title' AS name,
147
+ s.inserted_at AS created,
148
+ s.updated_at AS updated,
149
+ ST_XMin(s.bbox) AS minx,
150
+ ST_YMin(s.bbox) AS miny,
151
+ ST_XMax(s.bbox) AS maxx,
152
+ ST_YMax(s.bbox) AS maxy,
153
+ accounts.name AS account_name,
154
+ ST_X(ST_PointN(s.geom, 1)) AS x1,
155
+ ST_Y(ST_PointN(s.geom, 1)) AS y1,
156
+ s.min_picture_ts AS mints,
157
+ s.max_picture_ts AS maxts,
158
+ s.nb_pictures AS nbpic,
159
+ {status},
160
+ s.computed_capture_date AS datetime
161
+ FROM sequences s
162
+ LEFT JOIN accounts on s.account_id = accounts.id
163
+ WHERE {filter}
164
+ ORDER BY {order1}
165
+ LIMIT {limit}
168
166
  """
169
167
  )
170
168
  sqlSequences = sqlSequencesRaw.format(
@@ -306,3 +304,203 @@ def get_pagination_links(
306
304
  )
307
305
 
308
306
  return links
307
+
308
+
309
+ class Direction(Enum):
310
+ """Represent the sort direction"""
311
+
312
+ ASC = "+"
313
+ DESC = "-"
314
+
315
+ def is_reverse(self):
316
+ return self == Direction.DESC
317
+
318
+
319
+ class CollectionSortOrder(Enum):
320
+ """Represent the sort order"""
321
+
322
+ FILE_DATE = "filedate"
323
+ FILE_NAME = "filename"
324
+ GPS_DATE = "gpsdate"
325
+
326
+
327
+ @dataclass
328
+ class CollectionSort:
329
+ order: CollectionSortOrder
330
+ direction: Direction
331
+
332
+ def as_str(self) -> str:
333
+ return f"{self.direction.value}{self.order.value}"
334
+
335
+
336
+ def sort_collection(db, collectionId: UUID, sortby: CollectionSort):
337
+ """
338
+ Sort a collection by a given parameter
339
+
340
+ Note: the transaction is not commited at the end, you need to commit it or use an autocommit connection
341
+ """
342
+
343
+ # Remove existing order, and keep list of pictures IDs
344
+ picIds = db.execute(
345
+ SQL(
346
+ """
347
+ DELETE FROM sequences_pictures
348
+ WHERE seq_id = %(id)s
349
+ RETURNING pic_id
350
+ """
351
+ ),
352
+ {"id": collectionId},
353
+ ).fetchall()
354
+ picIds = [p["pic_id"] for p in picIds]
355
+
356
+ # Fetch metadata and EXIF tags of concerned pictures
357
+ picMetas = db.execute(SQL("SELECT id, metadata, exif FROM pictures WHERE id = ANY(%s)"), [picIds]).fetchall()
358
+ usedDateField = None
359
+ isFileNameNumeric = False
360
+
361
+ if sortby.order == CollectionSortOrder.FILE_NAME:
362
+ # Check if filenames are numeric
363
+ try:
364
+ for pm in picMetas:
365
+ int(PurePath(pm["metadata"]["originalFileName"] or "").stem)
366
+ isFileNameNumeric = True
367
+ except ValueError:
368
+ pass
369
+
370
+ if sortby.order == CollectionSortOrder.FILE_DATE:
371
+ # Look out what EXIF field is used for storing dates in this sequence
372
+ for field in [
373
+ "Exif.Image.DateTimeOriginal",
374
+ "Exif.Photo.DateTimeOriginal",
375
+ "Exif.Image.DateTime",
376
+ "Xmp.GPano.SourceImageCreateTime",
377
+ ]:
378
+ if field in picMetas[0]["exif"]:
379
+ usedDateField = field
380
+ break
381
+
382
+ if usedDateField is None:
383
+ raise errors.InvalidAPIUsage(
384
+ "Sort by file date is not possible on this sequence (no file date information available on pictures)",
385
+ status_code=422,
386
+ )
387
+
388
+ for pm in picMetas:
389
+ # Find value for wanted sort
390
+ if sortby.order == CollectionSortOrder.GPS_DATE:
391
+ pm["sort"] = reader.decodeGPSDateTime(pm["exif"], "Exif.GPSInfo")[0]
392
+ elif sortby.order == CollectionSortOrder.FILE_DATE:
393
+ assert usedDateField # nullity has been checked before
394
+ pm["sort"] = reader.decodeDateTimeOriginal(pm["exif"], usedDateField)[0]
395
+ elif sortby.order == CollectionSortOrder.FILE_NAME:
396
+ pm["sort"] = pm["metadata"].get("originalFileName")
397
+ if isFileNameNumeric:
398
+ pm["sort"] = int(PurePath(pm["sort"]).stem)
399
+
400
+ # Fail if sort value is missing
401
+ if pm["sort"] is None:
402
+ raise errors.InvalidAPIUsage(
403
+ f"Sort using {sortby} is not possible on this sequence, picture {pm['id']} is missing mandatory metadata",
404
+ status_code=422,
405
+ )
406
+
407
+ # Actual sorting of pictures
408
+ picMetas.sort(key=lambda p: p["sort"], reverse=sortby.direction.is_reverse())
409
+ picForDb = [(collectionId, i + 1, p["id"]) for i, p in enumerate(picMetas)]
410
+
411
+ # Inject back pictures in sequence
412
+ db.executemany(
413
+ SQL(
414
+ """
415
+ INSERT INTO sequences_pictures(seq_id, rank, pic_id)
416
+ VALUES (%s, %s, %s)
417
+ """
418
+ ),
419
+ picForDb,
420
+ )
421
+
422
+
423
+ def update_headings(
424
+ db,
425
+ sequenceId: UUID,
426
+ editingAccount: Optional[UUID] = None,
427
+ relativeHeading: int = 0,
428
+ updateOnlyMissing: bool = True,
429
+ ):
430
+ """Defines pictures heading according to sequence path.
431
+ Database is not committed in this function, to make entry definitively stored
432
+ you have to call db.commit() after or use an autocommit connection.
433
+
434
+ Parameters
435
+ ----------
436
+ db : psycopg.Connection
437
+ Database connection
438
+ sequenceId : uuid
439
+ The sequence's uuid, as stored in the database
440
+ relativeHeading : int
441
+ Camera relative orientation compared to path, in degrees clockwise.
442
+ Example: 0° = looking forward, 90° = looking to right, 180° = looking backward, -90° = looking left.
443
+ updateOnlyMissing : bool
444
+ If true, doesn't change existing heading values in database
445
+ """
446
+
447
+ db.execute(
448
+ SQL(
449
+ """
450
+ WITH h AS (
451
+ SELECT
452
+ p.id,
453
+ p.heading AS old_heading,
454
+ CASE
455
+ WHEN LEAD(sp.rank) OVER othpics IS NULL AND LAG(sp.rank) OVER othpics IS NULL
456
+ THEN NULL
457
+ WHEN LEAD(sp.rank) OVER othpics IS NULL
458
+ THEN (360 + FLOOR(DEGREES(ST_Azimuth(LAG(p.geom) OVER othpics, p.geom)))::int + (%(diff)s %% 360)) %% 360
459
+ ELSE
460
+ (360 + FLOOR(DEGREES(ST_Azimuth(p.geom, LEAD(p.geom) OVER othpics)))::int + (%(diff)s %% 360)) %% 360
461
+ END AS heading
462
+ FROM pictures p
463
+ JOIN sequences_pictures sp ON sp.pic_id = p.id AND sp.seq_id = %(seq)s
464
+ WINDOW othpics AS (ORDER BY sp.rank)
465
+ )
466
+ UPDATE pictures p
467
+ SET heading = h.heading, heading_computed = true {editing_account}
468
+ FROM h
469
+ WHERE h.id = p.id {update_missing}
470
+ """
471
+ ).format(
472
+ update_missing=SQL(" AND (p.heading IS NULL OR p.heading = 0 OR p.heading_computed)") if updateOnlyMissing else SQL(""),
473
+ editing_account=SQL(", last_account_to_edit = %(account)s") if editingAccount is not None else SQL(""),
474
+ ), # 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
475
+ {"seq": sequenceId, "diff": relativeHeading, "account": editingAccount},
476
+ )
477
+
478
+
479
+ def update_pictures_grid(db) -> bool:
480
+ """Refreshes the pictures_grid materialized view for an up-to-date view of pictures availability on map.
481
+
482
+ Note: the transaction is not commited at the end, you need to commit it or use an autocommit connection.
483
+
484
+ Parameters
485
+ ----------
486
+ db : psycopg.Connection
487
+ Database connection
488
+
489
+ Returns
490
+ -------
491
+ bool : True if the view has been updated else False
492
+ """
493
+ logger = logging.getLogger("geovisio.picture_grid")
494
+ with db.transaction():
495
+ try:
496
+ db.execute("SELECT refreshed_at FROM refresh_database FOR UPDATE NOWAIT").fetchone()
497
+ except psycopg.errors.LockNotAvailable:
498
+ logger.info("Database refresh already in progress, nothing to do")
499
+ return False
500
+
501
+ with sentry_sdk.start_span(description="Refreshing database") as span:
502
+ with utils.time.log_elapsed(f"Refreshing database", logger=logger):
503
+ logger.info("Refreshing database")
504
+ db.execute("UPDATE refresh_database SET refreshed_at = NOW()")
505
+ db.execute("REFRESH MATERIALIZED VIEW pictures_grid")
506
+ return True
geovisio/web/auth.py CHANGED
@@ -3,8 +3,9 @@ from flask import current_app, url_for, session, redirect, request, jsonify
3
3
  import psycopg
4
4
  from typing import Any
5
5
  from urllib.parse import quote
6
- from geovisio import utils
6
+ from geovisio import utils, errors
7
7
  from geovisio.utils.auth import Account, ACCOUNT_KEY
8
+ from authlib.integrations.base_client.errors import MismatchingStateError
8
9
 
9
10
  bp = flask.Blueprint("auth", __name__, url_prefix="/api/auth")
10
11
 
@@ -52,7 +53,19 @@ def auth():
52
53
  schema:
53
54
  type: string
54
55
  """
55
- tokenResponse = utils.auth.oauth_provider.client.authorize_access_token()
56
+ try:
57
+ tokenResponse = utils.auth.oauth_provider.client.authorize_access_token()
58
+ except MismatchingStateError as e:
59
+ raise errors.InternalError(
60
+ "Impossible to finish authentication flow",
61
+ payload={
62
+ "details": {
63
+ "error": str(e),
64
+ "tips": "You can try to clear your cookies and retry. If the problem persists, contact your instance administrator.",
65
+ }
66
+ },
67
+ status_code=403,
68
+ )
56
69
 
57
70
  oauth_info = utils.auth.oauth_provider.get_user_oauth_info(tokenResponse)
58
71
  with psycopg.connect(current_app.config["DB_URL"]) as conn: