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/web/docs.py
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
from geovisio.web import utils, upload_set, reports, excluded_areas
|
|
1
|
+
from geovisio.web import annotations, collections, items, prepare, users, utils, upload_set, reports, excluded_areas, pages
|
|
2
2
|
from geovisio.utils import upload_set as upload_set_utils, reports as reports_utils, excluded_areas as excluded_areas_utils
|
|
3
3
|
from importlib import metadata
|
|
4
4
|
import re
|
|
@@ -135,6 +135,9 @@ API_CONFIG = {
|
|
|
135
135
|
},
|
|
136
136
|
},
|
|
137
137
|
"STACStatsForItems": {"$ref": "https://stac-extensions.github.io/stats/v0.2.0/schema.json#/definitions/stats_for_items"},
|
|
138
|
+
"STACStatsForCollections": {
|
|
139
|
+
"$ref": "https://stac-extensions.github.io/stats/v0.2.0/schema.json#/definitions/stats_for_collections"
|
|
140
|
+
},
|
|
138
141
|
"STACLinks": {
|
|
139
142
|
"type": "object",
|
|
140
143
|
"properties": {
|
|
@@ -234,6 +237,9 @@ Note that you may not rely only on these ID that could change through time.
|
|
|
234
237
|
},
|
|
235
238
|
]
|
|
236
239
|
},
|
|
240
|
+
"PreparationParameter": prepare.PreparationParameter.model_json_schema(
|
|
241
|
+
ref_template="#/components/schemas/PreparationParameter/$defs/{model}", mode="serialization"
|
|
242
|
+
),
|
|
237
243
|
"GeoVisioPostUploadSet": upload_set.UploadSetCreationParameter.model_json_schema(
|
|
238
244
|
ref_template="#/components/schemas/GeoVisioPostUploadSet/$defs/{model}", mode="serialization"
|
|
239
245
|
),
|
|
@@ -266,6 +272,7 @@ Note that you may not rely only on these ID that could change through time.
|
|
|
266
272
|
"required": ["href", "rel"],
|
|
267
273
|
"properties": {
|
|
268
274
|
"stats:items": {"$ref": "#/components/schemas/STACStatsForItems"},
|
|
275
|
+
"stats:collections": {"$ref": "#/components/schemas/STACStatsForCollections"},
|
|
269
276
|
"extent": {"$ref": "#/components/schemas/STACExtentTemporal"},
|
|
270
277
|
"geovisio:status": {"$ref": "#/components/schemas/GeoVisioCollectionStatus"},
|
|
271
278
|
"geovisio:length_km": {"$ref": "#/components/schemas/GeoVisioLengthKm"},
|
|
@@ -286,6 +293,30 @@ Note that you may not rely only on these ID that could change through time.
|
|
|
286
293
|
},
|
|
287
294
|
]
|
|
288
295
|
},
|
|
296
|
+
"GeoVisioCSVCollections": {
|
|
297
|
+
"type": "string",
|
|
298
|
+
"descrition": f"""CSV file containing the collections.
|
|
299
|
+
|
|
300
|
+
The CSV headers will be:
|
|
301
|
+
* id: ID of the collection
|
|
302
|
+
* status: Status of the collection
|
|
303
|
+
* name: Name of the collection (its title)
|
|
304
|
+
* created: Creation date of the collection
|
|
305
|
+
* updated: Last update date of the collection
|
|
306
|
+
* capture_date: Computed capture date of the collection (date of its first picture)
|
|
307
|
+
* minimum_capture_time: Capture datetime of the first picture
|
|
308
|
+
* maximum_capture_time: Capture datetime of the last picture
|
|
309
|
+
* min_x: Minimum X coordinate of the bounding box of the collection
|
|
310
|
+
* min_y: Minimum Y coordinate of the bounding box of the collection
|
|
311
|
+
* max_x: Maximum X coordinate of the bounding box of the collection
|
|
312
|
+
* max_y: Maximum Y coordinate of the bounding box of the collection
|
|
313
|
+
* nb_pictures: Number of pictures in the collection
|
|
314
|
+
* length_km: Total length of the collection in kilometers
|
|
315
|
+
* computed_h_pixel_density: Horizontal pixel density of the pictures in the collection, if all pictures have the same one
|
|
316
|
+
* computed_gps_accuracy: GPS accuracy of the pictures in the collection, if all pictures have the same one
|
|
317
|
+
|
|
318
|
+
""",
|
|
319
|
+
},
|
|
289
320
|
"GeoVisioCollections": {
|
|
290
321
|
"allOf": [
|
|
291
322
|
{"$ref": "#/components/schemas/STACCollections"},
|
|
@@ -400,41 +431,9 @@ Note that you may not rely only on these ID that could change through time.
|
|
|
400
431
|
"type": "object",
|
|
401
432
|
"properties": {"title": {"type": "string", "description": "The sequence title"}},
|
|
402
433
|
},
|
|
403
|
-
"GeoVisioPatchCollection":
|
|
404
|
-
"
|
|
405
|
-
|
|
406
|
-
"visible": {
|
|
407
|
-
"type": "string",
|
|
408
|
-
"description": "Should the sequence be publicly visible ?",
|
|
409
|
-
"enum": ["true", "false", "null"],
|
|
410
|
-
"default": "null",
|
|
411
|
-
},
|
|
412
|
-
"title": {
|
|
413
|
-
"type": "string",
|
|
414
|
-
"description": "The sequence title (publicly displayed)",
|
|
415
|
-
},
|
|
416
|
-
"relative_heading": {
|
|
417
|
-
"type": "number",
|
|
418
|
-
"minimum": -180,
|
|
419
|
-
"maximum": 180,
|
|
420
|
-
"description": "The relative heading (in degrees), offset based on movement path (0° = looking forward, -90° = looking left, 90° = looking right). Headings are unchanged if this parameter is not set.",
|
|
421
|
-
},
|
|
422
|
-
"sortby": {
|
|
423
|
-
"description": """
|
|
424
|
-
Define the pictures sort order based on given property. Sort order is defined based on preceding '+' (asc) or '-' (desc).
|
|
425
|
-
|
|
426
|
-
Available properties are:
|
|
427
|
-
* `gpsdate`: sort by GPS datetime
|
|
428
|
-
* `filedate`: sort by the camera-generated capture date. This is based on EXIF tags `Exif.Image.DateTimeOriginal`, `Exif.Photo.DateTimeOriginal`, `Exif.Image.DateTime` or `Xmp.GPano.SourceImageCreateTime` (in this order).
|
|
429
|
-
* `filename`: sort by the original picture file name
|
|
430
|
-
|
|
431
|
-
If unset, sort order is unchanged.
|
|
432
|
-
""",
|
|
433
|
-
"type": "string",
|
|
434
|
-
"enum": ["+gpsdate", "-gpsdate", "+filedate", "-filedate", "+filename", "-filename"],
|
|
435
|
-
},
|
|
436
|
-
},
|
|
437
|
-
},
|
|
434
|
+
"GeoVisioPatchCollection": collections.PatchCollectionParameter.model_json_schema(
|
|
435
|
+
ref_template="#/components/schemas/GeoVisioPatchCollection/$defs/{model}", mode="serialization"
|
|
436
|
+
),
|
|
438
437
|
"GeoVisioCollectionItems": {
|
|
439
438
|
"allOf": [
|
|
440
439
|
{"$ref": "#/components/schemas/STACCollectionItems"},
|
|
@@ -583,23 +582,9 @@ Note that this parameter is not taken in account for 360° pictures, as by defin
|
|
|
583
582
|
},
|
|
584
583
|
],
|
|
585
584
|
},
|
|
586
|
-
"GeoVisioPatchItem":
|
|
587
|
-
"
|
|
588
|
-
|
|
589
|
-
"visible": {
|
|
590
|
-
"type": "string",
|
|
591
|
-
"description": "Should the picture be publicly visible ?",
|
|
592
|
-
"enum": ["true", "false", "null"],
|
|
593
|
-
"default": "null",
|
|
594
|
-
},
|
|
595
|
-
"heading": {
|
|
596
|
-
"type": "number",
|
|
597
|
-
"minimum": 0,
|
|
598
|
-
"maximum": 360,
|
|
599
|
-
"description": "The picture heading (in degrees). North is 0°, East = 90°, South = 180° and West = 270°.",
|
|
600
|
-
},
|
|
601
|
-
},
|
|
602
|
-
},
|
|
585
|
+
"GeoVisioPatchItem": items.PatchItemParameter.model_json_schema(
|
|
586
|
+
ref_template="#/components/schemas/GeoVisioPatchItem/$defs/{model}", mode="serialization"
|
|
587
|
+
),
|
|
603
588
|
"GeoVisioCollectionStatus": {"type": "string", "enum": ["ready", "broken", "preparing", "waiting-for-process"]},
|
|
604
589
|
"GeoVisioLengthKm": {"type": "number", "description": "Total length of sequence (in kilometers)"},
|
|
605
590
|
"GeoVisioCollectionSortedBy": {
|
|
@@ -659,20 +644,12 @@ Available properties are:
|
|
|
659
644
|
},
|
|
660
645
|
},
|
|
661
646
|
},
|
|
662
|
-
"
|
|
663
|
-
"
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
"type": "array",
|
|
669
|
-
"items": {
|
|
670
|
-
"type": "object",
|
|
671
|
-
"properties": {"href": {"type": "string"}, "ref": {"type": "string"}, "type": {"type": "string"}},
|
|
672
|
-
},
|
|
673
|
-
},
|
|
674
|
-
},
|
|
675
|
-
},
|
|
647
|
+
"GeoVisioUserConfiguration": users.UserConfiguration.model_json_schema(
|
|
648
|
+
ref_template="#/components/schemas/GeoVisioUserConfiguration/$defs/{model}", mode="serialization"
|
|
649
|
+
),
|
|
650
|
+
"GeoVisioUser": users.UserInfo.model_json_schema(
|
|
651
|
+
ref_template="#/components/schemas/GeoVisioUser/$defs/{model}", mode="serialization"
|
|
652
|
+
),
|
|
676
653
|
"GeoVisioUserAuth": {
|
|
677
654
|
"type": "object",
|
|
678
655
|
"properties": {
|
|
@@ -704,6 +681,10 @@ Available properties are:
|
|
|
704
681
|
},
|
|
705
682
|
},
|
|
706
683
|
},
|
|
684
|
+
"GeoVisioPageName": {"type": "string", "enum": ["end-user-license-agreement", "terms-of-service"]},
|
|
685
|
+
"GeoVisioPageSummary": pages.PageSummary.model_json_schema(
|
|
686
|
+
ref_template="#/components/schemas/GeoVisioPageSummary/$defs/{model}", mode="serialization"
|
|
687
|
+
),
|
|
707
688
|
"GeoVisioConfiguration": {
|
|
708
689
|
"type": "object",
|
|
709
690
|
"properties": {
|
|
@@ -731,6 +712,21 @@ Available properties are:
|
|
|
731
712
|
},
|
|
732
713
|
},
|
|
733
714
|
},
|
|
715
|
+
"geo_coverage": {
|
|
716
|
+
"type": "object",
|
|
717
|
+
"properties": {
|
|
718
|
+
"label": {
|
|
719
|
+
"type": "string",
|
|
720
|
+
"description": "Instance geographical coverage for pictures uploads, in user language",
|
|
721
|
+
},
|
|
722
|
+
"langs": {
|
|
723
|
+
"type": "object",
|
|
724
|
+
"additionalProperties": "string",
|
|
725
|
+
"description": "Translated descriptions as lang -> value object",
|
|
726
|
+
"default": {"en": "Worldwide\nThe picture can be sent from anywhere in the world."},
|
|
727
|
+
},
|
|
728
|
+
},
|
|
729
|
+
},
|
|
734
730
|
"logo": {
|
|
735
731
|
"default": "https://gitlab.com/panoramax/gitlab-profile/-/raw/main/images/logo.svg",
|
|
736
732
|
"format": "uri",
|
|
@@ -740,11 +736,13 @@ Available properties are:
|
|
|
740
736
|
"type": "string",
|
|
741
737
|
},
|
|
742
738
|
"color": {"default": "#bf360c", "format": "color", "title": "Color", "type": "string"},
|
|
739
|
+
"email": {"default": "panoramax@panoramax.fr", "format": "email", "title": "Contact email", "type": "string"},
|
|
743
740
|
"auth": {
|
|
744
741
|
"type": "object",
|
|
745
742
|
"properties": {
|
|
746
743
|
"user_profile": {"type": "object", "properties": {"url": {"type": "string"}}},
|
|
747
744
|
"enabled": {"type": "boolean"},
|
|
745
|
+
"enforce_tos_acceptance": {"type": "boolean"},
|
|
748
746
|
},
|
|
749
747
|
"required": ["enabled"],
|
|
750
748
|
},
|
|
@@ -1078,9 +1076,11 @@ def getApiDocs():
|
|
|
1078
1076
|
"externalDocs": {"url": "https://docs.panoramax.fr/api/api/api/#upload"},
|
|
1079
1077
|
},
|
|
1080
1078
|
{"name": "Editing", "description": "Modifying pictures & sequences"},
|
|
1079
|
+
{"name": "Semantics", "description": "Panoramax semantics"},
|
|
1081
1080
|
{"name": "Reports", "description": "Report issues with pictures & sequences"},
|
|
1082
1081
|
{"name": "Excluded Areas", "description": "Areas where pictures cannot be uploaded"},
|
|
1083
1082
|
{"name": "Users", "description": "Account management"},
|
|
1084
1083
|
{"name": "Auth", "description": "User authentication"},
|
|
1084
|
+
{"name": "Configuration", "description": "Various settings"},
|
|
1085
1085
|
],
|
|
1086
1086
|
}
|
geovisio/web/items.py
CHANGED
|
@@ -1,14 +1,18 @@
|
|
|
1
|
+
from datetime import datetime
|
|
1
2
|
import json
|
|
2
3
|
import logging
|
|
3
4
|
import os
|
|
4
|
-
from typing import Dict, Optional, Any
|
|
5
|
+
from typing import Dict, List, Optional, Any
|
|
5
6
|
from urllib.parse import unquote
|
|
6
7
|
from psycopg.types.json import Jsonb
|
|
8
|
+
from pydantic import BaseModel, ConfigDict, ValidationError, field_validator, model_validator
|
|
7
9
|
from werkzeug.datastructures import MultiDict
|
|
8
10
|
from uuid import UUID
|
|
9
11
|
from geovisio import errors, utils
|
|
10
12
|
from geovisio.utils import auth, db
|
|
13
|
+
from geovisio.utils.params import validation_error
|
|
11
14
|
from geovisio.utils.pictures import cleanupExif
|
|
15
|
+
from geovisio.utils.semantics import SemanticTagUpdate, Entity, EntityType, update_tags
|
|
12
16
|
from geovisio.web.params import (
|
|
13
17
|
as_latitude,
|
|
14
18
|
as_longitude,
|
|
@@ -19,6 +23,7 @@ from geovisio.web.params import (
|
|
|
19
23
|
parse_list,
|
|
20
24
|
parse_lonlat,
|
|
21
25
|
parse_distance_range,
|
|
26
|
+
parse_picture_heading,
|
|
22
27
|
)
|
|
23
28
|
from geovisio.utils.fields import Bounds
|
|
24
29
|
import hashlib
|
|
@@ -140,6 +145,7 @@ def dbPictureToStacItem(seqId, dbPic):
|
|
|
140
145
|
"geovisio:thumbnail": _getThumbJpgPictureURL(dbPic["id"], dbPic.get("status")),
|
|
141
146
|
"exif": removeNoneInDict(cleanupExif(dbPic["exif"])),
|
|
142
147
|
"quality:horizontal_accuracy": float("{:.1f}".format(dbPic["gps_accuracy_m"])) if dbPic.get("gps_accuracy_m") else None,
|
|
148
|
+
"semantics": dbPic["semantics"] if "semantics" in dbPic else None,
|
|
143
149
|
}
|
|
144
150
|
),
|
|
145
151
|
"links": cleanNoneInList(
|
|
@@ -421,24 +427,33 @@ def getCollectionItems(collectionId):
|
|
|
421
427
|
|
|
422
428
|
query = SQL(
|
|
423
429
|
"""
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
430
|
+
SELECT
|
|
431
|
+
p.id, p.ts, p.heading, p.metadata, p.inserted_at, p.status,
|
|
432
|
+
ST_AsGeoJSON(p.geom)::json AS geojson,
|
|
433
|
+
a.name AS account_name,
|
|
428
434
|
p.account_id AS account_id,
|
|
429
435
|
sp.rank, p.exif, p.gps_accuracy_m, p.h_pixel_density,
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
436
|
+
CASE WHEN LAG(p.status) OVER othpics = 'ready' THEN LAG(p.id) OVER othpics END AS prevpic,
|
|
437
|
+
CASE WHEN LAG(p.status) OVER othpics = 'ready' THEN ST_AsGeoJSON(LAG(p.geom) OVER othpics)::json END AS prevpicgeojson,
|
|
438
|
+
CASE WHEN LEAD(p.status) OVER othpics = 'ready' THEN LEAD(p.id) OVER othpics END AS nextpic,
|
|
439
|
+
CASE WHEN LEAD(p.status) OVER othpics = 'ready' THEN ST_AsGeoJSON(LEAD(p.geom) OVER othpics)::json END AS nextpicgeojson,
|
|
440
|
+
t.semantics
|
|
441
|
+
FROM sequences_pictures sp
|
|
442
|
+
JOIN pictures p ON sp.pic_id = p.id
|
|
443
|
+
JOIN accounts a ON a.id = p.account_id
|
|
444
|
+
JOIN sequences s ON s.id = sp.seq_id
|
|
445
|
+
LEFT JOIN (
|
|
446
|
+
SELECT picture_id, json_agg(json_strip_nulls(json_build_object(
|
|
447
|
+
'key', key,
|
|
448
|
+
'value', value
|
|
449
|
+
))) AS semantics
|
|
450
|
+
FROM pictures_semantics
|
|
451
|
+
GROUP BY picture_id
|
|
452
|
+
) t ON t.picture_id = p.id
|
|
453
|
+
WHERE
|
|
454
|
+
{filter}
|
|
455
|
+
WINDOW othpics AS (PARTITION BY sp.seq_id ORDER BY sp.rank)
|
|
456
|
+
ORDER BY rank
|
|
442
457
|
{limit}
|
|
443
458
|
"""
|
|
444
459
|
).format(filter=SQL(" AND ").join(filters), limit=sql_limit)
|
|
@@ -587,30 +602,39 @@ def _getPictureItemById(collectionId, itemId):
|
|
|
587
602
|
# Get rank + position of wanted picture
|
|
588
603
|
record = cursor.execute(
|
|
589
604
|
"""
|
|
590
|
-
|
|
591
|
-
|
|
605
|
+
SELECT
|
|
606
|
+
p.id, sp.rank, ST_AsGeoJSON(p.geom)::json AS geojson, p.heading, p.ts, p.metadata,
|
|
592
607
|
p.inserted_at, p.status, accounts.name AS account_name,
|
|
593
608
|
p.account_id AS account_id,
|
|
594
|
-
|
|
595
|
-
relp.related_pics, p.gps_accuracy_m, p.h_pixel_density
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
609
|
+
spl.prevpic, spl.prevpicgeojson, spl.nextpic, spl.nextpicgeojson, p.exif,
|
|
610
|
+
relp.related_pics, p.gps_accuracy_m, p.h_pixel_density,
|
|
611
|
+
t.semantics
|
|
612
|
+
FROM pictures p
|
|
613
|
+
JOIN sequences_pictures sp ON sp.pic_id = p.id
|
|
614
|
+
JOIN accounts ON p.account_id = accounts.id
|
|
615
|
+
JOIN sequences s ON sp.seq_id = s.id
|
|
616
|
+
LEFT JOIN (
|
|
617
|
+
SELECT picture_id, json_agg(json_strip_nulls(json_build_object(
|
|
618
|
+
'key', key,
|
|
619
|
+
'value', value
|
|
620
|
+
))) AS semantics
|
|
621
|
+
FROM pictures_semantics
|
|
622
|
+
GROUP BY picture_id
|
|
623
|
+
) t ON t.picture_id = p.id
|
|
624
|
+
LEFT JOIN (
|
|
625
|
+
SELECT
|
|
626
|
+
p.id,
|
|
627
|
+
LAG(p.id) OVER othpics AS prevpic,
|
|
628
|
+
ST_AsGeoJSON(LAG(p.geom) OVER othpics)::json AS prevpicgeojson,
|
|
629
|
+
LEAD(p.id) OVER othpics AS nextpic,
|
|
630
|
+
ST_AsGeoJSON(LEAD(p.geom) OVER othpics)::json AS nextpicgeojson
|
|
631
|
+
FROM pictures p
|
|
632
|
+
JOIN sequences_pictures sp ON p.id = sp.pic_id
|
|
633
|
+
WHERE
|
|
634
|
+
sp.seq_id = %(seq)s
|
|
635
|
+
AND (p.account_id = %(acc)s OR p.status != 'hidden')
|
|
636
|
+
WINDOW othpics AS (PARTITION BY sp.seq_id ORDER BY sp.rank)
|
|
637
|
+
) spl ON p.id = spl.id
|
|
614
638
|
LEFT JOIN (
|
|
615
639
|
SELECT array_agg(ARRAY[seq_id::text, id::text, geom, tstxt]) AS related_pics
|
|
616
640
|
FROM (
|
|
@@ -648,12 +672,12 @@ def _getPictureItemById(collectionId, itemId):
|
|
|
648
672
|
ORDER BY relsp.seq_id, p.geom <-> relp.geom
|
|
649
673
|
) a
|
|
650
674
|
) relp ON TRUE
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
675
|
+
WHERE sp.seq_id = %(seq)s
|
|
676
|
+
AND p.id = %(pic)s
|
|
677
|
+
AND (p.account_id = %(acc)s OR p.status != 'hidden')
|
|
678
|
+
AND (s.status != 'hidden' OR s.account_id = %(acc)s)
|
|
655
679
|
AND s.status != 'deleted'
|
|
656
|
-
|
|
680
|
+
""",
|
|
657
681
|
{"seq": collectionId, "pic": itemId, "acc": accountId},
|
|
658
682
|
).fetchone()
|
|
659
683
|
|
|
@@ -907,11 +931,20 @@ SELECT * FROM (
|
|
|
907
931
|
sp.seq_id, sp.rank AS rank,
|
|
908
932
|
accounts.name AS account_name,
|
|
909
933
|
p.account_id AS account_id,
|
|
910
|
-
p.exif, p.gps_accuracy_m, p.h_pixel_density
|
|
934
|
+
p.exif, p.gps_accuracy_m, p.h_pixel_density,
|
|
935
|
+
t.semantics
|
|
911
936
|
FROM pictures p
|
|
912
937
|
LEFT JOIN sequences_pictures sp ON p.id = sp.pic_id
|
|
913
938
|
LEFT JOIN sequences s ON s.id = sp.seq_id
|
|
914
939
|
LEFT JOIN accounts ON p.account_id = accounts.id
|
|
940
|
+
LEFT JOIN (
|
|
941
|
+
SELECT picture_id, json_agg(json_strip_nulls(json_build_object(
|
|
942
|
+
'key', key,
|
|
943
|
+
'value', value
|
|
944
|
+
))) AS semantics
|
|
945
|
+
FROM pictures_semantics
|
|
946
|
+
GROUP BY picture_id
|
|
947
|
+
) t ON t.picture_id = p.id
|
|
915
948
|
WHERE {sqlWhere}
|
|
916
949
|
{orderBy}
|
|
917
950
|
LIMIT %(limit)s
|
|
@@ -1119,13 +1152,102 @@ def postCollectionItem(collectionId, account=None):
|
|
|
1119
1152
|
)
|
|
1120
1153
|
|
|
1121
1154
|
|
|
1155
|
+
class PatchItemParameter(BaseModel):
|
|
1156
|
+
"""Parameters used to add an item to an UploadSet"""
|
|
1157
|
+
|
|
1158
|
+
heading: Optional[int] = None
|
|
1159
|
+
"""Heading of the picture. The new heading will not be persisted in the picture's exif tags for the moment."""
|
|
1160
|
+
visible: Optional[bool] = None
|
|
1161
|
+
"""Should the picture be publicly visible ?"""
|
|
1162
|
+
|
|
1163
|
+
capture_time: Optional[datetime] = None
|
|
1164
|
+
"""Capture time of the picture. The new capture time will not be persisted in the picture's exif tags for the moment."""
|
|
1165
|
+
longitude: Optional[float] = None
|
|
1166
|
+
"""Longitude of the picture. The new longitude will not be persisted in the picture's exif tags for the moment."""
|
|
1167
|
+
latitude: Optional[float] = None
|
|
1168
|
+
"""Latitude of the picture. The new latitude will not be persisted in the picture's exif tags for the moment."""
|
|
1169
|
+
|
|
1170
|
+
semantics: Optional[List[SemanticTagUpdate]] = None
|
|
1171
|
+
"""Tags to update on the picture. By default each tag will be added to the picture's tags, but you can change this behavior by setting the `action` parameter to `delete`.
|
|
1172
|
+
|
|
1173
|
+
If you want to replace a tag, you need to first delete it, then add it again.
|
|
1174
|
+
|
|
1175
|
+
Like:
|
|
1176
|
+
[
|
|
1177
|
+
{"key": "some_key", "value": "some_value", "action": "delete"},
|
|
1178
|
+
{"key": "some_key", "value": "some_new_value"}
|
|
1179
|
+
]
|
|
1180
|
+
|
|
1181
|
+
|
|
1182
|
+
Note that updating tags is only possible with JSON data, not with form-data."""
|
|
1183
|
+
|
|
1184
|
+
def has_override(self) -> bool:
|
|
1185
|
+
return self.model_fields_set
|
|
1186
|
+
|
|
1187
|
+
@field_validator("heading", mode="before")
|
|
1188
|
+
@classmethod
|
|
1189
|
+
def parse_heading(cls, value):
|
|
1190
|
+
if value is None:
|
|
1191
|
+
return None
|
|
1192
|
+
return parse_picture_heading(value)
|
|
1193
|
+
|
|
1194
|
+
@field_validator("visible", mode="before")
|
|
1195
|
+
@classmethod
|
|
1196
|
+
def parse_visible(cls, value):
|
|
1197
|
+
if value not in ["true", "false"]:
|
|
1198
|
+
raise errors.InvalidAPIUsage(_("Picture visibility parameter (visible) should be either unset, true or false"), status_code=400)
|
|
1199
|
+
return value == "true"
|
|
1200
|
+
|
|
1201
|
+
@field_validator("capture_time", mode="before")
|
|
1202
|
+
@classmethod
|
|
1203
|
+
def parse_capture_time(cls, value):
|
|
1204
|
+
if value is None:
|
|
1205
|
+
return None
|
|
1206
|
+
return parse_datetime(
|
|
1207
|
+
value,
|
|
1208
|
+
error=_(
|
|
1209
|
+
"Parameter `capture_time` is not a valid datetime, it should be an iso formated datetime (like '2017-07-21T17:32:28Z')."
|
|
1210
|
+
),
|
|
1211
|
+
)
|
|
1212
|
+
|
|
1213
|
+
@field_validator("longitude")
|
|
1214
|
+
@classmethod
|
|
1215
|
+
def parse_longitude(cls, value):
|
|
1216
|
+
return as_longitude(value, error=_("For parameter `longitude`, `%(v)s` is not a valid longitude", v=value))
|
|
1217
|
+
|
|
1218
|
+
@field_validator("latitude")
|
|
1219
|
+
@classmethod
|
|
1220
|
+
def parse_latitude(cls, value):
|
|
1221
|
+
return as_latitude(value, error=_("For parameter `latitude`, `%(v)s` is not a valid latitude", v=value))
|
|
1222
|
+
|
|
1223
|
+
@model_validator(mode="after")
|
|
1224
|
+
def validate(self):
|
|
1225
|
+
if self.latitude is None and self.longitude is not None:
|
|
1226
|
+
raise errors.InvalidAPIUsage(_("Longitude cannot be overridden alone, latitude also needs to be set"))
|
|
1227
|
+
if self.longitude is None and self.latitude is not None:
|
|
1228
|
+
raise errors.InvalidAPIUsage(_("Latitude cannot be overridden alone, longitude also needs to be set"))
|
|
1229
|
+
return self
|
|
1230
|
+
|
|
1231
|
+
def has_only_semantics_updates(self):
|
|
1232
|
+
return self.model_fields_set == {"semantics"}
|
|
1233
|
+
|
|
1234
|
+
|
|
1122
1235
|
@bp.route("/collections/<uuid:collectionId>/items/<uuid:itemId>", methods=["PATCH"])
|
|
1123
1236
|
@auth.login_required()
|
|
1124
1237
|
def patchCollectionItem(collectionId, itemId, account):
|
|
1125
1238
|
"""Edits properties of an existing picture
|
|
1239
|
+
|
|
1240
|
+
Note that tags cannot be added as form-data for the moment, only as JSON.
|
|
1241
|
+
|
|
1242
|
+
Note that there are rules on the editing of a picture's metadata:
|
|
1243
|
+
|
|
1244
|
+
- Only the owner of a picture can change its visibility
|
|
1245
|
+
- 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.
|
|
1246
|
+
- Everyone can add/edit/delete semantics tags.
|
|
1126
1247
|
---
|
|
1127
1248
|
tags:
|
|
1128
1249
|
- Editing
|
|
1250
|
+
- Tags
|
|
1129
1251
|
parameters:
|
|
1130
1252
|
- name: collectionId
|
|
1131
1253
|
in: path
|
|
@@ -1163,37 +1285,20 @@ def patchCollectionItem(collectionId, itemId, account):
|
|
|
1163
1285
|
"""
|
|
1164
1286
|
|
|
1165
1287
|
# Parse received parameters
|
|
1166
|
-
|
|
1288
|
+
|
|
1289
|
+
metadata = None
|
|
1167
1290
|
content_type = (request.headers.get("Content-Type") or "").split(";")[0]
|
|
1168
|
-
|
|
1291
|
+
|
|
1292
|
+
try:
|
|
1169
1293
|
if request.is_json and request.json:
|
|
1170
|
-
metadata
|
|
1294
|
+
metadata = PatchItemParameter(**request.json)
|
|
1171
1295
|
elif content_type in ["multipart/form-data", "application/x-www-form-urlencoded"]:
|
|
1172
|
-
metadata
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
if visible is not None:
|
|
1176
|
-
if visible not in ["true", "false"]:
|
|
1177
|
-
raise errors.InvalidAPIUsage(_("Picture visibility parameter (visible) should be either unset, true or false"), status_code=400)
|
|
1178
|
-
visible = visible == "true"
|
|
1179
|
-
|
|
1180
|
-
# Check if heading is valid
|
|
1181
|
-
heading = metadata.get("heading")
|
|
1182
|
-
if heading is not None:
|
|
1183
|
-
try:
|
|
1184
|
-
heading = int(heading)
|
|
1185
|
-
if heading < 0 or heading > 360:
|
|
1186
|
-
raise ValueError()
|
|
1187
|
-
except ValueError:
|
|
1188
|
-
raise errors.InvalidAPIUsage(
|
|
1189
|
-
_(
|
|
1190
|
-
"Heading is not valid, should be an integer in degrees from 0° to 360°. North is 0°, East = 90°, South = 180° and West = 270°."
|
|
1191
|
-
),
|
|
1192
|
-
status_code=400,
|
|
1193
|
-
)
|
|
1296
|
+
metadata = PatchItemParameter(**request.form)
|
|
1297
|
+
except ValidationError as ve:
|
|
1298
|
+
raise errors.InvalidAPIUsage(_("Impossible to parse parameters"), payload=validation_error(ve))
|
|
1194
1299
|
|
|
1195
1300
|
# If no parameter is set
|
|
1196
|
-
if
|
|
1301
|
+
if metadata is None or not metadata.has_override():
|
|
1197
1302
|
return getCollectionItem(collectionId, itemId)
|
|
1198
1303
|
|
|
1199
1304
|
# Check if picture exists and if given account is authorized to edit
|
|
@@ -1205,10 +1310,21 @@ def patchCollectionItem(collectionId, itemId, account):
|
|
|
1205
1310
|
if not pic:
|
|
1206
1311
|
raise errors.InvalidAPIUsage(_("Picture %(p)s wasn't found in database", p=itemId), status_code=404)
|
|
1207
1312
|
|
|
1208
|
-
# Account associated to picture doesn't match current user
|
|
1209
1313
|
if account is not None and account.id != str(pic["account_id"]):
|
|
1210
|
-
|
|
1314
|
+
# Account associated to picture doesn't match current user
|
|
1315
|
+
# and we limit the status change to only the owner.
|
|
1316
|
+
if metadata.visible is not None:
|
|
1317
|
+
raise errors.InvalidAPIUsage(
|
|
1318
|
+
_("You're not authorized to edit the visibility of this picture. Only the owner can change this."), status_code=403
|
|
1319
|
+
)
|
|
1211
1320
|
|
|
1321
|
+
# for core metadata editing (all appart the semantic tags), we check if the user has allowed it
|
|
1322
|
+
if not metadata.has_only_semantics_updates():
|
|
1323
|
+
if not auth.account_allow_collaborative_editing(pic["account_id"]):
|
|
1324
|
+
raise errors.InvalidAPIUsage(
|
|
1325
|
+
_("You're not authorized to edit this picture, collaborative editing is not allowed"),
|
|
1326
|
+
status_code=403,
|
|
1327
|
+
)
|
|
1212
1328
|
sqlUpdates = []
|
|
1213
1329
|
sqlParams = {"id": itemId, "account": account.id}
|
|
1214
1330
|
|
|
@@ -1226,34 +1342,42 @@ def patchCollectionItem(collectionId, itemId, account):
|
|
|
1226
1342
|
)
|
|
1227
1343
|
|
|
1228
1344
|
newStatus = None
|
|
1229
|
-
if visible is not None:
|
|
1230
|
-
newStatus = "ready" if visible is True else "hidden"
|
|
1345
|
+
if metadata.visible is not None:
|
|
1346
|
+
newStatus = "ready" if metadata.visible is True else "hidden"
|
|
1231
1347
|
if newStatus != oldStatus:
|
|
1232
1348
|
sqlUpdates.append(SQL("status = %(status)s"))
|
|
1233
1349
|
sqlParams["status"] = newStatus
|
|
1234
1350
|
|
|
1235
|
-
if heading is not None:
|
|
1351
|
+
if metadata.heading is not None:
|
|
1236
1352
|
sqlUpdates.extend([SQL("heading = %(heading)s"), SQL("heading_computed = false")])
|
|
1237
|
-
sqlParams["heading"] = heading
|
|
1238
|
-
|
|
1239
|
-
if not
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1353
|
+
sqlParams["heading"] = metadata.heading
|
|
1354
|
+
|
|
1355
|
+
if metadata.capture_time is not None:
|
|
1356
|
+
sqlUpdates.extend([SQL("ts = %(capture_time)s")])
|
|
1357
|
+
sqlParams["capture_time"] = metadata.capture_time
|
|
1358
|
+
|
|
1359
|
+
if metadata.longitude is not None and metadata.latitude is not None:
|
|
1360
|
+
sqlUpdates.extend([SQL("geom = ST_SetSRID(ST_MakePoint(%(longitude)s, %(latitude)s), 4326)")])
|
|
1361
|
+
sqlParams["longitude"] = metadata.longitude
|
|
1362
|
+
sqlParams["latitude"] = metadata.latitude
|
|
1363
|
+
|
|
1364
|
+
if metadata.semantics is not None:
|
|
1365
|
+
# semantic tags are managed separately
|
|
1366
|
+
update_tags(cursor, Entity(type=EntityType.pic, id=itemId), metadata.semantics, account=account.id)
|
|
1367
|
+
|
|
1368
|
+
if sqlUpdates:
|
|
1369
|
+
# Note: we set the field `last_account_to_edit` to track who changed the collection last
|
|
1370
|
+
# setting this field will trigger the history tracking of the collection (using postgres trigger)
|
|
1371
|
+
sqlUpdates.append(SQL("last_account_to_edit = %(account)s"))
|
|
1372
|
+
|
|
1373
|
+
cursor.execute(
|
|
1374
|
+
SQL(
|
|
1375
|
+
"""UPDATE pictures
|
|
1376
|
+
SET {updates}
|
|
1377
|
+
WHERE id = %(id)s"""
|
|
1378
|
+
).format(updates=SQL(", ").join(sqlUpdates)),
|
|
1379
|
+
sqlParams,
|
|
1380
|
+
)
|
|
1257
1381
|
|
|
1258
1382
|
# Redirect response to a classic GET
|
|
1259
1383
|
return getCollectionItem(collectionId, itemId)
|