geovisio 2.7.1__py3-none-any.whl → 2.8.1__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 +25 -4
- geovisio/admin_cli/__init__.py +3 -1
- geovisio/admin_cli/user.py +75 -0
- geovisio/config_app.py +86 -4
- geovisio/templates/main.html +2 -2
- geovisio/templates/viewer.html +3 -3
- geovisio/translations/br/LC_MESSAGES/messages.mo +0 -0
- geovisio/translations/br/LC_MESSAGES/messages.po +762 -0
- geovisio/translations/da/LC_MESSAGES/messages.mo +0 -0
- geovisio/translations/da/LC_MESSAGES/messages.po +859 -0
- geovisio/translations/de/LC_MESSAGES/messages.mo +0 -0
- geovisio/translations/de/LC_MESSAGES/messages.po +106 -1
- geovisio/translations/el/LC_MESSAGES/messages.mo +0 -0
- geovisio/translations/en/LC_MESSAGES/messages.mo +0 -0
- geovisio/translations/en/LC_MESSAGES/messages.po +218 -133
- geovisio/translations/eo/LC_MESSAGES/messages.mo +0 -0
- geovisio/translations/eo/LC_MESSAGES/messages.po +856 -0
- geovisio/translations/es/LC_MESSAGES/messages.mo +0 -0
- geovisio/translations/es/LC_MESSAGES/messages.po +4 -3
- geovisio/translations/fi/LC_MESSAGES/messages.mo +0 -0
- geovisio/translations/fr/LC_MESSAGES/messages.mo +0 -0
- geovisio/translations/fr/LC_MESSAGES/messages.po +66 -3
- geovisio/translations/hu/LC_MESSAGES/messages.mo +0 -0
- geovisio/translations/hu/LC_MESSAGES/messages.po +4 -3
- geovisio/translations/it/LC_MESSAGES/messages.mo +0 -0
- geovisio/translations/it/LC_MESSAGES/messages.po +884 -0
- geovisio/translations/ja/LC_MESSAGES/messages.mo +0 -0
- geovisio/translations/ja/LC_MESSAGES/messages.po +807 -0
- geovisio/translations/ko/LC_MESSAGES/messages.mo +0 -0
- geovisio/translations/messages.pot +191 -122
- geovisio/translations/nl/LC_MESSAGES/messages.mo +0 -0
- geovisio/translations/pl/LC_MESSAGES/messages.mo +0 -0
- geovisio/translations/pl/LC_MESSAGES/messages.po +728 -0
- geovisio/translations/zh_Hant/LC_MESSAGES/messages.mo +0 -0
- geovisio/translations/zh_Hant/LC_MESSAGES/messages.po +719 -0
- geovisio/utils/auth.py +80 -8
- geovisio/utils/link.py +3 -2
- geovisio/utils/loggers.py +14 -0
- geovisio/utils/model_query.py +55 -0
- geovisio/utils/params.py +7 -4
- geovisio/utils/pictures.py +12 -43
- geovisio/utils/semantics.py +120 -0
- geovisio/utils/sequences.py +10 -1
- geovisio/utils/tokens.py +5 -3
- geovisio/utils/upload_set.py +71 -22
- geovisio/utils/website.py +53 -0
- geovisio/web/annotations.py +17 -0
- geovisio/web/auth.py +11 -6
- geovisio/web/collections.py +217 -61
- geovisio/web/configuration.py +17 -1
- geovisio/web/docs.py +67 -67
- geovisio/web/items.py +220 -96
- geovisio/web/map.py +48 -18
- geovisio/web/pages.py +240 -0
- geovisio/web/params.py +17 -0
- geovisio/web/prepare.py +165 -0
- geovisio/web/stac.py +17 -4
- geovisio/web/tokens.py +14 -4
- geovisio/web/upload_set.py +108 -14
- geovisio/web/users.py +203 -44
- geovisio/workers/runner_pictures.py +61 -22
- {geovisio-2.7.1.dist-info → geovisio-2.8.1.dist-info}/METADATA +8 -6
- geovisio-2.8.1.dist-info/RECORD +92 -0
- {geovisio-2.7.1.dist-info → geovisio-2.8.1.dist-info}/WHEEL +1 -1
- geovisio-2.7.1.dist-info/RECORD +0 -70
- {geovisio-2.7.1.dist-info → geovisio-2.8.1.dist-info/licenses}/LICENSE +0 -0
geovisio/utils/upload_set.py
CHANGED
|
@@ -17,6 +17,8 @@ from flask import current_app
|
|
|
17
17
|
from flask_babel import gettext as _
|
|
18
18
|
from geopic_tag_reader import sequence as geopic_sequence, reader
|
|
19
19
|
|
|
20
|
+
from geovisio.utils.loggers import getLoggerWithExtra
|
|
21
|
+
|
|
20
22
|
|
|
21
23
|
class AggregatedStatus(BaseModel):
|
|
22
24
|
"""Aggregated status"""
|
|
@@ -147,6 +149,7 @@ class UploadSetFile(BaseModel):
|
|
|
147
149
|
"""File uploaded in an UploadSet"""
|
|
148
150
|
|
|
149
151
|
picture_id: Optional[UUID] = None
|
|
152
|
+
"""ID of the picture this file belongs to. Can only be seen by the owner of the File"""
|
|
150
153
|
file_name: str
|
|
151
154
|
content_md5: Optional[UUID] = None
|
|
152
155
|
inserted_at: datetime
|
|
@@ -434,6 +437,7 @@ def dispatch(upload_set_id: UUID):
|
|
|
434
437
|
if not db_upload_set:
|
|
435
438
|
raise Exception(f"Upload set {upload_set_id} not found")
|
|
436
439
|
|
|
440
|
+
logger = getLoggerWithExtra("geovisio.upload_set", {"upload_set_id": str(upload_set_id)})
|
|
437
441
|
with db.conn(current_app) as conn:
|
|
438
442
|
with conn.transaction(), conn.cursor(row_factory=dict_row) as cursor:
|
|
439
443
|
|
|
@@ -448,16 +452,25 @@ def dispatch(upload_set_id: UUID):
|
|
|
448
452
|
p.heading as heading,
|
|
449
453
|
p.metadata->>'originalFileName' as file_name,
|
|
450
454
|
p.metadata,
|
|
451
|
-
s.id as sequence_id
|
|
455
|
+
s.id as sequence_id,
|
|
456
|
+
f is null as has_no_file
|
|
452
457
|
FROM pictures p
|
|
453
458
|
LEFT JOIN sequences_pictures sp ON sp.pic_id = p.id
|
|
454
459
|
LEFT JOIN sequences s ON s.id = sp.seq_id
|
|
460
|
+
LEFT JOIN files f ON f.picture_id = p.id
|
|
455
461
|
WHERE p.upload_set_id = %(upload_set_id)s"""
|
|
456
462
|
),
|
|
457
463
|
{"upload_set_id": upload_set_id},
|
|
458
464
|
).fetchall()
|
|
459
465
|
|
|
466
|
+
# there is currently a bug where 2 pictures can be uploaded for the same file, so only 1 is associated to it.
|
|
467
|
+
# we want to delete one of them
|
|
468
|
+
# Those duplicates happen when a client send an upload that timeouts, but the client retries the upload and the server is not aware of this timeout (the connection is not closed).
|
|
469
|
+
# Note: later, if we are confident the bug has been removed, we might clean this code.
|
|
470
|
+
pics_to_delete_bug = [p["id"] for p in db_pics if p["has_no_file"]]
|
|
471
|
+
db_pics = [p for p in db_pics if p["has_no_file"] is False] # pictures without files will be deleted, we don't need them
|
|
460
472
|
pics_by_filename = {p["file_name"]: p for p in db_pics}
|
|
473
|
+
|
|
461
474
|
pics = [
|
|
462
475
|
geopic_sequence.Picture(
|
|
463
476
|
p["file_name"],
|
|
@@ -483,16 +496,19 @@ WHERE p.upload_set_id = %(upload_set_id)s"""
|
|
|
483
496
|
maxDistance=db_upload_set.duplicate_distance, maxRotationAngle=db_upload_set.duplicate_rotation
|
|
484
497
|
),
|
|
485
498
|
sortMethod=db_upload_set.sort_method,
|
|
486
|
-
splitParams=geopic_sequence.SplitParams(
|
|
499
|
+
splitParams=geopic_sequence.SplitParams(
|
|
500
|
+
maxDistance=db_upload_set.split_distance, maxTime=db_upload_set.split_time.total_seconds()
|
|
501
|
+
),
|
|
487
502
|
)
|
|
488
503
|
reused_sequence = set()
|
|
489
504
|
|
|
490
|
-
|
|
505
|
+
pics_to_delete_duplicates = [pics_by_filename[p.filename]["id"] for p in report.duplicate_pictures or []]
|
|
506
|
+
pics_to_delete = pics_to_delete_duplicates + pics_to_delete_bug
|
|
491
507
|
if pics_to_delete:
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
f"For uploadset '{upload_set_id}', duplicate pictures {[p.filename for p in report.duplicate_pictures or []]}"
|
|
508
|
+
logger.debug(
|
|
509
|
+
f"nb duplicate pictures {len(pics_to_delete_duplicates)} {f' and {len(pics_to_delete_bug)} pictures without files' if pics_to_delete_bug else ''}"
|
|
495
510
|
)
|
|
511
|
+
logger.debug(f"duplicate pictures {[p.filename for p in report.duplicate_pictures or []]}")
|
|
496
512
|
|
|
497
513
|
cursor.execute(SQL("CREATE TEMPORARY TABLE tmp_duplicates(picture_id UUID) ON COMMIT DROP"))
|
|
498
514
|
with cursor.copy("COPY tmp_duplicates(picture_id) FROM stdin;") as copy:
|
|
@@ -507,16 +523,17 @@ WHERE p.upload_set_id = %(upload_set_id)s"""
|
|
|
507
523
|
# delete all pictures (the DB triggers will also add background jobs to delete the associated files)
|
|
508
524
|
cursor.execute(SQL("DELETE FROM pictures WHERE id IN (select picture_id FROM tmp_duplicates)"))
|
|
509
525
|
|
|
510
|
-
|
|
526
|
+
number_title = len(report.sequences) > 1
|
|
527
|
+
existing_sequences = set(p["sequence_id"] for p in db_pics if p["sequence_id"])
|
|
528
|
+
new_sequence_ids = set()
|
|
529
|
+
for i, s in enumerate(report.sequences, start=1):
|
|
511
530
|
existing_sequence = next(
|
|
512
531
|
(seq for p in s.pictures if (seq := pics_by_filename[p.filename]["sequence_id"]) not in reused_sequence),
|
|
513
532
|
None,
|
|
514
533
|
)
|
|
515
534
|
# if some of the pictures were already in a sequence, we should not create a new one
|
|
516
535
|
if existing_sequence:
|
|
517
|
-
|
|
518
|
-
f"For uploadset '{upload_set_id}', sequence {existing_sequence} already contains pictures, we will not create a new one"
|
|
519
|
-
)
|
|
536
|
+
logger.info(f"sequence {existing_sequence} already contains pictures, we will not create a new one")
|
|
520
537
|
# we should wipe the sequences_pictures though
|
|
521
538
|
seq_id = existing_sequence
|
|
522
539
|
cursor.execute(
|
|
@@ -525,6 +542,7 @@ WHERE p.upload_set_id = %(upload_set_id)s"""
|
|
|
525
542
|
)
|
|
526
543
|
reused_sequence.add(seq_id)
|
|
527
544
|
else:
|
|
545
|
+
new_title = f"{db_upload_set.title}{f'-{i}' if number_title else ''}"
|
|
528
546
|
seq_id = cursor.execute(
|
|
529
547
|
SQL(
|
|
530
548
|
"""INSERT INTO sequences(account_id, metadata, user_agent)
|
|
@@ -533,12 +551,14 @@ RETURNING id"""
|
|
|
533
551
|
),
|
|
534
552
|
{
|
|
535
553
|
"account_id": db_upload_set.account_id,
|
|
536
|
-
"metadata": Jsonb({"title":
|
|
554
|
+
"metadata": Jsonb({"title": new_title}),
|
|
537
555
|
"user_agent": db_upload_set.user_agent,
|
|
538
556
|
},
|
|
539
557
|
).fetchone()
|
|
540
558
|
seq_id = seq_id["id"]
|
|
541
559
|
|
|
560
|
+
new_sequence_ids.add(seq_id)
|
|
561
|
+
|
|
542
562
|
with cursor.copy("COPY sequences_pictures(seq_id, pic_id, rank) FROM stdin;") as copy:
|
|
543
563
|
for i, p in enumerate(s.pictures, 1):
|
|
544
564
|
copy.write_row(
|
|
@@ -547,8 +567,17 @@ RETURNING id"""
|
|
|
547
567
|
|
|
548
568
|
sequences.add_finalization_job(cursor=cursor, seqId=seq_id)
|
|
549
569
|
|
|
570
|
+
# we can delete all the old sequences
|
|
571
|
+
sequences_to_delete = existing_sequences - new_sequence_ids
|
|
572
|
+
if sequences_to_delete:
|
|
573
|
+
logger.debug(f"sequences to delete = {sequences_to_delete} (existing = {existing_sequences}, new = {new_sequence_ids})")
|
|
574
|
+
conn.execute(SQL("DELETE FROM sequences_pictures WHERE seq_id = ANY(%(seq_ids)s)"), {"seq_ids": list(sequences_to_delete)})
|
|
575
|
+
conn.execute(
|
|
576
|
+
SQL("UPDATE sequences SET status = 'deleted' WHERE id = ANY(%(seq_ids)s)"), {"seq_ids": list(sequences_to_delete)}
|
|
577
|
+
)
|
|
578
|
+
|
|
550
579
|
for s in report.sequences_splits or []:
|
|
551
|
-
|
|
580
|
+
logger.debug(f"split = {s.prevPic.filename} -> {s.nextPic.filename} : {s.reason}")
|
|
552
581
|
conn.execute(SQL("UPDATE upload_sets SET dispatched = true WHERE id = %(upload_set_id)s"), {"upload_set_id": db_upload_set.id})
|
|
553
582
|
|
|
554
583
|
|
|
@@ -567,19 +596,39 @@ def insertFileInDatabase(
|
|
|
567
596
|
) -> UploadSetFile:
|
|
568
597
|
"""Insert a file linked to an UploadSet into the database"""
|
|
569
598
|
|
|
599
|
+
# we check if there is already a file with this name in the upload set with an associated picture.
|
|
600
|
+
# If there is no picture (because the picture has been rejected), we accept that the file is overridden
|
|
601
|
+
existing_file = cursor.execute(
|
|
602
|
+
SQL(
|
|
603
|
+
"""SELECT picture_id, rejection_status
|
|
604
|
+
FROM files
|
|
605
|
+
WHERE upload_set_id = %(upload_set_id)s AND file_name = %(file_name)s AND picture_id IS NOT NULL"""
|
|
606
|
+
),
|
|
607
|
+
params={
|
|
608
|
+
"upload_set_id": upload_set_id,
|
|
609
|
+
"file_name": file_name,
|
|
610
|
+
},
|
|
611
|
+
).fetchone()
|
|
612
|
+
if existing_file:
|
|
613
|
+
raise errors.InvalidAPIUsage(
|
|
614
|
+
_("A different picture with the same name has already been added to this uploadset"),
|
|
615
|
+
status_code=409,
|
|
616
|
+
payload={"existing_item": {"id": existing_file["picture_id"]}},
|
|
617
|
+
)
|
|
618
|
+
|
|
570
619
|
f = cursor.execute(
|
|
571
620
|
SQL(
|
|
572
621
|
"""INSERT INTO files(
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
622
|
+
upload_set_id, picture_id, file_type, file_name,
|
|
623
|
+
size, content_md5, rejection_status, rejection_message, rejection_details)
|
|
624
|
+
VALUES (
|
|
625
|
+
%(upload_set_id)s, %(picture_id)s, %(type)s, %(file_name)s,
|
|
626
|
+
%(size)s, %(content_md5)s, %(rejection_status)s, %(rejection_message)s, %(rejection_details)s)
|
|
627
|
+
ON CONFLICT (upload_set_id, file_name)
|
|
628
|
+
DO UPDATE SET picture_id = %(picture_id)s, size = %(size)s, content_md5 = %(content_md5)s,
|
|
629
|
+
rejection_status = %(rejection_status)s, rejection_message = %(rejection_message)s, rejection_details = %(rejection_details)s
|
|
630
|
+
WHERE files.picture_id IS NULL -- check again that we do not override an existing picture
|
|
631
|
+
RETURNING *"""
|
|
583
632
|
),
|
|
584
633
|
params={
|
|
585
634
|
"upload_set_id": upload_set_id,
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
from typing import Optional, Dict
|
|
2
|
+
|
|
3
|
+
from flask import url_for
|
|
4
|
+
|
|
5
|
+
from geovisio import web
|
|
6
|
+
|
|
7
|
+
WEBSITE_UNDER_SAME_HOST = "same-host"
|
|
8
|
+
|
|
9
|
+
TOKEN_ACCEPTED_PAGE = "token-accepted"
|
|
10
|
+
TOS_VALIDATION_PAGE = "tos-validation"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class Website:
|
|
14
|
+
"""Website associated to the API.
|
|
15
|
+
This wrapper will define the routes we expect from the website.
|
|
16
|
+
|
|
17
|
+
We should limit the interraction from the api to the website, but for some flow (especially auth flows), it's can be useful to redirect to website's page
|
|
18
|
+
|
|
19
|
+
If the url is:
|
|
20
|
+
* set to `false`, there is no associated website
|
|
21
|
+
* set to `same-host`, the website is assumed to be on the same host as the API (and will respect the host of the current request)
|
|
22
|
+
* else it should be a valid url
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
def __init__(self, website_url: str):
|
|
26
|
+
if website_url == WEBSITE_UNDER_SAME_HOST:
|
|
27
|
+
self.url = WEBSITE_UNDER_SAME_HOST
|
|
28
|
+
elif website_url == "false":
|
|
29
|
+
self.url = None
|
|
30
|
+
elif website_url.startswith("http"):
|
|
31
|
+
self.url = website_url
|
|
32
|
+
if not self.url.endswith("/"):
|
|
33
|
+
self.url += "/"
|
|
34
|
+
else:
|
|
35
|
+
raise Exception(
|
|
36
|
+
"API_WEBSITE_URL should either be `same-host` (and the website will be assumed to be on the same host), set to `false` if there is no website, or a valid URL"
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
def _to_url(self, route: str, params: Optional[Dict[str, str]] = None):
|
|
40
|
+
if not self.url:
|
|
41
|
+
return None
|
|
42
|
+
|
|
43
|
+
base_url = self.url if self.url != WEBSITE_UNDER_SAME_HOST else url_for("index", _external=True)
|
|
44
|
+
|
|
45
|
+
from urllib.parse import urlencode
|
|
46
|
+
|
|
47
|
+
return f"{base_url}{route}{f'?{urlencode(params)}' if params else ''}"
|
|
48
|
+
|
|
49
|
+
def tos_validation_page(self, params: Optional[Dict[str, str]] = None):
|
|
50
|
+
return self._to_url(TOS_VALIDATION_PAGE, params)
|
|
51
|
+
|
|
52
|
+
def cli_token_accepted_page(self, params: Optional[Dict[str, str]] = None):
|
|
53
|
+
return self._to_url(TOKEN_ACCEPTED_PAGE, params)
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
from geovisio.utils import auth
|
|
2
|
+
from psycopg.rows import dict_row, class_row
|
|
3
|
+
from psycopg.sql import SQL
|
|
4
|
+
from geovisio.utils.semantics import Entity, EntityType, SemanticTagUpdate, update_tags
|
|
5
|
+
from geovisio.web.utils import accountIdOrDefault
|
|
6
|
+
from psycopg.types.json import Jsonb
|
|
7
|
+
from geovisio.utils import db
|
|
8
|
+
from geovisio.utils.params import validation_error
|
|
9
|
+
from geovisio import errors
|
|
10
|
+
from pydantic import BaseModel, ConfigDict, ValidationError
|
|
11
|
+
from uuid import UUID
|
|
12
|
+
from typing import List, Optional
|
|
13
|
+
from flask import Blueprint, request, current_app
|
|
14
|
+
from flask_babel import gettext as _
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
bp = Blueprint("annotations", __name__, url_prefix="/api")
|
geovisio/web/auth.py
CHANGED
|
@@ -70,7 +70,7 @@ def auth():
|
|
|
70
70
|
oauth_info = utils.auth.oauth_provider.get_user_oauth_info(tokenResponse)
|
|
71
71
|
with db.cursor(current_app) as cursor:
|
|
72
72
|
res = cursor.execute(
|
|
73
|
-
"INSERT INTO accounts (name, oauth_provider, oauth_id) VALUES (%(name)s, %(provider)s, %(id)s) ON CONFLICT (oauth_provider, oauth_id) DO UPDATE SET name = %(name)s RETURNING id, name",
|
|
73
|
+
"INSERT INTO accounts (name, oauth_provider, oauth_id) VALUES (%(name)s, %(provider)s, %(id)s) ON CONFLICT (oauth_provider, oauth_id) DO UPDATE SET name = %(name)s RETURNING id, name, tos_accepted",
|
|
74
74
|
{
|
|
75
75
|
"provider": utils.auth.oauth_provider.name,
|
|
76
76
|
"id": oauth_info.id,
|
|
@@ -79,21 +79,26 @@ def auth():
|
|
|
79
79
|
).fetchone()
|
|
80
80
|
if res is None:
|
|
81
81
|
raise Exception("Impossible to insert user in database")
|
|
82
|
-
id, name = res
|
|
82
|
+
id, name, tos_accepted = res
|
|
83
83
|
account = Account(
|
|
84
84
|
id=str(id), # convert uuid to string for serialization
|
|
85
85
|
name=name,
|
|
86
86
|
oauth_provider=utils.auth.oauth_provider.name,
|
|
87
87
|
oauth_id=oauth_info.id,
|
|
88
|
+
tos_accepted=tos_accepted,
|
|
88
89
|
)
|
|
89
90
|
session[ACCOUNT_KEY] = account.model_dump(exclude_none=True)
|
|
90
91
|
session.permanent = True
|
|
91
92
|
|
|
92
93
|
next_url = session.pop(NEXT_URL_KEY, None)
|
|
93
|
-
if
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
94
|
+
if not tos_accepted and current_app.config["API_ENFORCE_TOS_ACCEPTANCE"]:
|
|
95
|
+
args = {"next_url": next_url} if next_url else None
|
|
96
|
+
next_url = current_app.config["API_WEBSITE_URL"].tos_validation_page(args)
|
|
97
|
+
|
|
98
|
+
if next_url is None:
|
|
99
|
+
next_url = "/"
|
|
100
|
+
|
|
101
|
+
response = flask.make_response(redirect(next_url))
|
|
97
102
|
|
|
98
103
|
# also store id/name in cookies for the front end to use those
|
|
99
104
|
max_age = current_app.config["PERMANENT_SESSION_LIFETIME"]
|