geovisio 2.9.0__py3-none-any.whl → 2.11.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 +8 -1
- geovisio/admin_cli/user.py +7 -2
- geovisio/config_app.py +26 -12
- geovisio/translations/ar/LC_MESSAGES/messages.mo +0 -0
- geovisio/translations/ar/LC_MESSAGES/messages.po +818 -0
- geovisio/translations/be/LC_MESSAGES/messages.mo +0 -0
- geovisio/translations/be/LC_MESSAGES/messages.po +886 -0
- geovisio/translations/br/LC_MESSAGES/messages.po +1 -1
- geovisio/translations/da/LC_MESSAGES/messages.mo +0 -0
- geovisio/translations/da/LC_MESSAGES/messages.po +96 -4
- geovisio/translations/de/LC_MESSAGES/messages.mo +0 -0
- geovisio/translations/de/LC_MESSAGES/messages.po +214 -122
- geovisio/translations/el/LC_MESSAGES/messages.po +1 -1
- geovisio/translations/en/LC_MESSAGES/messages.mo +0 -0
- geovisio/translations/en/LC_MESSAGES/messages.po +234 -157
- geovisio/translations/eo/LC_MESSAGES/messages.mo +0 -0
- geovisio/translations/eo/LC_MESSAGES/messages.po +55 -5
- geovisio/translations/es/LC_MESSAGES/messages.po +1 -1
- geovisio/translations/fi/LC_MESSAGES/messages.po +1 -1
- geovisio/translations/fr/LC_MESSAGES/messages.mo +0 -0
- geovisio/translations/fr/LC_MESSAGES/messages.po +92 -3
- geovisio/translations/hu/LC_MESSAGES/messages.po +1 -1
- geovisio/translations/it/LC_MESSAGES/messages.mo +0 -0
- geovisio/translations/it/LC_MESSAGES/messages.po +63 -3
- geovisio/translations/ja/LC_MESSAGES/messages.po +1 -1
- geovisio/translations/ko/LC_MESSAGES/messages.po +1 -1
- geovisio/translations/messages.pot +216 -139
- geovisio/translations/nl/LC_MESSAGES/messages.mo +0 -0
- geovisio/translations/nl/LC_MESSAGES/messages.po +333 -62
- geovisio/translations/oc/LC_MESSAGES/messages.mo +0 -0
- geovisio/translations/oc/LC_MESSAGES/messages.po +821 -0
- geovisio/translations/pl/LC_MESSAGES/messages.po +1 -1
- geovisio/translations/pt/LC_MESSAGES/messages.mo +0 -0
- geovisio/translations/pt/LC_MESSAGES/messages.po +944 -0
- geovisio/translations/pt_BR/LC_MESSAGES/messages.mo +0 -0
- geovisio/translations/pt_BR/LC_MESSAGES/messages.po +942 -0
- geovisio/translations/sv/LC_MESSAGES/messages.mo +0 -0
- geovisio/translations/sv/LC_MESSAGES/messages.po +4 -3
- geovisio/translations/ti/LC_MESSAGES/messages.mo +0 -0
- geovisio/translations/ti/LC_MESSAGES/messages.po +762 -0
- geovisio/translations/tr/LC_MESSAGES/messages.mo +0 -0
- geovisio/translations/tr/LC_MESSAGES/messages.po +927 -0
- geovisio/translations/uk/LC_MESSAGES/messages.mo +0 -0
- geovisio/translations/uk/LC_MESSAGES/messages.po +920 -0
- geovisio/translations/zh_Hant/LC_MESSAGES/messages.po +1 -1
- geovisio/utils/annotations.py +21 -21
- geovisio/utils/auth.py +47 -13
- geovisio/utils/cql2.py +22 -5
- geovisio/utils/fields.py +14 -2
- geovisio/utils/items.py +44 -0
- geovisio/utils/model_query.py +2 -2
- geovisio/utils/pic_shape.py +1 -1
- geovisio/utils/pictures.py +127 -36
- geovisio/utils/semantics.py +32 -3
- geovisio/utils/sentry.py +1 -1
- geovisio/utils/sequences.py +155 -109
- geovisio/utils/upload_set.py +303 -206
- geovisio/utils/users.py +18 -0
- geovisio/utils/website.py +1 -1
- geovisio/web/annotations.py +303 -69
- geovisio/web/auth.py +1 -1
- geovisio/web/collections.py +194 -97
- geovisio/web/configuration.py +36 -4
- geovisio/web/docs.py +109 -13
- geovisio/web/items.py +319 -186
- geovisio/web/map.py +92 -54
- geovisio/web/pages.py +48 -4
- geovisio/web/params.py +100 -42
- geovisio/web/pictures.py +37 -3
- geovisio/web/prepare.py +4 -2
- geovisio/web/queryables.py +57 -0
- geovisio/web/stac.py +8 -2
- geovisio/web/tokens.py +49 -1
- geovisio/web/upload_set.py +226 -51
- geovisio/web/users.py +89 -8
- geovisio/web/utils.py +26 -8
- geovisio/workers/runner_pictures.py +128 -23
- {geovisio-2.9.0.dist-info → geovisio-2.11.0.dist-info}/METADATA +15 -14
- geovisio-2.11.0.dist-info/RECORD +117 -0
- geovisio-2.9.0.dist-info/RECORD +0 -98
- {geovisio-2.9.0.dist-info → geovisio-2.11.0.dist-info}/WHEEL +0 -0
- {geovisio-2.9.0.dist-info → geovisio-2.11.0.dist-info}/licenses/LICENSE +0 -0
geovisio/web/items.py
CHANGED
|
@@ -5,8 +5,7 @@ import os
|
|
|
5
5
|
from typing import Dict, List, Optional, Any
|
|
6
6
|
from urllib.parse import unquote
|
|
7
7
|
from psycopg.types.json import Jsonb
|
|
8
|
-
from pydantic import BaseModel,
|
|
9
|
-
from shapely import intersects
|
|
8
|
+
from pydantic import BaseModel, ValidationError, field_validator, model_validator
|
|
10
9
|
from werkzeug.datastructures import MultiDict
|
|
11
10
|
from uuid import UUID
|
|
12
11
|
from geovisio import errors, utils
|
|
@@ -15,7 +14,9 @@ from geovisio.utils.cql2 import parse_search_filter
|
|
|
15
14
|
from geovisio.utils.params import validation_error
|
|
16
15
|
from geovisio.utils.pictures import cleanupExif
|
|
17
16
|
from geovisio.utils.semantics import Entity, EntityType, update_tags
|
|
17
|
+
from geovisio.utils.items import SortableItemField, SortBy, ItemSortByField
|
|
18
18
|
from geovisio.utils.tags import SemanticTagUpdate
|
|
19
|
+
from geovisio.utils.auth import Account
|
|
19
20
|
from geovisio.web.params import (
|
|
20
21
|
as_latitude,
|
|
21
22
|
as_longitude,
|
|
@@ -23,17 +24,20 @@ from geovisio.web.params import (
|
|
|
23
24
|
parse_datetime,
|
|
24
25
|
parse_datetime_interval,
|
|
25
26
|
parse_bbox,
|
|
27
|
+
parse_item_sortby,
|
|
26
28
|
parse_list,
|
|
27
29
|
parse_lonlat,
|
|
28
30
|
parse_distance_range,
|
|
29
31
|
parse_picture_heading,
|
|
32
|
+
Visibility,
|
|
33
|
+
check_visibility,
|
|
30
34
|
)
|
|
31
|
-
from geovisio.utils.fields import Bounds
|
|
35
|
+
from geovisio.utils.fields import Bounds, SQLDirection
|
|
32
36
|
import hashlib
|
|
33
37
|
from psycopg.rows import dict_row
|
|
34
38
|
from psycopg.sql import SQL
|
|
35
39
|
from geovisio.web.utils import (
|
|
36
|
-
|
|
40
|
+
accountOrDefault,
|
|
37
41
|
cleanNoneInList,
|
|
38
42
|
dbTsToStac,
|
|
39
43
|
dbTsToStacTZ,
|
|
@@ -46,13 +50,19 @@ from flask import current_app, request, url_for, Blueprint
|
|
|
46
50
|
from flask_babel import gettext as _, get_locale
|
|
47
51
|
from geopic_tag_reader.writer import writePictureMetadata, PictureMetadata
|
|
48
52
|
import sentry_sdk
|
|
49
|
-
import math
|
|
50
53
|
|
|
51
54
|
|
|
52
55
|
bp = Blueprint("stac_items", __name__, url_prefix="/api")
|
|
53
56
|
|
|
54
57
|
|
|
55
|
-
def
|
|
58
|
+
def retrocompatible_picture_status(db_pic):
|
|
59
|
+
"""We used to display status='hidden' for hidden picture, now that the status and the visiblity has been split, we still return a 'ready' status for retrocompatibility"""
|
|
60
|
+
if db_pic.get("status") == "hidden":
|
|
61
|
+
return "ready"
|
|
62
|
+
return db_pic.get("status")
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def dbPictureToStacItem(dbPic):
|
|
56
66
|
"""Transforms a picture extracted from database into a STAC Item
|
|
57
67
|
|
|
58
68
|
Parameters
|
|
@@ -70,6 +80,7 @@ def dbPictureToStacItem(seqId, dbPic):
|
|
|
70
80
|
|
|
71
81
|
sensorDim = None
|
|
72
82
|
visibleArea = None
|
|
83
|
+
seqId = str(dbPic["seq_id"])
|
|
73
84
|
if dbPic["metadata"].get("crop") is not None:
|
|
74
85
|
sensorDim = [dbPic["metadata"]["crop"].get("fullWidth"), dbPic["metadata"]["crop"].get("fullHeight")]
|
|
75
86
|
visibleArea = [
|
|
@@ -115,7 +126,7 @@ def dbPictureToStacItem(seqId, dbPic):
|
|
|
115
126
|
"datetime": dbTsToStac(dbPic["ts"]),
|
|
116
127
|
"datetimetz": dbTsToStacTZ(dbPic["ts"], dbPic["metadata"].get("tz")),
|
|
117
128
|
"created": dbTsToStac(dbPic["inserted_at"]),
|
|
118
|
-
|
|
129
|
+
"updated": dbTsToStac(dbPic["updated_at"]),
|
|
119
130
|
"license": current_app.config["API_PICTURES_LICENSE_SPDX_ID"],
|
|
120
131
|
"view:azimuth": dbPic["heading"],
|
|
121
132
|
"pers:interior_orientation": (
|
|
@@ -139,18 +150,20 @@ def dbPictureToStacItem(seqId, dbPic):
|
|
|
139
150
|
),
|
|
140
151
|
"pers:pitch": dbPic["metadata"].get("pitch"),
|
|
141
152
|
"pers:roll": dbPic["metadata"].get("roll"),
|
|
142
|
-
"geovisio:status": dbPic
|
|
153
|
+
"geovisio:status": retrocompatible_picture_status(dbPic),
|
|
154
|
+
"geovisio:visibility": dbPic.get("visibility"),
|
|
143
155
|
"geovisio:producer": dbPic["account_name"],
|
|
144
156
|
"geovisio:rank_in_collection": dbPic["rank"],
|
|
145
157
|
"original_file:size": dbPic["metadata"].get("originalFileSize"),
|
|
146
158
|
"original_file:name": dbPic["metadata"].get("originalFileName"),
|
|
147
159
|
"panoramax:horizontal_pixel_density": dbPic.get("h_pixel_density"),
|
|
148
|
-
"geovisio:image": _getHDJpgPictureURL(dbPic["id"], dbPic.get("
|
|
149
|
-
"geovisio:thumbnail": _getThumbJpgPictureURL(dbPic["id"], dbPic.get("
|
|
160
|
+
"geovisio:image": _getHDJpgPictureURL(dbPic["id"], dbPic.get("visibility")),
|
|
161
|
+
"geovisio:thumbnail": _getThumbJpgPictureURL(dbPic["id"], dbPic.get("visibility")),
|
|
150
162
|
"exif": removeNoneInDict(cleanupExif(dbPic["exif"])),
|
|
151
163
|
"quality:horizontal_accuracy": float("{:.1f}".format(dbPic["gps_accuracy_m"])) if dbPic.get("gps_accuracy_m") else None,
|
|
152
164
|
"semantics": [s for s in dbPic.get("semantics") or [] if s],
|
|
153
165
|
"annotations": [a for a in dbPic.get("annotations") or [] if a],
|
|
166
|
+
"collection": {"semantics": dbPic["sequence_semantics"]} if "sequence_semantics" in dbPic else None,
|
|
154
167
|
}
|
|
155
168
|
),
|
|
156
169
|
"links": cleanNoneInList(
|
|
@@ -180,21 +193,21 @@ def dbPictureToStacItem(seqId, dbPic):
|
|
|
180
193
|
"description": "Highest resolution available of this picture",
|
|
181
194
|
"roles": ["data"],
|
|
182
195
|
"type": "image/jpeg",
|
|
183
|
-
"href": _getHDJpgPictureURL(dbPic["id"],
|
|
196
|
+
"href": _getHDJpgPictureURL(dbPic["id"], visibility=dbPic.get("visibility")),
|
|
184
197
|
},
|
|
185
198
|
"sd": {
|
|
186
199
|
"title": "SD picture",
|
|
187
200
|
"description": "Picture in standard definition (fixed width of 2048px)",
|
|
188
201
|
"roles": ["visual"],
|
|
189
202
|
"type": "image/jpeg",
|
|
190
|
-
"href": _getSDJpgPictureURL(dbPic["id"],
|
|
203
|
+
"href": _getSDJpgPictureURL(dbPic["id"], visibility=dbPic.get("visibility")),
|
|
191
204
|
},
|
|
192
205
|
"thumb": {
|
|
193
206
|
"title": "Thumbnail",
|
|
194
207
|
"description": "Picture in low definition (fixed width of 500px)",
|
|
195
208
|
"roles": ["thumbnail"],
|
|
196
209
|
"type": "image/jpeg",
|
|
197
|
-
"href": _getThumbJpgPictureURL(dbPic["id"],
|
|
210
|
+
"href": _getThumbJpgPictureURL(dbPic["id"], visibility=dbPic.get("visibility")),
|
|
198
211
|
},
|
|
199
212
|
},
|
|
200
213
|
"collection": str(seqId),
|
|
@@ -272,7 +285,7 @@ def dbPictureToStacItem(seqId, dbPic):
|
|
|
272
285
|
"description": "Highest resolution available of this picture, as tiles",
|
|
273
286
|
"roles": ["data"],
|
|
274
287
|
"type": "image/jpeg",
|
|
275
|
-
"href": _getTilesJpgPictureURL(dbPic["id"],
|
|
288
|
+
"href": _getTilesJpgPictureURL(dbPic["id"], visibility=dbPic.get("visibility")),
|
|
276
289
|
}
|
|
277
290
|
}
|
|
278
291
|
|
|
@@ -361,9 +374,10 @@ def getCollectionItems(collectionId):
|
|
|
361
374
|
|
|
362
375
|
filters = [
|
|
363
376
|
SQL("sp.seq_id = %(seq)s"),
|
|
364
|
-
SQL("(p.
|
|
365
|
-
SQL("(is_sequence_visible_by_user(s, %(account)s))"),
|
|
377
|
+
SQL("(p.preparing_status = 'prepared' OR p.account_id = %(account)s)"),
|
|
366
378
|
]
|
|
379
|
+
if account is None or not account.can_see_all():
|
|
380
|
+
filters.append(SQL("(is_picture_visible_by_user(p, %(account)s))"))
|
|
367
381
|
|
|
368
382
|
# Check if limit is valid
|
|
369
383
|
sql_limit = SQL("")
|
|
@@ -402,7 +416,7 @@ def getCollectionItems(collectionId):
|
|
|
402
416
|
+ (", MAX(sp.rank) AS max_rank, MIN(sp.rank) AS min_rank " if paginated else "")
|
|
403
417
|
+ "FROM sequences s "
|
|
404
418
|
+ ("LEFT JOIN sequences_pictures sp ON sp.seq_id = s.id " if paginated else "")
|
|
405
|
-
+ "WHERE s.id = %(seq)s AND (is_sequence_visible_by_user(s, %(account)s)
|
|
419
|
+
+ "WHERE s.id = %(seq)s AND (s.status = 'ready' OR s.account_id = %(account)s) AND is_sequence_visible_by_user(s, %(account)s) AND s.status != 'deleted'"
|
|
406
420
|
+ ("GROUP BY s.id" if paginated else ""),
|
|
407
421
|
params,
|
|
408
422
|
).fetchone()
|
|
@@ -424,36 +438,43 @@ def getCollectionItems(collectionId):
|
|
|
424
438
|
params={"id": withPicture, "seq": collectionId},
|
|
425
439
|
).fetchone()
|
|
426
440
|
if not pic:
|
|
427
|
-
raise errors.InvalidAPIUsage(_("Picture with id %(p)s does not
|
|
441
|
+
raise errors.InvalidAPIUsage(_("Picture with id %(p)s does not exist", p=withPicture))
|
|
428
442
|
rank = get_first_rank_of_page(pic["rank"], limit)
|
|
429
443
|
|
|
430
444
|
filters.append(SQL("rank >= %(start_after_rank)s"))
|
|
431
445
|
params["start_after_rank"] = rank
|
|
432
446
|
|
|
433
447
|
query = SQL(
|
|
434
|
-
"""
|
|
435
|
-
|
|
436
|
-
p.id, p.ts, p.heading, p.metadata, p.inserted_at, p.status,
|
|
448
|
+
"""SELECT
|
|
449
|
+
p.id, p.ts, p.heading, p.metadata, p.inserted_at, p.updated_at, p.status, p.visibility,
|
|
437
450
|
ST_AsGeoJSON(p.geom)::json AS geojson,
|
|
438
451
|
a.name AS account_name,
|
|
439
452
|
p.account_id AS account_id,
|
|
440
|
-
sp.rank, p.exif, p.gps_accuracy_m, p.h_pixel_density,
|
|
441
|
-
CASE WHEN LAG(p
|
|
442
|
-
CASE WHEN LAG(p
|
|
443
|
-
CASE WHEN LEAD(p
|
|
444
|
-
CASE WHEN LEAD(p
|
|
453
|
+
sp.seq_id, sp.rank, p.exif, p.gps_accuracy_m, p.h_pixel_density,
|
|
454
|
+
CASE WHEN LAG(is_picture_visible_by_user(p, %(account)s)) OVER othpics THEN LAG(p.id) OVER othpics END AS prevpic,
|
|
455
|
+
CASE WHEN LAG(is_picture_visible_by_user(p, %(account)s)) OVER othpics THEN ST_AsGeoJSON(LAG(p.geom) OVER othpics)::json END AS prevpicgeojson,
|
|
456
|
+
CASE WHEN LEAD(is_picture_visible_by_user(p, %(account)s)) OVER othpics THEN LEAD(p.id) OVER othpics END AS nextpic,
|
|
457
|
+
CASE WHEN LEAD(is_picture_visible_by_user(p, %(account)s)) OVER othpics THEN ST_AsGeoJSON(LEAD(p.geom) OVER othpics)::json END AS nextpicgeojson,
|
|
445
458
|
get_picture_semantics(p.id) as semantics,
|
|
446
|
-
get_picture_annotations(p.id) as annotations
|
|
459
|
+
get_picture_annotations(p.id) as annotations,
|
|
460
|
+
COALESCE(seq_sem.semantics, '[]'::json) AS sequence_semantics
|
|
447
461
|
FROM sequences_pictures sp
|
|
448
462
|
JOIN pictures p ON sp.pic_id = p.id
|
|
449
463
|
JOIN accounts a ON a.id = p.account_id
|
|
450
464
|
JOIN sequences s ON s.id = sp.seq_id
|
|
465
|
+
LEFT JOIN (
|
|
466
|
+
SELECT sequence_id, json_agg(json_strip_nulls(json_build_object(
|
|
467
|
+
'key', key,
|
|
468
|
+
'value', value
|
|
469
|
+
)) ORDER BY key, value) AS semantics
|
|
470
|
+
FROM sequences_semantics
|
|
471
|
+
GROUP BY sequence_id
|
|
472
|
+
) seq_sem ON seq_sem.sequence_id = s.id
|
|
451
473
|
WHERE
|
|
452
474
|
{filter}
|
|
453
475
|
WINDOW othpics AS (PARTITION BY sp.seq_id ORDER BY sp.rank)
|
|
454
476
|
ORDER BY rank
|
|
455
|
-
{limit}
|
|
456
|
-
"""
|
|
477
|
+
{limit}"""
|
|
457
478
|
).format(filter=SQL(" AND ").join(filters), limit=sql_limit)
|
|
458
479
|
|
|
459
480
|
records = cursor.execute(query, params)
|
|
@@ -464,7 +485,7 @@ def getCollectionItems(collectionId):
|
|
|
464
485
|
if first_rank is None:
|
|
465
486
|
first_rank = dbPic["rank"]
|
|
466
487
|
last_rank = dbPic["rank"]
|
|
467
|
-
items.append(dbPictureToStacItem(
|
|
488
|
+
items.append(dbPictureToStacItem(dbPic))
|
|
468
489
|
bounds = Bounds(first=first_rank, last=last_rank) if records else None
|
|
469
490
|
|
|
470
491
|
links = [
|
|
@@ -570,44 +591,39 @@ def getCollectionItems(collectionId):
|
|
|
570
591
|
)
|
|
571
592
|
|
|
572
593
|
|
|
573
|
-
def _getPictureItemById(
|
|
574
|
-
"""Get a picture metadata by its ID
|
|
575
|
-
|
|
576
|
-
---
|
|
577
|
-
tags:
|
|
578
|
-
- Pictures
|
|
579
|
-
parameters:
|
|
580
|
-
- name: collectionId
|
|
581
|
-
in: path
|
|
582
|
-
description: ID of collection to retrieve
|
|
583
|
-
required: true
|
|
584
|
-
schema:
|
|
585
|
-
type: string
|
|
586
|
-
- name: itemId
|
|
587
|
-
in: path
|
|
588
|
-
description: ID of item to retrieve
|
|
589
|
-
required: true
|
|
590
|
-
schema:
|
|
591
|
-
type: string
|
|
592
|
-
"""
|
|
594
|
+
def _getPictureItemById(itemId: UUID, account: Optional[Account]):
|
|
595
|
+
"""Get a picture metadata by its ID"""
|
|
593
596
|
with current_app.pool.connection() as conn:
|
|
597
|
+
perm_filter = SQL("")
|
|
598
|
+
if account is not None and account.can_see_all():
|
|
599
|
+
# admins can see all pictures, regardless of their visibility
|
|
600
|
+
perm_filter = SQL("TRUE")
|
|
601
|
+
else:
|
|
602
|
+
perm_filter = SQL(
|
|
603
|
+
"""(p.account_id = %(acc)s OR p.status != 'hidden') -- for retrocompabitilty, we can drop this filter once database have migrated all hidden pictures
|
|
604
|
+
AND (is_picture_visible_by_user(p, %(acc)s))
|
|
605
|
+
AND (s.status != 'hidden' OR s.account_id = %(acc)s) -- same, we can drop this later (and replace it with `s.status = 'ready'`)
|
|
606
|
+
AND is_sequence_visible_by_user(s, %(acc)s)"""
|
|
607
|
+
)
|
|
608
|
+
|
|
594
609
|
with conn.cursor(row_factory=dict_row) as cursor:
|
|
595
|
-
# Check if there is a logged user
|
|
596
|
-
account = auth.get_current_account()
|
|
597
|
-
accountId = account.id if account else None
|
|
598
610
|
|
|
599
611
|
# Get rank + position of wanted picture
|
|
600
612
|
record = cursor.execute(
|
|
601
|
-
|
|
613
|
+
SQL(
|
|
614
|
+
"""WITH seq AS (
|
|
615
|
+
SELECT seq_id FROM sequences_pictures WHERE pic_id = %(pic)s LIMIT 1
|
|
616
|
+
)
|
|
602
617
|
SELECT
|
|
603
|
-
p.id, sp.rank, ST_AsGeoJSON(p.geom)::json AS geojson, p.heading, p.ts, p.metadata,
|
|
604
|
-
p.inserted_at, p.
|
|
605
|
-
p.account_id AS account_id,
|
|
618
|
+
p.id, sp.seq_id, sp.rank, ST_AsGeoJSON(p.geom)::json AS geojson, p.heading, p.ts, p.metadata,
|
|
619
|
+
p.inserted_at, p.updated_at, p.status,
|
|
620
|
+
accounts.name AS account_name, p.account_id AS account_id,
|
|
621
|
+
p.visibility,
|
|
606
622
|
spl.prevpic, spl.prevpicgeojson, spl.nextpic, spl.nextpicgeojson, p.exif,
|
|
607
623
|
relp.related_pics, p.gps_accuracy_m, p.h_pixel_density,
|
|
608
|
-
|
|
609
624
|
get_picture_semantics(p.id) as semantics,
|
|
610
|
-
get_picture_annotations(p.id) as annotations
|
|
625
|
+
get_picture_annotations(p.id) as annotations,
|
|
626
|
+
COALESCE(seq_sem.semantics, '[]'::json) AS sequence_semantics
|
|
611
627
|
FROM pictures p
|
|
612
628
|
JOIN sequences_pictures sp ON sp.pic_id = p.id
|
|
613
629
|
JOIN accounts ON p.account_id = accounts.id
|
|
@@ -622,8 +638,8 @@ def _getPictureItemById(collectionId, itemId):
|
|
|
622
638
|
FROM pictures p
|
|
623
639
|
JOIN sequences_pictures sp ON p.id = sp.pic_id
|
|
624
640
|
WHERE
|
|
625
|
-
sp.seq_id
|
|
626
|
-
AND (p
|
|
641
|
+
sp.seq_id IN (SELECT seq_id FROM seq)
|
|
642
|
+
AND (is_picture_visible_by_user(p, %(acc)s) AND p.preparing_status = 'prepared')
|
|
627
643
|
WINDOW othpics AS (PARTITION BY sp.seq_id ORDER BY sp.rank)
|
|
628
644
|
) spl ON p.id = spl.id
|
|
629
645
|
LEFT JOIN (
|
|
@@ -650,7 +666,7 @@ def _getPictureItemById(collectionId, itemId):
|
|
|
650
666
|
AND relp.status != 'waiting-for-delete'
|
|
651
667
|
AND relp.id != p.id
|
|
652
668
|
AND relsp.pic_id = relp.id
|
|
653
|
-
AND relsp.seq_id
|
|
669
|
+
AND relsp.seq_id NOT IN (SELECT seq_id FROM seq)
|
|
654
670
|
AND (
|
|
655
671
|
p.metadata->>'type' = 'equirectangular'
|
|
656
672
|
OR (relp.heading IS NULL OR p.heading IS NULL)
|
|
@@ -663,19 +679,28 @@ def _getPictureItemById(collectionId, itemId):
|
|
|
663
679
|
ORDER BY relsp.seq_id, p.geom <-> relp.geom
|
|
664
680
|
) a
|
|
665
681
|
) relp ON TRUE
|
|
666
|
-
|
|
682
|
+
LEFT JOIN (
|
|
683
|
+
SELECT sequence_id, json_agg(json_strip_nulls(json_build_object(
|
|
684
|
+
'key', key,
|
|
685
|
+
'value', value
|
|
686
|
+
)) ORDER BY key, value) AS semantics
|
|
687
|
+
FROM sequences_semantics
|
|
688
|
+
GROUP BY sequence_id
|
|
689
|
+
) seq_sem ON seq_sem.sequence_id = s.id
|
|
690
|
+
WHERE sp.seq_id IN (SELECT seq_id FROM seq)
|
|
667
691
|
AND p.id = %(pic)s
|
|
668
|
-
AND (p.account_id = %(acc)s OR p.
|
|
669
|
-
AND
|
|
692
|
+
-- TODO Should we show non prepared items to all ? AND (p.account_id = %(acc)s OR p.preparing_status = 'prepared')
|
|
693
|
+
AND {perm_filter}
|
|
670
694
|
AND s.status != 'deleted'
|
|
671
|
-
"""
|
|
672
|
-
|
|
695
|
+
"""
|
|
696
|
+
).format(perm_filter=perm_filter),
|
|
697
|
+
{"pic": itemId, "acc": account.id if account is not None else None},
|
|
673
698
|
).fetchone()
|
|
674
699
|
|
|
675
700
|
if record is None:
|
|
676
701
|
return None
|
|
677
702
|
|
|
678
|
-
return dbPictureToStacItem(
|
|
703
|
+
return dbPictureToStacItem(record)
|
|
679
704
|
|
|
680
705
|
|
|
681
706
|
@bp.route("/collections/<uuid:collectionId>/items/<uuid:itemId>")
|
|
@@ -711,12 +736,12 @@ def getCollectionItem(collectionId, itemId):
|
|
|
711
736
|
schema:
|
|
712
737
|
$ref: '#/components/schemas/GeoVisioItem'
|
|
713
738
|
"""
|
|
739
|
+
account = auth.get_current_account()
|
|
714
740
|
|
|
715
|
-
stacItem = _getPictureItemById(
|
|
741
|
+
stacItem = _getPictureItemById(itemId, account)
|
|
716
742
|
if stacItem is None:
|
|
717
743
|
raise errors.InvalidAPIUsage(_("Item doesn't exist"), status_code=404)
|
|
718
744
|
|
|
719
|
-
account = auth.get_current_account()
|
|
720
745
|
picStatusToHttpCode = {
|
|
721
746
|
"waiting-for-process": 102,
|
|
722
747
|
"ready": 200,
|
|
@@ -726,18 +751,6 @@ def getCollectionItem(collectionId, itemId):
|
|
|
726
751
|
return stacItem, picStatusToHttpCode[stacItem["properties"]["geovisio:status"]], {"Content-Type": "application/geo+json"}
|
|
727
752
|
|
|
728
753
|
|
|
729
|
-
class SearchParams(BaseModel):
|
|
730
|
-
bbox: Optional[str] = None
|
|
731
|
-
limit: int = 10
|
|
732
|
-
datetime: Optional[str] = None
|
|
733
|
-
place_position: Optional[str] = None
|
|
734
|
-
place_distance: Optional[str] = None
|
|
735
|
-
place_fov_tolerance: Optional[int] = None
|
|
736
|
-
intersects: Optional[str] = None
|
|
737
|
-
ids: Optional[str] = None
|
|
738
|
-
collections: Optional[str] = None
|
|
739
|
-
|
|
740
|
-
|
|
741
754
|
@bp.route("/search", methods=["GET", "POST"])
|
|
742
755
|
def searchItems():
|
|
743
756
|
"""Search through all available items
|
|
@@ -759,6 +772,7 @@ def searchItems():
|
|
|
759
772
|
- $ref: '#/components/parameters/GeoVisio_place_distance'
|
|
760
773
|
- $ref: '#/components/parameters/GeoVisio_place_fov_tolerance'
|
|
761
774
|
- $ref: '#/components/parameters/searchCQL2_filter'
|
|
775
|
+
- $ref: '#/components/parameters/GeoVisioSearchSortedBy'
|
|
762
776
|
post:
|
|
763
777
|
requestBody:
|
|
764
778
|
required: true
|
|
@@ -768,15 +782,21 @@ def searchItems():
|
|
|
768
782
|
$ref: '#/components/schemas/GeoVisioItemSearchBody'
|
|
769
783
|
responses:
|
|
770
784
|
200:
|
|
771
|
-
|
|
785
|
+
description: The search results
|
|
786
|
+
content:
|
|
787
|
+
application/geo+json:
|
|
788
|
+
schema:
|
|
789
|
+
$ref: '#/components/schemas/GeoVisioCollectionItems'
|
|
772
790
|
"""
|
|
773
791
|
|
|
774
792
|
account = auth.get_current_account()
|
|
775
793
|
accountId = account.id if account is not None else None
|
|
776
|
-
sqlWhere = [
|
|
794
|
+
sqlWhere = [
|
|
795
|
+
SQL("(p.status = 'ready' AND is_picture_visible_by_user(p, %(account)s))"),
|
|
796
|
+
SQL("(s.status = 'ready' AND is_sequence_visible_by_user(s, %(account)s))"),
|
|
797
|
+
]
|
|
777
798
|
sqlParams: Dict[str, Any] = {"account": accountId}
|
|
778
|
-
sqlSubQueryWhere = [SQL("(p.status = 'ready'
|
|
779
|
-
order_by = SQL("")
|
|
799
|
+
sqlSubQueryWhere = [SQL("(p.status = 'ready' AND is_picture_visible_by_user(p, %(account)s))")]
|
|
780
800
|
|
|
781
801
|
#
|
|
782
802
|
# Parameters parsing and verification
|
|
@@ -801,6 +821,8 @@ def searchItems():
|
|
|
801
821
|
raise errors.InvalidAPIUsage(_("Parameter limit must be either empty or a number between 1 and 10000"), status_code=400)
|
|
802
822
|
sqlParams["limit"] = limit
|
|
803
823
|
|
|
824
|
+
sort_by = parse_item_sortby(args.get("sortby"))
|
|
825
|
+
|
|
804
826
|
# Bounding box
|
|
805
827
|
bboxarg = parse_bbox(args.getlist("bbox"))
|
|
806
828
|
if bboxarg is not None:
|
|
@@ -810,7 +832,16 @@ def searchItems():
|
|
|
810
832
|
sqlParams["maxx"] = bboxarg[2]
|
|
811
833
|
sqlParams["maxy"] = bboxarg[3]
|
|
812
834
|
# if we search by bbox, we'll give first the items near the center of the bounding box
|
|
813
|
-
|
|
835
|
+
if not sort_by:
|
|
836
|
+
sort_by = SortBy(
|
|
837
|
+
fields=[
|
|
838
|
+
ItemSortByField(
|
|
839
|
+
field=SortableItemField.distance_to,
|
|
840
|
+
direction=SQLDirection.ASC,
|
|
841
|
+
obj_to_compare=SQL("ST_Centroid(ST_MakeEnvelope(%(minx)s, %(miny)s, %(maxx)s, %(maxy)s, 4326))"),
|
|
842
|
+
),
|
|
843
|
+
]
|
|
844
|
+
)
|
|
814
845
|
|
|
815
846
|
# Datetime
|
|
816
847
|
min_dt, max_dt = parse_datetime_interval(args.get("datetime"))
|
|
@@ -866,7 +897,16 @@ def searchItems():
|
|
|
866
897
|
)
|
|
867
898
|
|
|
868
899
|
# Sort pictures by nearest to POI
|
|
869
|
-
|
|
900
|
+
if not sort_by:
|
|
901
|
+
sort_by = SortBy(
|
|
902
|
+
fields=[
|
|
903
|
+
ItemSortByField(
|
|
904
|
+
field=SortableItemField.distance_to,
|
|
905
|
+
direction=SQLDirection.ASC,
|
|
906
|
+
obj_to_compare=SQL("ST_Point(%(placex)s, %(placey)s, 4326)"),
|
|
907
|
+
),
|
|
908
|
+
]
|
|
909
|
+
)
|
|
870
910
|
|
|
871
911
|
# Intersects
|
|
872
912
|
if args.get("intersects") is not None:
|
|
@@ -881,7 +921,16 @@ def searchItems():
|
|
|
881
921
|
sqlWhere.append(SQL("ST_Intersects(p.geom, ST_GeomFromGeoJSON(%(geom)s))"))
|
|
882
922
|
sqlParams["geom"] = Jsonb(intersects)
|
|
883
923
|
# if we search by bbox, we'll give first the items near the center of the bounding box
|
|
884
|
-
|
|
924
|
+
if not sort_by:
|
|
925
|
+
sort_by = SortBy(
|
|
926
|
+
fields=[
|
|
927
|
+
ItemSortByField(
|
|
928
|
+
field=SortableItemField.distance_to,
|
|
929
|
+
direction=SQLDirection.ASC,
|
|
930
|
+
obj_to_compare=SQL("ST_Centroid(ST_GeomFromGeoJSON(%(geom)s))"),
|
|
931
|
+
),
|
|
932
|
+
]
|
|
933
|
+
)
|
|
885
934
|
|
|
886
935
|
# Ids
|
|
887
936
|
if args.get("ids") is not None:
|
|
@@ -910,11 +959,7 @@ def searchItems():
|
|
|
910
959
|
picture_id = ids[0]
|
|
911
960
|
|
|
912
961
|
with current_app.pool.connection() as conn, conn.cursor() as cursor:
|
|
913
|
-
|
|
914
|
-
if not seq:
|
|
915
|
-
raise errors.InvalidAPIUsage(_("Picture doesn't exist"), status_code=404)
|
|
916
|
-
|
|
917
|
-
item = _getPictureItemById(seq[0], UUID(picture_id))
|
|
962
|
+
item = _getPictureItemById(UUID(picture_id), account)
|
|
918
963
|
features = [item] if item else []
|
|
919
964
|
return (
|
|
920
965
|
{"type": "FeatureCollection", "features": features, "links": [get_root_link()]},
|
|
@@ -926,6 +971,18 @@ def searchItems():
|
|
|
926
971
|
cql_filter = parse_search_filter(filter_param)
|
|
927
972
|
if cql_filter is not None:
|
|
928
973
|
sqlWhere.append(cql_filter)
|
|
974
|
+
|
|
975
|
+
if not sort_by:
|
|
976
|
+
# by default we sort by last updated (and id in case of equalities)
|
|
977
|
+
sort_by = SortBy(
|
|
978
|
+
fields=[
|
|
979
|
+
ItemSortByField(field=SortableItemField.updated, direction=SQLDirection.DESC),
|
|
980
|
+
ItemSortByField(field=SortableItemField.id, direction=SQLDirection.ASC),
|
|
981
|
+
]
|
|
982
|
+
)
|
|
983
|
+
|
|
984
|
+
order_by = sort_by.to_sql()
|
|
985
|
+
|
|
929
986
|
#
|
|
930
987
|
# Database query
|
|
931
988
|
#
|
|
@@ -934,18 +991,27 @@ def searchItems():
|
|
|
934
991
|
"""
|
|
935
992
|
SELECT * FROM (
|
|
936
993
|
SELECT
|
|
937
|
-
p.id, p.ts, p.heading, p.metadata, p.inserted_at,
|
|
994
|
+
p.id, p.ts, p.heading, p.metadata, p.inserted_at, p.updated_at,
|
|
938
995
|
ST_AsGeoJSON(p.geom)::json AS geojson,
|
|
939
996
|
sp.seq_id, sp.rank AS rank,
|
|
940
997
|
accounts.name AS account_name,
|
|
941
998
|
p.account_id AS account_id,
|
|
942
999
|
p.exif, p.gps_accuracy_m, p.h_pixel_density,
|
|
943
1000
|
get_picture_semantics(p.id) as semantics,
|
|
944
|
-
get_picture_annotations(p.id) as annotations
|
|
1001
|
+
get_picture_annotations(p.id) as annotations,
|
|
1002
|
+
COALESCE(seq_sem.semantics, '[]'::json) AS sequence_semantics
|
|
945
1003
|
FROM pictures p
|
|
946
1004
|
LEFT JOIN sequences_pictures sp ON p.id = sp.pic_id
|
|
947
1005
|
LEFT JOIN sequences s ON s.id = sp.seq_id
|
|
948
1006
|
LEFT JOIN accounts ON p.account_id = accounts.id
|
|
1007
|
+
LEFT JOIN (
|
|
1008
|
+
SELECT sequence_id, json_agg(json_strip_nulls(json_build_object(
|
|
1009
|
+
'key', key,
|
|
1010
|
+
'value', value
|
|
1011
|
+
)) ORDER BY key, value) AS semantics
|
|
1012
|
+
FROM sequences_semantics
|
|
1013
|
+
GROUP BY sequence_id
|
|
1014
|
+
) seq_sem ON seq_sem.sequence_id = s.id
|
|
949
1015
|
WHERE {sqlWhere}
|
|
950
1016
|
{orderBy}
|
|
951
1017
|
LIMIT %(limit)s
|
|
@@ -968,13 +1034,14 @@ LEFT JOIN LATERAL (
|
|
|
968
1034
|
ORDER BY sp.rank ASC
|
|
969
1035
|
LIMIT 1
|
|
970
1036
|
) next on true
|
|
1037
|
+
|
|
971
1038
|
;
|
|
972
1039
|
"""
|
|
973
1040
|
).format(sqlWhere=SQL(" AND ").join(sqlWhere), sqlSubQueryWhere=SQL(" AND ").join(sqlSubQueryWhere), orderBy=order_by)
|
|
974
1041
|
|
|
975
1042
|
records = cursor.execute(query, sqlParams)
|
|
976
1043
|
|
|
977
|
-
items = [dbPictureToStacItem(
|
|
1044
|
+
items = [dbPictureToStacItem(dbPic) for dbPic in records]
|
|
978
1045
|
|
|
979
1046
|
return (
|
|
980
1047
|
{
|
|
@@ -992,7 +1059,9 @@ LEFT JOIN LATERAL (
|
|
|
992
1059
|
@bp.route("/collections/<uuid:collectionId>/items", methods=["POST"])
|
|
993
1060
|
@auth.login_required_by_setting("API_FORCE_AUTH_ON_UPLOAD")
|
|
994
1061
|
def postCollectionItem(collectionId, account=None):
|
|
995
|
-
"""Add a new picture in a given sequence
|
|
1062
|
+
"""Add a new picture in a given sequence.
|
|
1063
|
+
|
|
1064
|
+
Note that this is the legacy API, upload should be done using the [UploadSet](#UploadSet) endpoints if possible.
|
|
996
1065
|
---
|
|
997
1066
|
tags:
|
|
998
1067
|
- Upload
|
|
@@ -1018,6 +1087,42 @@ def postCollectionItem(collectionId, account=None):
|
|
|
1018
1087
|
application/geo+json:
|
|
1019
1088
|
schema:
|
|
1020
1089
|
$ref: '#/components/schemas/GeoVisioItem'
|
|
1090
|
+
400:
|
|
1091
|
+
description: Error if the request is malformed
|
|
1092
|
+
content:
|
|
1093
|
+
application/json:
|
|
1094
|
+
schema:
|
|
1095
|
+
$ref: '#/components/schemas/GeoVisioError'
|
|
1096
|
+
401:
|
|
1097
|
+
description: Error if you're not logged in
|
|
1098
|
+
content:
|
|
1099
|
+
application/json:
|
|
1100
|
+
schema:
|
|
1101
|
+
$ref: '#/components/schemas/GeoVisioError'
|
|
1102
|
+
403:
|
|
1103
|
+
description: Error if you're not authorized to add picture to this collection
|
|
1104
|
+
content:
|
|
1105
|
+
application/json:
|
|
1106
|
+
schema:
|
|
1107
|
+
$ref: '#/components/schemas/GeoVisioError'
|
|
1108
|
+
404:
|
|
1109
|
+
description: Error if the collection doesn't exist
|
|
1110
|
+
content:
|
|
1111
|
+
application/json:
|
|
1112
|
+
schema:
|
|
1113
|
+
$ref: '#/components/schemas/GeoVisioError'
|
|
1114
|
+
409:
|
|
1115
|
+
description: Error if a picture (named `item` in the API) has already been added in the same index (named `position` in the API) in this collection
|
|
1116
|
+
content:
|
|
1117
|
+
application/json:
|
|
1118
|
+
schema:
|
|
1119
|
+
$ref: '#/components/schemas/GeoVisioError'
|
|
1120
|
+
415:
|
|
1121
|
+
description: Error if the content type is not multipart/form-data
|
|
1122
|
+
content:
|
|
1123
|
+
application/json:
|
|
1124
|
+
schema:
|
|
1125
|
+
$ref: '#/components/schemas/GeoVisioError'
|
|
1021
1126
|
"""
|
|
1022
1127
|
|
|
1023
1128
|
if not request.headers.get("Content-Type", "").startswith("multipart/form-data"):
|
|
@@ -1098,7 +1203,7 @@ def postCollectionItem(collectionId, account=None):
|
|
|
1098
1203
|
raise errors.InvalidAPIUsage(_("The collection has been deleted, impossible to add pictures to it"), status_code=404)
|
|
1099
1204
|
|
|
1100
1205
|
# Compute various metadata
|
|
1101
|
-
accountId =
|
|
1206
|
+
accountId = accountOrDefault(account).id
|
|
1102
1207
|
raw_pic = picture.read()
|
|
1103
1208
|
filesize = len(raw_pic)
|
|
1104
1209
|
|
|
@@ -1125,7 +1230,7 @@ def postCollectionItem(collectionId, account=None):
|
|
|
1125
1230
|
conn, collectionId, position, updated_picture, accountId, additionalMetadata, lang=get_locale().language
|
|
1126
1231
|
)
|
|
1127
1232
|
except utils.pictures.PicturePositionConflict:
|
|
1128
|
-
raise errors.InvalidAPIUsage(_("
|
|
1233
|
+
raise errors.InvalidAPIUsage(_("There is already a picture with the same index in the sequence"), status_code=409)
|
|
1129
1234
|
except utils.pictures.MetadataReadingError as e:
|
|
1130
1235
|
raise errors.InvalidAPIUsage(_("Impossible to parse picture metadata"), payload={"details": {"error": e.details}})
|
|
1131
1236
|
except utils.pictures.InvalidMetadataValue as e:
|
|
@@ -1143,7 +1248,7 @@ def postCollectionItem(collectionId, account=None):
|
|
|
1143
1248
|
|
|
1144
1249
|
# Return picture metadata
|
|
1145
1250
|
return (
|
|
1146
|
-
|
|
1251
|
+
_getPictureItemById(picId, account=account),
|
|
1147
1252
|
202,
|
|
1148
1253
|
{
|
|
1149
1254
|
"Content-Type": "application/json",
|
|
@@ -1159,7 +1264,21 @@ class PatchItemParameter(BaseModel):
|
|
|
1159
1264
|
heading: Optional[int] = None
|
|
1160
1265
|
"""Heading of the picture. The new heading will not be persisted in the picture's exif tags for the moment."""
|
|
1161
1266
|
visible: Optional[bool] = None
|
|
1162
|
-
"""Should the picture be publicly visible ?
|
|
1267
|
+
"""Should the picture be publicly visible ?
|
|
1268
|
+
|
|
1269
|
+
This parameter is deprecated in favor of the finer grained `visibility` parameter.
|
|
1270
|
+
`visible=true` is equivalent to `visibility=anyone`.
|
|
1271
|
+
`visible=false` is equivalent to `visibility=logged-only`.
|
|
1272
|
+
"""
|
|
1273
|
+
visibility: Optional[Visibility] = None
|
|
1274
|
+
"""Visibility of the sequence. Can be set to:
|
|
1275
|
+
* `anyone`: the sequence is visible to anyone
|
|
1276
|
+
* `owner-only`: the sequence is visible to the owner and administrator only
|
|
1277
|
+
* `logged-only`: the sequence is visible to logged users only
|
|
1278
|
+
|
|
1279
|
+
This visibility can also be set for each picture individually, using the `visibility` field of the pictures.
|
|
1280
|
+
If not set at the sequence level, it will default to the visibility of the `upload_set` and if not set the default visibility of the `account` and if not set the default visibility of the instance.
|
|
1281
|
+
"""
|
|
1163
1282
|
|
|
1164
1283
|
capture_time: Optional[datetime] = None
|
|
1165
1284
|
"""Capture time of the picture. The new capture time will not be persisted in the picture's exif tags for the moment."""
|
|
@@ -1227,66 +1346,29 @@ class PatchItemParameter(BaseModel):
|
|
|
1227
1346
|
raise errors.InvalidAPIUsage(_("Longitude cannot be overridden alone, latitude also needs to be set"))
|
|
1228
1347
|
if self.longitude is None and self.latitude is not None:
|
|
1229
1348
|
raise errors.InvalidAPIUsage(_("Latitude cannot be overridden alone, longitude also needs to be set"))
|
|
1349
|
+
if self.visibility is not None and self.visible is not None:
|
|
1350
|
+
raise errors.InvalidAPIUsage(_("Visibility and visible parameters are mutually exclusive parameters"))
|
|
1351
|
+
# handle retrocompatibility on the visible parameter
|
|
1352
|
+
if self.visible is not None:
|
|
1353
|
+
self.visibility = Visibility.anyone if self.visible is True else Visibility.owner_only
|
|
1230
1354
|
return self
|
|
1231
1355
|
|
|
1232
1356
|
def has_only_semantics_updates(self):
|
|
1233
1357
|
return self.model_fields_set == {"semantics"}
|
|
1234
1358
|
|
|
1359
|
+
@field_validator("visibility", mode="after")
|
|
1360
|
+
@classmethod
|
|
1361
|
+
def validate_visibility(cls, visibility):
|
|
1362
|
+
if not check_visibility(visibility):
|
|
1363
|
+
raise errors.InvalidAPIUsage(
|
|
1364
|
+
_("The logged-only visibility is not allowed on this instance since anybody can create an account"),
|
|
1365
|
+
status_code=400,
|
|
1366
|
+
)
|
|
1367
|
+
return visibility
|
|
1235
1368
|
|
|
1236
|
-
@bp.route("/collections/<uuid:collectionId>/items/<uuid:itemId>", methods=["PATCH"])
|
|
1237
|
-
@auth.login_required()
|
|
1238
|
-
def patchCollectionItem(collectionId, itemId, account):
|
|
1239
|
-
"""Edits properties of an existing picture
|
|
1240
|
-
|
|
1241
|
-
Note that tags cannot be added as form-data for the moment, only as JSON.
|
|
1242
|
-
|
|
1243
|
-
Note that there are rules on the editing of a picture's metadata:
|
|
1244
|
-
|
|
1245
|
-
- Only the owner of a picture can change its visibility
|
|
1246
|
-
- For core metadata (heading, capture_time, position, longitude, latitude), the owner can restrict their change by other accounts (see `collaborative_metadata` field in `/api/users/me`) and if not explicitly defined by the user, the instance's default value is used.
|
|
1247
|
-
- Everyone can add/edit/delete semantics tags.
|
|
1248
|
-
---
|
|
1249
|
-
tags:
|
|
1250
|
-
- Editing
|
|
1251
|
-
- Semantics
|
|
1252
|
-
parameters:
|
|
1253
|
-
- name: collectionId
|
|
1254
|
-
in: path
|
|
1255
|
-
description: ID of sequence the picture belongs to
|
|
1256
|
-
required: true
|
|
1257
|
-
schema:
|
|
1258
|
-
type: string
|
|
1259
|
-
- name: itemId
|
|
1260
|
-
in: path
|
|
1261
|
-
description: ID of picture to edit
|
|
1262
|
-
required: true
|
|
1263
|
-
schema:
|
|
1264
|
-
type: string
|
|
1265
|
-
requestBody:
|
|
1266
|
-
content:
|
|
1267
|
-
application/json:
|
|
1268
|
-
schema:
|
|
1269
|
-
$ref: '#/components/schemas/GeoVisioPatchItem'
|
|
1270
|
-
application/x-www-form-urlencoded:
|
|
1271
|
-
schema:
|
|
1272
|
-
$ref: '#/components/schemas/GeoVisioPatchItem'
|
|
1273
|
-
multipart/form-data:
|
|
1274
|
-
schema:
|
|
1275
|
-
$ref: '#/components/schemas/GeoVisioPatchItem'
|
|
1276
|
-
security:
|
|
1277
|
-
- bearerToken: []
|
|
1278
|
-
- cookieAuth: []
|
|
1279
|
-
responses:
|
|
1280
|
-
200:
|
|
1281
|
-
description: the wanted item
|
|
1282
|
-
content:
|
|
1283
|
-
application/geo+json:
|
|
1284
|
-
schema:
|
|
1285
|
-
$ref: '#/components/schemas/GeoVisioItem'
|
|
1286
|
-
"""
|
|
1287
1369
|
|
|
1370
|
+
def update_picture(itemId: UUID, account: Account):
|
|
1288
1371
|
# Parse received parameters
|
|
1289
|
-
|
|
1290
1372
|
metadata = None
|
|
1291
1373
|
content_type = (request.headers.get("Content-Type") or "").split(";")[0]
|
|
1292
1374
|
|
|
@@ -1300,21 +1382,28 @@ def patchCollectionItem(collectionId, itemId, account):
|
|
|
1300
1382
|
|
|
1301
1383
|
# If no parameter is set
|
|
1302
1384
|
if metadata is None or not metadata.has_override():
|
|
1303
|
-
return
|
|
1385
|
+
return (_getPictureItemById(itemId, account), 304)
|
|
1304
1386
|
|
|
1305
1387
|
# Check if picture exists and if given account is authorized to edit
|
|
1306
1388
|
with db.conn(current_app) as conn:
|
|
1307
1389
|
with conn.transaction(), conn.cursor(row_factory=dict_row) as cursor:
|
|
1308
|
-
pic = cursor.execute(
|
|
1390
|
+
pic = cursor.execute(
|
|
1391
|
+
"""SELECT p.visibility, p.account_id
|
|
1392
|
+
FROM pictures p
|
|
1393
|
+
JOIN sequences_pictures sp ON sp.pic_id = p.id
|
|
1394
|
+
JOIN sequences s ON s.id = sp.seq_id
|
|
1395
|
+
WHERE p.id = %(id)s AND is_picture_visible_by_user(p, %(account)s) AND is_sequence_visible_by_user(s, %(account)s)""",
|
|
1396
|
+
{"id": itemId, "account": account.id},
|
|
1397
|
+
).fetchone()
|
|
1309
1398
|
|
|
1310
1399
|
# Picture not found
|
|
1311
1400
|
if not pic:
|
|
1312
1401
|
raise errors.InvalidAPIUsage(_("Picture %(p)s wasn't found in database", p=itemId), status_code=404)
|
|
1313
1402
|
|
|
1314
|
-
if
|
|
1403
|
+
if not account.can_edit_item(str(pic["account_id"])):
|
|
1315
1404
|
# Account associated to picture doesn't match current user
|
|
1316
1405
|
# and we limit the status change to only the owner.
|
|
1317
|
-
if metadata.
|
|
1406
|
+
if metadata.visibility is not None:
|
|
1318
1407
|
raise errors.InvalidAPIUsage(
|
|
1319
1408
|
_("You're not authorized to edit the visibility of this picture. Only the owner can change this."), status_code=403
|
|
1320
1409
|
)
|
|
@@ -1330,24 +1419,14 @@ def patchCollectionItem(collectionId, itemId, account):
|
|
|
1330
1419
|
sqlParams = {"id": itemId, "account": account.id}
|
|
1331
1420
|
|
|
1332
1421
|
# Let's edit this picture
|
|
1333
|
-
|
|
1334
|
-
if oldStatus not in ["ready", "hidden"]:
|
|
1335
|
-
# Picture is in a preparing/broken/... state so no edit possible
|
|
1336
|
-
raise errors.InvalidAPIUsage(
|
|
1337
|
-
_(
|
|
1338
|
-
"Picture %(p)s is in %(s)s state, its visibility can't be changed for now",
|
|
1339
|
-
p=itemId,
|
|
1340
|
-
s=oldStatus,
|
|
1341
|
-
),
|
|
1342
|
-
status_code=400,
|
|
1343
|
-
)
|
|
1422
|
+
oldVisibility = pic["visibility"]
|
|
1344
1423
|
|
|
1345
|
-
|
|
1346
|
-
if metadata.
|
|
1347
|
-
|
|
1348
|
-
if
|
|
1349
|
-
sqlUpdates.append(SQL("
|
|
1350
|
-
sqlParams["
|
|
1424
|
+
newVisibility = None
|
|
1425
|
+
if metadata.visibility is not None:
|
|
1426
|
+
newVisibility = metadata.visibility.value
|
|
1427
|
+
if newVisibility != oldVisibility:
|
|
1428
|
+
sqlUpdates.append(SQL("visibility = %(visibility)s"))
|
|
1429
|
+
sqlParams["visibility"] = newVisibility
|
|
1351
1430
|
|
|
1352
1431
|
if metadata.heading is not None:
|
|
1353
1432
|
sqlUpdates.extend([SQL("heading = %(heading)s"), SQL("heading_computed = false")])
|
|
@@ -1381,12 +1460,66 @@ WHERE id = %(id)s"""
|
|
|
1381
1460
|
)
|
|
1382
1461
|
|
|
1383
1462
|
# Redirect response to a classic GET
|
|
1384
|
-
return
|
|
1463
|
+
return (_getPictureItemById(itemId, account), 200)
|
|
1464
|
+
|
|
1465
|
+
|
|
1466
|
+
@bp.route("/collections/<uuid:collectionId>/items/<uuid:itemId>", methods=["PATCH"])
|
|
1467
|
+
@auth.login_required()
|
|
1468
|
+
def patchCollectionItem(collectionId, itemId, account):
|
|
1469
|
+
"""Edits properties of an existing picture
|
|
1470
|
+
|
|
1471
|
+
Note that tags cannot be added as form-data for the moment, only as JSON.
|
|
1472
|
+
|
|
1473
|
+
Note that there are rules on the editing of a picture's metadata:
|
|
1474
|
+
|
|
1475
|
+
- Only the owner of a picture can change its visibility
|
|
1476
|
+
- For core metadata (heading, capture_time, position, longitude, latitude), the owner can restrict their change by other accounts (see `collaborative_metadata` field in `/api/users/me`) and if not explicitly defined by the user, the instance's default value is used.
|
|
1477
|
+
- Everyone can add/edit/delete semantics tags.
|
|
1478
|
+
---
|
|
1479
|
+
tags:
|
|
1480
|
+
- Editing
|
|
1481
|
+
- Semantics
|
|
1482
|
+
parameters:
|
|
1483
|
+
- name: collectionId
|
|
1484
|
+
in: path
|
|
1485
|
+
description: ID of sequence the picture belongs to
|
|
1486
|
+
required: true
|
|
1487
|
+
schema:
|
|
1488
|
+
type: string
|
|
1489
|
+
- name: itemId
|
|
1490
|
+
in: path
|
|
1491
|
+
description: ID of picture to edit
|
|
1492
|
+
required: true
|
|
1493
|
+
schema:
|
|
1494
|
+
type: string
|
|
1495
|
+
requestBody:
|
|
1496
|
+
content:
|
|
1497
|
+
application/json:
|
|
1498
|
+
schema:
|
|
1499
|
+
$ref: '#/components/schemas/GeoVisioPatchItem'
|
|
1500
|
+
application/x-www-form-urlencoded:
|
|
1501
|
+
schema:
|
|
1502
|
+
$ref: '#/components/schemas/GeoVisioPatchItem'
|
|
1503
|
+
multipart/form-data:
|
|
1504
|
+
schema:
|
|
1505
|
+
$ref: '#/components/schemas/GeoVisioPatchItem'
|
|
1506
|
+
security:
|
|
1507
|
+
- bearerToken: []
|
|
1508
|
+
- cookieAuth: []
|
|
1509
|
+
responses:
|
|
1510
|
+
200:
|
|
1511
|
+
description: the wanted item
|
|
1512
|
+
content:
|
|
1513
|
+
application/geo+json:
|
|
1514
|
+
schema:
|
|
1515
|
+
$ref: '#/components/schemas/GeoVisioItem'
|
|
1516
|
+
"""
|
|
1517
|
+
return update_picture(itemId, account=account)
|
|
1385
1518
|
|
|
1386
1519
|
|
|
1387
1520
|
@bp.route("/collections/<uuid:collectionId>/items/<uuid:itemId>", methods=["DELETE"])
|
|
1388
1521
|
@auth.login_required()
|
|
1389
|
-
def deleteCollectionItem(collectionId, itemId, account):
|
|
1522
|
+
def deleteCollectionItem(collectionId: UUID, itemId: UUID, account: Account):
|
|
1390
1523
|
"""Delete an existing picture
|
|
1391
1524
|
---
|
|
1392
1525
|
tags:
|
|
@@ -1422,7 +1555,7 @@ def deleteCollectionItem(collectionId, itemId, account):
|
|
|
1422
1555
|
raise errors.InvalidAPIUsage(_("Picture %(p)s wasn't found in database", p=itemId), status_code=404)
|
|
1423
1556
|
|
|
1424
1557
|
# Account associated to picture doesn't match current user
|
|
1425
|
-
if
|
|
1558
|
+
if not account.can_edit_item(str(pic[1])):
|
|
1426
1559
|
raise errors.InvalidAPIUsage(_("You're not authorized to edit this picture"), status_code=403)
|
|
1427
1560
|
|
|
1428
1561
|
cursor.execute("DELETE FROM pictures WHERE id = %s", [itemId])
|
|
@@ -1433,29 +1566,29 @@ def deleteCollectionItem(collectionId, itemId, account):
|
|
|
1433
1566
|
return "", 204
|
|
1434
1567
|
|
|
1435
1568
|
|
|
1436
|
-
def _getHDJpgPictureURL(picId: str,
|
|
1569
|
+
def _getHDJpgPictureURL(picId: str, visibility: Optional[str]):
|
|
1437
1570
|
external_url = utils.pictures.getPublicHDPictureExternalUrl(picId, format="jpg")
|
|
1438
|
-
if external_url and
|
|
1571
|
+
if external_url and visibility == "anyone": # we always serve non public pictures through the API to be able to check permission:
|
|
1439
1572
|
return external_url
|
|
1440
1573
|
return url_for("pictures.getPictureHD", _external=True, pictureId=picId, format="jpg")
|
|
1441
1574
|
|
|
1442
1575
|
|
|
1443
|
-
def _getSDJpgPictureURL(picId: str,
|
|
1576
|
+
def _getSDJpgPictureURL(picId: str, visibility: Optional[str]):
|
|
1444
1577
|
external_url = utils.pictures.getPublicDerivatePictureExternalUrl(picId, format="jpg", derivateFileName="sd.jpg")
|
|
1445
|
-
if external_url and
|
|
1578
|
+
if external_url and visibility == "anyone": # we always serve non public pictures through the API to be able to check permission:
|
|
1446
1579
|
return external_url
|
|
1447
1580
|
return url_for("pictures.getPictureSD", _external=True, pictureId=picId, format="jpg")
|
|
1448
1581
|
|
|
1449
1582
|
|
|
1450
|
-
def _getThumbJpgPictureURL(picId: str,
|
|
1583
|
+
def _getThumbJpgPictureURL(picId: str, visibility: Optional[str]):
|
|
1451
1584
|
external_url = utils.pictures.getPublicDerivatePictureExternalUrl(picId, format="jpg", derivateFileName="thumb.jpg")
|
|
1452
|
-
if external_url and
|
|
1585
|
+
if external_url and visibility == "anyone": # we always serve non public pictures through the API to be able to check permission
|
|
1453
1586
|
return external_url
|
|
1454
1587
|
return url_for("pictures.getPictureThumb", _external=True, pictureId=picId, format="jpg")
|
|
1455
1588
|
|
|
1456
1589
|
|
|
1457
|
-
def _getTilesJpgPictureURL(picId: str,
|
|
1590
|
+
def _getTilesJpgPictureURL(picId: str, visibility: Optional[str]):
|
|
1458
1591
|
external_url = utils.pictures.getPublicDerivatePictureExternalUrl(picId, format="jpg", derivateFileName="tiles/{TileCol}_{TileRow}.jpg")
|
|
1459
|
-
if external_url and
|
|
1592
|
+
if external_url and visibility == "anyone": # we always serve non public pictures through the API to be able to check permission:
|
|
1460
1593
|
return external_url
|
|
1461
1594
|
return unquote(url_for("pictures.getPictureTile", _external=True, pictureId=picId, format="jpg", col="{TileCol}", row="{TileRow}"))
|