geovisio 2.6.0__py3-none-any.whl → 2.7.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- geovisio/__init__.py +36 -7
- geovisio/admin_cli/db.py +1 -4
- geovisio/config_app.py +40 -1
- geovisio/db_migrations.py +24 -3
- geovisio/templates/main.html +13 -13
- geovisio/templates/viewer.html +3 -3
- geovisio/translations/de/LC_MESSAGES/messages.mo +0 -0
- geovisio/translations/de/LC_MESSAGES/messages.po +667 -0
- geovisio/translations/en/LC_MESSAGES/messages.mo +0 -0
- geovisio/translations/en/LC_MESSAGES/messages.po +730 -0
- geovisio/translations/es/LC_MESSAGES/messages.mo +0 -0
- geovisio/translations/es/LC_MESSAGES/messages.po +778 -0
- geovisio/translations/fi/LC_MESSAGES/messages.mo +0 -0
- geovisio/translations/fi/LC_MESSAGES/messages.po +589 -0
- geovisio/translations/fr/LC_MESSAGES/messages.mo +0 -0
- geovisio/translations/fr/LC_MESSAGES/messages.po +814 -0
- geovisio/translations/ko/LC_MESSAGES/messages.mo +0 -0
- geovisio/translations/ko/LC_MESSAGES/messages.po +685 -0
- geovisio/translations/messages.pot +686 -0
- geovisio/translations/nl/LC_MESSAGES/messages.mo +0 -0
- geovisio/translations/nl/LC_MESSAGES/messages.po +594 -0
- geovisio/utils/__init__.py +1 -1
- geovisio/utils/auth.py +50 -11
- geovisio/utils/db.py +65 -0
- geovisio/utils/excluded_areas.py +83 -0
- geovisio/utils/extent.py +30 -0
- geovisio/utils/fields.py +1 -1
- geovisio/utils/filesystems.py +0 -1
- geovisio/utils/link.py +14 -0
- geovisio/utils/params.py +20 -0
- geovisio/utils/pictures.py +92 -68
- geovisio/utils/reports.py +171 -0
- geovisio/utils/sequences.py +264 -126
- geovisio/utils/tokens.py +37 -42
- geovisio/utils/upload_set.py +654 -0
- geovisio/web/auth.py +37 -37
- geovisio/web/collections.py +286 -302
- geovisio/web/configuration.py +14 -0
- geovisio/web/docs.py +241 -14
- geovisio/web/excluded_areas.py +377 -0
- geovisio/web/items.py +156 -108
- geovisio/web/map.py +20 -20
- geovisio/web/params.py +69 -26
- geovisio/web/pictures.py +14 -31
- geovisio/web/reports.py +399 -0
- geovisio/web/rss.py +13 -7
- geovisio/web/stac.py +129 -134
- geovisio/web/tokens.py +98 -109
- geovisio/web/upload_set.py +768 -0
- geovisio/web/users.py +100 -73
- geovisio/web/utils.py +28 -9
- geovisio/workers/runner_pictures.py +252 -204
- {geovisio-2.6.0.dist-info → geovisio-2.7.0.dist-info}/METADATA +16 -13
- geovisio-2.7.0.dist-info/RECORD +66 -0
- geovisio-2.6.0.dist-info/RECORD +0 -41
- {geovisio-2.6.0.dist-info → geovisio-2.7.0.dist-info}/LICENSE +0 -0
- {geovisio-2.6.0.dist-info → geovisio-2.7.0.dist-info}/WHEEL +0 -0
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
from enum import Enum
|
|
2
|
+
from uuid import UUID
|
|
3
|
+
from typing import Optional, List
|
|
4
|
+
from typing_extensions import Self
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
from pydantic import BaseModel, ConfigDict
|
|
7
|
+
from geovisio.utils import db
|
|
8
|
+
from geovisio.errors import InvalidAPIUsage
|
|
9
|
+
from flask import current_app
|
|
10
|
+
from psycopg.sql import SQL
|
|
11
|
+
from psycopg.rows import class_row
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class ReportType(Enum):
|
|
15
|
+
blur_missing = "blur_missing"
|
|
16
|
+
blur_excess = "blur_excess"
|
|
17
|
+
inappropriate = "inappropriate"
|
|
18
|
+
privacy = "privacy"
|
|
19
|
+
picture_low_quality = "picture_low_quality"
|
|
20
|
+
mislocated = "mislocated"
|
|
21
|
+
copyright = "copyright"
|
|
22
|
+
other = "other"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class ReportStatus(Enum):
|
|
26
|
+
open = "open"
|
|
27
|
+
open_autofix = "open_autofix"
|
|
28
|
+
waiting = "waiting"
|
|
29
|
+
closed_solved = "closed_solved"
|
|
30
|
+
closed_ignored = "closed_ignored"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class Report(BaseModel):
|
|
34
|
+
"""A Report is a problem reported from a third-party about a picture or a sequence."""
|
|
35
|
+
|
|
36
|
+
id: UUID
|
|
37
|
+
issue: ReportType
|
|
38
|
+
status: ReportStatus
|
|
39
|
+
picture_id: Optional[UUID]
|
|
40
|
+
sequence_id: Optional[UUID]
|
|
41
|
+
ts_opened: datetime
|
|
42
|
+
ts_closed: Optional[datetime]
|
|
43
|
+
reporter_account_id: Optional[UUID]
|
|
44
|
+
reporter_email: Optional[str]
|
|
45
|
+
resolver_account_id: Optional[UUID]
|
|
46
|
+
reporter_comments: Optional[str]
|
|
47
|
+
resolver_comments: Optional[str]
|
|
48
|
+
|
|
49
|
+
model_config = ConfigDict(use_enum_values=True, ser_json_timedelta="float")
|
|
50
|
+
|
|
51
|
+
def for_public(self) -> Self:
|
|
52
|
+
"""Report version for public display (without report email and admin comments)"""
|
|
53
|
+
return Report(
|
|
54
|
+
id=self.id,
|
|
55
|
+
issue=self.issue,
|
|
56
|
+
status=self.status,
|
|
57
|
+
picture_id=self.picture_id,
|
|
58
|
+
sequence_id=self.sequence_id,
|
|
59
|
+
ts_opened=self.ts_opened,
|
|
60
|
+
ts_closed=self.ts_closed,
|
|
61
|
+
reporter_account_id=self.reporter_account_id,
|
|
62
|
+
reporter_email=None,
|
|
63
|
+
resolver_account_id=self.resolver_account_id,
|
|
64
|
+
reporter_comments=self.reporter_comments,
|
|
65
|
+
resolver_comments=None,
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class Reports(BaseModel):
|
|
70
|
+
reports: List[Report]
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def get_report(id: UUID) -> Optional[Report]:
|
|
74
|
+
"""Get the Report corresponding to the ID"""
|
|
75
|
+
db_report = db.fetchone(
|
|
76
|
+
current_app,
|
|
77
|
+
SQL("SELECT * FROM reports WHERE id = %(id)s"),
|
|
78
|
+
{"id": id},
|
|
79
|
+
row_factory=class_row(Report),
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
return db_report
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def is_picture_owner(report: Report, account_id: UUID):
|
|
86
|
+
"""Check if given account is owner of picture concerned by report"""
|
|
87
|
+
|
|
88
|
+
isOwner = False
|
|
89
|
+
if report.picture_id is not None:
|
|
90
|
+
concernedPic = db.fetchone(
|
|
91
|
+
current_app,
|
|
92
|
+
SQL("SELECT id FROM pictures WHERE id = %(id)s AND account_id = %(uid)s"),
|
|
93
|
+
{"id": report.picture_id, "uid": account_id},
|
|
94
|
+
)
|
|
95
|
+
isOwner = concernedPic is not None
|
|
96
|
+
elif report.sequence_id is not None:
|
|
97
|
+
concernedSeq = db.fetchone(
|
|
98
|
+
current_app,
|
|
99
|
+
SQL("SELECT id FROM sequences WHERE id = %(id)s AND account_id = %(uid)s"),
|
|
100
|
+
{"id": report.sequence_id, "uid": account_id},
|
|
101
|
+
)
|
|
102
|
+
isOwner = concernedSeq is not None
|
|
103
|
+
return isOwner
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _parse_filter(filter: Optional[str]) -> SQL:
|
|
107
|
+
"""
|
|
108
|
+
Parse a filter string and return a SQL expression
|
|
109
|
+
|
|
110
|
+
>>> _parse_filter('')
|
|
111
|
+
SQL('TRUE')
|
|
112
|
+
>>> _parse_filter(None)
|
|
113
|
+
SQL('TRUE')
|
|
114
|
+
>>> _parse_filter("status = \'open\'")
|
|
115
|
+
SQL("(r.status = \'open\')")
|
|
116
|
+
>>> _parse_filter("status IN (\'open_autofix\', \'waiting\')")
|
|
117
|
+
SQL("r.status IN (\'open_autofix\', \'waiting\')")
|
|
118
|
+
>>> _parse_filter("reporter = \'me\'")
|
|
119
|
+
SQL('(reporter_account_id = %(account_id)s)')
|
|
120
|
+
>>> _parse_filter("owner = \'me\'")
|
|
121
|
+
SQL('(COALESCE(p.account_id, s.account_id) = %(account_id)s)')
|
|
122
|
+
>>> _parse_filter("status IN (\'open\', \'open_autofix\', \'waiting\') AND (owner = \'me\' OR reporter = \'me\')")
|
|
123
|
+
SQL("(r.status IN (\'open\', \'open_autofix\', \'waiting\') AND ((COALESCE(p.account_id, s.account_id) = %(account_id)s) OR (reporter_account_id = %(account_id)s)))")
|
|
124
|
+
"""
|
|
125
|
+
if not filter:
|
|
126
|
+
return SQL("TRUE")
|
|
127
|
+
from pygeofilter.backends.sql import to_sql_where
|
|
128
|
+
from pygeofilter.parsers.ecql import parse as ecql_parser
|
|
129
|
+
|
|
130
|
+
try:
|
|
131
|
+
filterAst = ecql_parser(filter)
|
|
132
|
+
fieldsToFilter = {
|
|
133
|
+
"status": "r.status",
|
|
134
|
+
"reporter": "reporter_account_id",
|
|
135
|
+
"owner": "COALESCE(p.account_id, s.account_id)",
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
f = to_sql_where(filterAst, fieldsToFilter).replace('"', "").replace("'me'", "%(account_id)s") # type: ignore
|
|
139
|
+
return SQL(f) # type: ignore
|
|
140
|
+
except Exception as e:
|
|
141
|
+
print(e)
|
|
142
|
+
raise InvalidAPIUsage(_("Unsupported filter parameter"), status_code=400)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def list_reports(account_id: UUID, limit: int = 100, filter: Optional[str] = None, forceAccount: bool = True) -> Reports:
|
|
146
|
+
filter_sql = _parse_filter(filter)
|
|
147
|
+
if forceAccount:
|
|
148
|
+
filter_sql = SQL(" ").join(
|
|
149
|
+
[SQL("(COALESCE(p.account_id, s.account_id) = %(account_id)s OR reporter_account_id = %(account_id)s) AND "), filter_sql]
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
l = db.fetchall(
|
|
153
|
+
current_app,
|
|
154
|
+
SQL(
|
|
155
|
+
"""
|
|
156
|
+
SELECT
|
|
157
|
+
r.*,
|
|
158
|
+
COALESCE(p.account_id, s.account_id) AS owner_account_id
|
|
159
|
+
FROM reports r
|
|
160
|
+
LEFT JOIN pictures p ON r.picture_id = p.id
|
|
161
|
+
LEFT JOIN sequences s ON r.sequence_id = s.id
|
|
162
|
+
WHERE {filter}
|
|
163
|
+
ORDER BY ts_opened DESC
|
|
164
|
+
LIMIT %(limit)s
|
|
165
|
+
"""
|
|
166
|
+
).format(filter=filter_sql),
|
|
167
|
+
{"account_id": account_id, "limit": limit},
|
|
168
|
+
row_factory=class_row(Report),
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
return Reports(reports=l)
|
geovisio/utils/sequences.py
CHANGED
|
@@ -1,14 +1,16 @@
|
|
|
1
1
|
import psycopg
|
|
2
2
|
from flask import current_app, url_for
|
|
3
|
+
from flask_babel import gettext as _
|
|
3
4
|
from psycopg.types.json import Jsonb
|
|
4
|
-
from psycopg import
|
|
5
|
-
from psycopg.sql import SQL
|
|
5
|
+
from psycopg.sql import SQL, Composable
|
|
6
6
|
from psycopg.rows import dict_row
|
|
7
7
|
from dataclasses import dataclass, field
|
|
8
8
|
from typing import Any, List, Dict, Optional
|
|
9
9
|
import datetime
|
|
10
10
|
from uuid import UUID
|
|
11
11
|
from enum import Enum
|
|
12
|
+
from geovisio.utils import db
|
|
13
|
+
from geovisio.utils.auth import Account
|
|
12
14
|
from geovisio.utils.fields import FieldMapping, SortBy, SQLDirection, BBox, Bounds
|
|
13
15
|
from geopic_tag_reader import reader
|
|
14
16
|
from pathlib import PurePath
|
|
@@ -17,30 +19,26 @@ import logging
|
|
|
17
19
|
import sentry_sdk
|
|
18
20
|
|
|
19
21
|
|
|
20
|
-
def createSequence(metadata, accountId) ->
|
|
21
|
-
with
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
if seqId is None:
|
|
32
|
-
raise Exception(f"impossible to insert sequence in database")
|
|
33
|
-
return seqId[0]
|
|
22
|
+
def createSequence(metadata, accountId, user_agent: Optional[str] = None) -> UUID:
|
|
23
|
+
with db.execute(
|
|
24
|
+
current_app,
|
|
25
|
+
"INSERT INTO sequences(account_id, metadata, user_agent) VALUES(%s, %s, %s) RETURNING id",
|
|
26
|
+
[accountId, Jsonb(metadata), user_agent],
|
|
27
|
+
) as r:
|
|
28
|
+
seqId = r.fetchone()
|
|
29
|
+
if seqId is None:
|
|
30
|
+
raise Exception("impossible to insert sequence in database")
|
|
31
|
+
return seqId[0]
|
|
34
32
|
|
|
35
33
|
|
|
36
34
|
# Mappings from stac name to SQL names
|
|
37
35
|
STAC_FIELD_MAPPINGS = {
|
|
38
36
|
p.stac: p
|
|
39
37
|
for p in [
|
|
40
|
-
FieldMapping(sql_column=
|
|
41
|
-
FieldMapping(sql_column=
|
|
42
|
-
FieldMapping(sql_column=
|
|
43
|
-
FieldMapping(sql_column=
|
|
38
|
+
FieldMapping(sql_column=SQL("inserted_at"), stac="created"),
|
|
39
|
+
FieldMapping(sql_column=SQL("updated_at"), stac="updated"),
|
|
40
|
+
FieldMapping(sql_column=SQL("computed_capture_date"), stac="datetime"),
|
|
41
|
+
FieldMapping(sql_column=SQL("status"), stac="status"),
|
|
44
42
|
]
|
|
45
43
|
}
|
|
46
44
|
STAC_FIELD_TO_SQL_FILTER = {p.stac: p.sql_filter.as_string(None) for p in STAC_FIELD_MAPPINGS.values()}
|
|
@@ -66,8 +64,8 @@ class CollectionsRequest:
|
|
|
66
64
|
created_before: Optional[datetime.datetime] = None
|
|
67
65
|
user_id: Optional[UUID] = None
|
|
68
66
|
bbox: Optional[BBox] = None
|
|
69
|
-
user_filter: Optional[
|
|
70
|
-
pagination_filter: Optional[
|
|
67
|
+
user_filter: Optional[SQL] = None
|
|
68
|
+
pagination_filter: Optional[SQL] = None
|
|
71
69
|
limit: int = 100
|
|
72
70
|
userOwnsAllCollections: bool = False # bool to represent that the user's asking for the collections is the owner of them
|
|
73
71
|
|
|
@@ -77,9 +75,8 @@ class CollectionsRequest:
|
|
|
77
75
|
|
|
78
76
|
def get_collections(request: CollectionsRequest) -> Collections:
|
|
79
77
|
# Check basic parameters
|
|
80
|
-
seq_filter: List[
|
|
78
|
+
seq_filter: List[Composable] = []
|
|
81
79
|
seq_params: dict = {}
|
|
82
|
-
pic_filter = [SQL("sp.seq_id = s.id")]
|
|
83
80
|
|
|
84
81
|
# Sort-by parameter
|
|
85
82
|
# Note for review: I'm not sure I understand this non nullity constraint, but if so, shouldn't all sortby fields be added ?
|
|
@@ -92,25 +89,25 @@ def get_collections(request: CollectionsRequest) -> Collections:
|
|
|
92
89
|
seq_filter.append(SQL("s.account_id = %(account)s"))
|
|
93
90
|
seq_params["account"] = request.user_id
|
|
94
91
|
|
|
95
|
-
if request.user_filter is None
|
|
92
|
+
user_filter_str = request.user_filter.as_string(None) if request.user_filter is not None else None
|
|
93
|
+
if user_filter_str is None or "status" not in user_filter_str:
|
|
96
94
|
# if the filter does not contains any `status` condition, we want to show only 'ready' collection to the general users, and non deleted one for the owner
|
|
97
95
|
if not request.userOwnsAllCollections:
|
|
98
|
-
seq_filter.append(SQL("
|
|
99
|
-
pic_filter.append(SQL("p.status = 'ready'"))
|
|
96
|
+
seq_filter.append(SQL("status = 'ready'"))
|
|
100
97
|
else:
|
|
101
|
-
seq_filter.append(SQL("
|
|
98
|
+
seq_filter.append(SQL("status != 'deleted'"))
|
|
102
99
|
else:
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
seq_filter.append(SQL("
|
|
106
|
-
pic_filter.append(SQL("p.status <> 'hidden'"))
|
|
100
|
+
if not request.userOwnsAllCollections and "'deleted'" not in user_filter_str:
|
|
101
|
+
# if there are status filter and we ask for deleted sequence, we also include hidden one and consider them as deleted
|
|
102
|
+
seq_filter.append(SQL("status <> 'hidden'"))
|
|
107
103
|
|
|
108
104
|
status_field = None
|
|
109
105
|
if request.userOwnsAllCollections:
|
|
110
106
|
# only logged users can see detailed status
|
|
111
107
|
status_field = SQL("s.status AS status")
|
|
112
108
|
else:
|
|
113
|
-
|
|
109
|
+
# hidden sequence are marked as deleted, this way crawler can update their catalog
|
|
110
|
+
status_field = SQL("CASE WHEN s.status IN ('hidden', 'deleted') THEN 'deleted' ELSE s.status END AS status")
|
|
114
111
|
|
|
115
112
|
# Datetime
|
|
116
113
|
if request.min_dt is not None:
|
|
@@ -136,94 +133,90 @@ def get_collections(request: CollectionsRequest) -> Collections:
|
|
|
136
133
|
seq_filter.append(SQL("s.inserted_at < %(created_before)s::timestamp with time zone"))
|
|
137
134
|
seq_params["created_before"] = request.created_before
|
|
138
135
|
|
|
139
|
-
with
|
|
140
|
-
|
|
141
|
-
sqlSequencesRaw = SQL(
|
|
142
|
-
"""
|
|
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}
|
|
136
|
+
with utils.db.cursor(current_app, row_factory=dict_row) as cursor:
|
|
137
|
+
sqlSequencesRaw = SQL(
|
|
166
138
|
"""
|
|
139
|
+
SELECT
|
|
140
|
+
s.id,
|
|
141
|
+
s.status,
|
|
142
|
+
s.metadata->>'title' AS name,
|
|
143
|
+
s.inserted_at AS created,
|
|
144
|
+
s.updated_at AS updated,
|
|
145
|
+
ST_XMin(s.bbox) AS minx,
|
|
146
|
+
ST_YMin(s.bbox) AS miny,
|
|
147
|
+
ST_XMax(s.bbox) AS maxx,
|
|
148
|
+
ST_YMax(s.bbox) AS maxy,
|
|
149
|
+
accounts.name AS account_name,
|
|
150
|
+
s.account_id AS account_id,
|
|
151
|
+
ST_X(ST_PointN(ST_GeometryN(s.geom, 1), 1)) AS x1,
|
|
152
|
+
ST_Y(ST_PointN(ST_GeometryN(s.geom, 1), 1)) AS y1,
|
|
153
|
+
s.min_picture_ts AS mints,
|
|
154
|
+
s.max_picture_ts AS maxts,
|
|
155
|
+
s.nb_pictures AS nbpic,
|
|
156
|
+
{status},
|
|
157
|
+
s.computed_capture_date AS datetime,
|
|
158
|
+
s.user_agent,
|
|
159
|
+
ROUND(ST_Length(s.geom::geography)) / 1000 AS length_km
|
|
160
|
+
FROM sequences s
|
|
161
|
+
LEFT JOIN accounts on s.account_id = accounts.id
|
|
162
|
+
WHERE {filter}
|
|
163
|
+
ORDER BY {order1}
|
|
164
|
+
LIMIT {limit}
|
|
165
|
+
"""
|
|
166
|
+
)
|
|
167
|
+
sqlSequences = sqlSequencesRaw.format(
|
|
168
|
+
filter=SQL(" AND ").join(seq_filter),
|
|
169
|
+
order1=request.sort_by.as_sql(),
|
|
170
|
+
limit=request.limit,
|
|
171
|
+
status=status_field,
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
# Different request if we want the last n sequences
|
|
175
|
+
# Useful for paginating from last page to first
|
|
176
|
+
if request.pagination_filter and (
|
|
177
|
+
(
|
|
178
|
+
request.sort_by.fields[0].direction == SQLDirection.ASC
|
|
179
|
+
and request.pagination_filter.as_string(None).startswith(f"({request.sort_by.fields[0].field.sql_filter.as_string(None)} <")
|
|
180
|
+
)
|
|
181
|
+
or (
|
|
182
|
+
request.sort_by.fields[0].direction == SQLDirection.DESC
|
|
183
|
+
and request.pagination_filter.as_string(None).startswith(f"({request.sort_by.fields[0].field.sql_filter.as_string(None)} >")
|
|
167
184
|
)
|
|
168
|
-
|
|
185
|
+
):
|
|
186
|
+
base_query = sqlSequencesRaw.format(
|
|
169
187
|
filter=SQL(" AND ").join(seq_filter),
|
|
170
|
-
order1=request.sort_by.
|
|
188
|
+
order1=request.sort_by.revert(),
|
|
171
189
|
limit=request.limit,
|
|
172
|
-
pic_filter=SQL(" AND ").join(pic_filter),
|
|
173
190
|
status=status_field,
|
|
174
191
|
)
|
|
175
|
-
|
|
176
|
-
# Different request if we want the last n sequences
|
|
177
|
-
# Useful for paginating from last page to first
|
|
178
|
-
if request.pagination_filter and (
|
|
179
|
-
(
|
|
180
|
-
request.sort_by.fields[0].direction == SQLDirection.ASC
|
|
181
|
-
and request.pagination_filter.as_string(None).startswith(
|
|
182
|
-
f"({request.sort_by.fields[0].field.sql_filter.as_string(None)} <"
|
|
183
|
-
)
|
|
184
|
-
)
|
|
185
|
-
or (
|
|
186
|
-
request.sort_by.fields[0].direction == SQLDirection.DESC
|
|
187
|
-
and request.pagination_filter.as_string(None).startswith(
|
|
188
|
-
f"({request.sort_by.fields[0].field.sql_filter.as_string(None)} >"
|
|
189
|
-
)
|
|
190
|
-
)
|
|
191
|
-
):
|
|
192
|
-
base_query = sqlSequencesRaw.format(
|
|
193
|
-
filter=SQL(" AND ").join(seq_filter),
|
|
194
|
-
order1=request.sort_by.revert(),
|
|
195
|
-
limit=request.limit,
|
|
196
|
-
pic_filter=SQL(" AND ").join(pic_filter),
|
|
197
|
-
status=status_field,
|
|
198
|
-
)
|
|
199
|
-
sqlSequences = SQL(
|
|
200
|
-
"""
|
|
201
|
-
SELECT *
|
|
202
|
-
FROM ({base_query}) s
|
|
203
|
-
ORDER BY {order2}
|
|
192
|
+
sqlSequences = SQL(
|
|
204
193
|
"""
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
query_bounds = None
|
|
213
|
-
for s in records:
|
|
214
|
-
first_order_val = s.get(request.sort_by.fields[0].field.stac)
|
|
215
|
-
if first_order_val is None:
|
|
216
|
-
continue
|
|
217
|
-
if query_bounds is None:
|
|
218
|
-
query_bounds = Bounds(first_order_val, first_order_val)
|
|
219
|
-
else:
|
|
220
|
-
query_bounds.update(first_order_val)
|
|
221
|
-
|
|
222
|
-
return Collections(
|
|
223
|
-
collections=records,
|
|
224
|
-
query_first_order_bounds=query_bounds,
|
|
194
|
+
SELECT *
|
|
195
|
+
FROM ({base_query}) s
|
|
196
|
+
ORDER BY {order2}
|
|
197
|
+
"""
|
|
198
|
+
).format(
|
|
199
|
+
order2=request.sort_by.as_sql(),
|
|
200
|
+
base_query=base_query,
|
|
225
201
|
)
|
|
226
202
|
|
|
203
|
+
records = cursor.execute(sqlSequences, seq_params).fetchall()
|
|
204
|
+
|
|
205
|
+
query_bounds = None
|
|
206
|
+
for s in records:
|
|
207
|
+
first_order_val = s.get(request.sort_by.fields[0].field.stac)
|
|
208
|
+
if first_order_val is None:
|
|
209
|
+
continue
|
|
210
|
+
if query_bounds is None:
|
|
211
|
+
query_bounds = Bounds(first_order_val, first_order_val)
|
|
212
|
+
else:
|
|
213
|
+
query_bounds.update(first_order_val)
|
|
214
|
+
|
|
215
|
+
return Collections(
|
|
216
|
+
collections=records,
|
|
217
|
+
query_first_order_bounds=query_bounds,
|
|
218
|
+
)
|
|
219
|
+
|
|
227
220
|
|
|
228
221
|
def get_pagination_links(
|
|
229
222
|
route: str,
|
|
@@ -381,17 +374,23 @@ def sort_collection(db, collectionId: UUID, sortby: CollectionSort):
|
|
|
381
374
|
|
|
382
375
|
if usedDateField is None:
|
|
383
376
|
raise errors.InvalidAPIUsage(
|
|
384
|
-
"Sort by file date is not possible on this sequence (no file date information available on pictures)",
|
|
377
|
+
_("Sort by file date is not possible on this sequence (no file date information available on pictures)"),
|
|
385
378
|
status_code=422,
|
|
386
379
|
)
|
|
387
380
|
|
|
388
381
|
for pm in picMetas:
|
|
389
382
|
# Find value for wanted sort
|
|
390
383
|
if sortby.order == CollectionSortOrder.GPS_DATE:
|
|
391
|
-
|
|
384
|
+
if "ts_gps" in pm["metadata"]:
|
|
385
|
+
pm["sort"] = pm["metadata"]["ts_gps"]
|
|
386
|
+
else:
|
|
387
|
+
pm["sort"] = reader.decodeGPSDateTime(pm["exif"], "Exif.GPSInfo", _)[0]
|
|
392
388
|
elif sortby.order == CollectionSortOrder.FILE_DATE:
|
|
393
|
-
|
|
394
|
-
|
|
389
|
+
if "ts_camera" in pm["metadata"]:
|
|
390
|
+
pm["sort"] = pm["metadata"]["ts_camera"]
|
|
391
|
+
else:
|
|
392
|
+
assert usedDateField # nullity has been checked before
|
|
393
|
+
pm["sort"] = reader.decodeDateTimeOriginal(pm["exif"], usedDateField, _)[0]
|
|
395
394
|
elif sortby.order == CollectionSortOrder.FILE_NAME:
|
|
396
395
|
pm["sort"] = pm["metadata"].get("originalFileName")
|
|
397
396
|
if isFileNameNumeric:
|
|
@@ -400,7 +399,11 @@ def sort_collection(db, collectionId: UUID, sortby: CollectionSort):
|
|
|
400
399
|
# Fail if sort value is missing
|
|
401
400
|
if pm["sort"] is None:
|
|
402
401
|
raise errors.InvalidAPIUsage(
|
|
403
|
-
|
|
402
|
+
_(
|
|
403
|
+
"Sort using %(sort)s is not possible on this sequence, picture %(pic)s is missing mandatory metadata",
|
|
404
|
+
sort=sortby,
|
|
405
|
+
pic=pm["id"],
|
|
406
|
+
),
|
|
404
407
|
status_code=422,
|
|
405
408
|
)
|
|
406
409
|
|
|
@@ -476,10 +479,68 @@ def update_headings(
|
|
|
476
479
|
)
|
|
477
480
|
|
|
478
481
|
|
|
479
|
-
def
|
|
480
|
-
"""
|
|
482
|
+
def add_finalization_job(cursor, seqId: UUID):
|
|
483
|
+
"""
|
|
484
|
+
Add a sequence finalization job in the queue.
|
|
485
|
+
If there is already a finalization job, do nothing (changing it might cause a deadlock, since a worker could be processing this job)
|
|
486
|
+
"""
|
|
487
|
+
cursor.execute(
|
|
488
|
+
"""INSERT INTO
|
|
489
|
+
job_queue(sequence_id, task)
|
|
490
|
+
VALUES (%(seq_id)s, 'finalize')
|
|
491
|
+
ON CONFLICT (sequence_id) DO NOTHING""",
|
|
492
|
+
{"seq_id": seqId},
|
|
493
|
+
)
|
|
494
|
+
|
|
481
495
|
|
|
482
|
-
|
|
496
|
+
def finalize(cursor, seqId: UUID, logger: logging.Logger = logging.getLogger()):
|
|
497
|
+
"""
|
|
498
|
+
Finalize a sequence, by updating its status and computed fields.
|
|
499
|
+
"""
|
|
500
|
+
with sentry_sdk.start_span(description="Finalizing sequence") as span:
|
|
501
|
+
span.set_data("sequence_id", seqId)
|
|
502
|
+
logger.debug(f"Finalizing sequence {seqId}")
|
|
503
|
+
|
|
504
|
+
with utils.time.log_elapsed(f"Finalizing sequence {seqId}"):
|
|
505
|
+
# Complete missing headings in pictures
|
|
506
|
+
update_headings(cursor, seqId)
|
|
507
|
+
|
|
508
|
+
# Change sequence database status in DB
|
|
509
|
+
# Also generates data in computed columns
|
|
510
|
+
cursor.execute(
|
|
511
|
+
"""WITH
|
|
512
|
+
aggregated_pictures AS (
|
|
513
|
+
SELECT
|
|
514
|
+
sp.seq_id,
|
|
515
|
+
MIN(p.ts::DATE) AS day,
|
|
516
|
+
ARRAY_AGG(DISTINCT TRIM(
|
|
517
|
+
CONCAT(p.metadata->>'make', ' ', p.metadata->>'model')
|
|
518
|
+
)) AS models,
|
|
519
|
+
ARRAY_AGG(DISTINCT p.metadata->>'type') AS types
|
|
520
|
+
FROM sequences_pictures sp
|
|
521
|
+
JOIN pictures p ON sp.pic_id = p.id
|
|
522
|
+
WHERE sp.seq_id = %(seq)s
|
|
523
|
+
GROUP BY sp.seq_id
|
|
524
|
+
)
|
|
525
|
+
UPDATE sequences
|
|
526
|
+
SET
|
|
527
|
+
status = CASE WHEN status = 'hidden' THEN 'hidden'::sequence_status ELSE 'ready'::sequence_status END, -- we don't want to change status if it's hidden
|
|
528
|
+
geom = compute_sequence_geom(id),
|
|
529
|
+
bbox = compute_sequence_bbox(id),
|
|
530
|
+
computed_type = CASE WHEN array_length(types, 1) = 1 THEN types[1] ELSE NULL END,
|
|
531
|
+
computed_model = CASE WHEN array_length(models, 1) = 1 THEN models[1] ELSE NULL END,
|
|
532
|
+
computed_capture_date = day
|
|
533
|
+
FROM aggregated_pictures
|
|
534
|
+
WHERE id = %(seq)s
|
|
535
|
+
""",
|
|
536
|
+
{"seq": seqId},
|
|
537
|
+
)
|
|
538
|
+
|
|
539
|
+
logger.info(f"Sequence {seqId} is ready")
|
|
540
|
+
|
|
541
|
+
|
|
542
|
+
def update_pictures_grid() -> Optional[datetime.datetime]:
|
|
543
|
+
"""Refreshes the pictures_grid materialized view for an up-to-date view of pictures availability on map.
|
|
483
544
|
|
|
484
545
|
Parameters
|
|
485
546
|
----------
|
|
@@ -490,17 +551,94 @@ def update_pictures_grid(db) -> bool:
|
|
|
490
551
|
-------
|
|
491
552
|
bool : True if the view has been updated else False
|
|
492
553
|
"""
|
|
554
|
+
from geovisio.utils import db
|
|
555
|
+
|
|
493
556
|
logger = logging.getLogger("geovisio.picture_grid")
|
|
494
|
-
|
|
557
|
+
|
|
558
|
+
# get a connection outside of the connection pool in order to avoid
|
|
559
|
+
# the default statement timeout as this query can be very long
|
|
560
|
+
with db.long_queries_conn(current_app) as conn, conn.transaction():
|
|
495
561
|
try:
|
|
496
|
-
|
|
562
|
+
conn.execute("SELECT refreshed_at FROM refresh_database FOR UPDATE NOWAIT").fetchone()
|
|
497
563
|
except psycopg.errors.LockNotAvailable:
|
|
498
564
|
logger.info("Database refresh already in progress, nothing to do")
|
|
499
565
|
return False
|
|
500
566
|
|
|
501
|
-
with sentry_sdk.start_span(description="Refreshing database")
|
|
502
|
-
with utils.time.log_elapsed(
|
|
567
|
+
with sentry_sdk.start_span(description="Refreshing database"):
|
|
568
|
+
with utils.time.log_elapsed("Refreshing database", logger=logger):
|
|
503
569
|
logger.info("Refreshing database")
|
|
504
|
-
|
|
505
|
-
|
|
570
|
+
conn.execute("UPDATE refresh_database SET refreshed_at = NOW()")
|
|
571
|
+
conn.execute("REFRESH MATERIALIZED VIEW CONCURRENTLY pictures_grid")
|
|
572
|
+
|
|
506
573
|
return True
|
|
574
|
+
|
|
575
|
+
|
|
576
|
+
def delete_collection(collectionId: UUID, account: Optional[Account]) -> int:
|
|
577
|
+
"""
|
|
578
|
+
Mark a collection as deleted and delete all it's pictures.
|
|
579
|
+
|
|
580
|
+
Note that since the deletion as asynchronous, some workers need to be run in order for the deletion to be effective.
|
|
581
|
+
"""
|
|
582
|
+
with db.conn(current_app) as conn:
|
|
583
|
+
with conn.transaction(), conn.cursor() as cursor:
|
|
584
|
+
sequence = cursor.execute(
|
|
585
|
+
"SELECT status, account_id FROM sequences WHERE id = %s AND status != 'deleted'", [collectionId]
|
|
586
|
+
).fetchone()
|
|
587
|
+
|
|
588
|
+
# sequence not found
|
|
589
|
+
if not sequence:
|
|
590
|
+
raise errors.InvalidAPIUsage(_("Collection %(c)s wasn't found in database", c=collectionId), status_code=404)
|
|
591
|
+
|
|
592
|
+
# Account associated to sequence doesn't match current user
|
|
593
|
+
if account is not None and account.id != str(sequence[1]):
|
|
594
|
+
raise errors.InvalidAPIUsage("You're not authorized to edit this sequence", status_code=403)
|
|
595
|
+
|
|
596
|
+
logging.info(f"Asking for deletion of sequence {collectionId} and all its pictures")
|
|
597
|
+
|
|
598
|
+
# mark all the pictures as waiting for deletion for async removal as this can be quite long if the storage is slow if there are lots of pictures
|
|
599
|
+
# Note: To avoid a deadlock if some workers are currently also working on those picture to prepare them,
|
|
600
|
+
# the SQL queries are split in 2:
|
|
601
|
+
# - First a query to add the async deletion task to the queue.
|
|
602
|
+
# - Then a query changing the status of the picture to `waiting-for-delete`
|
|
603
|
+
#
|
|
604
|
+
# The trick there is that there can only be one task for a given picture (either preparing or deleting it)
|
|
605
|
+
# And the first query do a `ON CONFLICT DO UPDATE` to change the remaining `prepare` task to `delete`.
|
|
606
|
+
# So at the end of this query, we know that there are no more workers working on those pictures, so we can change their status
|
|
607
|
+
# without fearing a deadlock.
|
|
608
|
+
nb_updated = cursor.execute(
|
|
609
|
+
"""
|
|
610
|
+
WITH pic2rm AS (
|
|
611
|
+
SELECT pic_id FROM sequences_pictures WHERE seq_id = %(seq)s
|
|
612
|
+
),
|
|
613
|
+
picWithoutOtherSeq AS (
|
|
614
|
+
SELECT pic_id FROM pic2rm
|
|
615
|
+
EXCEPT
|
|
616
|
+
SELECT pic_id FROM sequences_pictures WHERE pic_id IN (SELECT pic_id FROM pic2rm) AND seq_id != %(seq)s
|
|
617
|
+
)
|
|
618
|
+
INSERT INTO job_queue(picture_id, task)
|
|
619
|
+
SELECT pic_id, 'delete' FROM picWithoutOtherSeq
|
|
620
|
+
ON CONFLICT (picture_id) DO UPDATE SET task = 'delete'
|
|
621
|
+
""",
|
|
622
|
+
{"seq": collectionId},
|
|
623
|
+
).rowcount
|
|
624
|
+
# if there was a finalize task for this collection in the queue, we remove it, it's useless
|
|
625
|
+
cursor.execute("""DELETE FROM job_queue WHERE sequence_id = %(seq)s""", {"seq": collectionId})
|
|
626
|
+
|
|
627
|
+
# after the task have been added to the queue, we mark all picture for deletion
|
|
628
|
+
cursor.execute(
|
|
629
|
+
"""
|
|
630
|
+
WITH pic2rm AS (
|
|
631
|
+
SELECT pic_id FROM sequences_pictures WHERE seq_id = %(seq)s
|
|
632
|
+
),
|
|
633
|
+
picWithoutOtherSeq AS (
|
|
634
|
+
SELECT pic_id FROM pic2rm
|
|
635
|
+
EXCEPT
|
|
636
|
+
SELECT pic_id FROM sequences_pictures WHERE pic_id IN (SELECT pic_id FROM pic2rm) AND seq_id != %(seq)s
|
|
637
|
+
)
|
|
638
|
+
UPDATE pictures SET status = 'waiting-for-delete' WHERE id IN (SELECT pic_id FROM picWithoutOtherSeq)
|
|
639
|
+
""",
|
|
640
|
+
{"seq": collectionId},
|
|
641
|
+
).rowcount
|
|
642
|
+
|
|
643
|
+
cursor.execute("UPDATE sequences SET status = 'deleted' WHERE id = %s", [collectionId])
|
|
644
|
+
return nb_updated
|