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 +3 -2
- geovisio/admin_cli/__init__.py +2 -2
- geovisio/admin_cli/db.py +11 -0
- geovisio/admin_cli/sequence_heading.py +2 -2
- geovisio/config_app.py +25 -0
- geovisio/templates/main.html +2 -2
- geovisio/utils/pictures.py +75 -30
- geovisio/utils/sequences.py +232 -34
- geovisio/web/auth.py +15 -2
- geovisio/web/collections.py +161 -111
- geovisio/web/docs.py +178 -4
- geovisio/web/items.py +169 -114
- geovisio/web/map.py +309 -93
- geovisio/web/params.py +82 -4
- geovisio/web/stac.py +14 -4
- geovisio/web/tokens.py +7 -3
- geovisio/web/users.py +4 -4
- geovisio/web/utils.py +10 -0
- geovisio/workers/runner_pictures.py +73 -70
- geovisio-2.6.0.dist-info/METADATA +92 -0
- geovisio-2.6.0.dist-info/RECORD +41 -0
- geovisio-2.4.0.dist-info/METADATA +0 -115
- geovisio-2.4.0.dist-info/RECORD +0 -41
- {geovisio-2.4.0.dist-info → geovisio-2.6.0.dist-info}/LICENSE +0 -0
- {geovisio-2.4.0.dist-info → geovisio-2.6.0.dist-info}/WHEEL +0 -0
geovisio/__init__.py
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"""GeoVisio API - Main"""
|
|
2
2
|
|
|
3
|
-
__version__ = "2.
|
|
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
|
-
|
|
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)
|
geovisio/admin_cli/__init__.py
CHANGED
|
@@ -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/
|
|
62
|
-
logging.error("This function has been deprecated, use https://gitlab.com/
|
|
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.
|
|
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
|
-
|
|
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
|
|
geovisio/templates/main.html
CHANGED
|
@@ -67,7 +67,7 @@
|
|
|
67
67
|
+'<div id="viewer" style="width: 500px; height: 300px"></div>\n\n'
|
|
68
68
|
+'<script>\n'
|
|
69
69
|
+'\t// All options available are listed here\n'
|
|
70
|
-
+'\t// https://gitlab.com/
|
|
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/
|
|
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>
|
geovisio/utils/pictures.py
CHANGED
|
@@ -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
|
|
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 (
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
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}
|
geovisio/utils/sequences.py
CHANGED
|
@@ -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
|
|
8
|
+
from typing import Any, List, Dict, Optional
|
|
9
9
|
import datetime
|
|
10
10
|
from uuid import UUID
|
|
11
|
-
from
|
|
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
|
|
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
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
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
|
-
|
|
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:
|