geovisio 2.8.1__py3-none-any.whl → 2.9.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 +1 -1
- geovisio/config_app.py +11 -0
- geovisio/translations/fr/LC_MESSAGES/messages.mo +0 -0
- geovisio/translations/fr/LC_MESSAGES/messages.po +12 -5
- geovisio/translations/nl/LC_MESSAGES/messages.mo +0 -0
- geovisio/translations/nl/LC_MESSAGES/messages.po +131 -25
- geovisio/translations/sv/LC_MESSAGES/messages.mo +0 -0
- geovisio/translations/sv/LC_MESSAGES/messages.po +822 -0
- geovisio/utils/annotations.py +186 -0
- geovisio/utils/cql2.py +134 -0
- geovisio/utils/db.py +7 -0
- geovisio/utils/fields.py +24 -7
- geovisio/utils/model_query.py +2 -2
- geovisio/utils/pic_shape.py +63 -0
- geovisio/utils/pictures.py +54 -12
- geovisio/utils/reports.py +10 -17
- geovisio/utils/semantics.py +165 -55
- geovisio/utils/sentry.py +0 -1
- geovisio/utils/sequences.py +141 -60
- geovisio/utils/tags.py +31 -0
- geovisio/utils/upload_set.py +2 -11
- geovisio/web/annotations.py +205 -9
- geovisio/web/collections.py +49 -34
- geovisio/web/configuration.py +2 -1
- geovisio/web/docs.py +52 -2
- geovisio/web/items.py +55 -54
- geovisio/web/map.py +25 -13
- geovisio/web/params.py +11 -21
- geovisio/web/stac.py +19 -12
- geovisio/web/upload_set.py +2 -9
- geovisio/workers/runner_pictures.py +71 -10
- {geovisio-2.8.1.dist-info → geovisio-2.9.0.dist-info}/METADATA +21 -20
- {geovisio-2.8.1.dist-info → geovisio-2.9.0.dist-info}/RECORD +35 -29
- {geovisio-2.8.1.dist-info → geovisio-2.9.0.dist-info}/WHEEL +1 -1
- {geovisio-2.8.1.dist-info → geovisio-2.9.0.dist-info}/licenses/LICENSE +0 -0
geovisio/web/items.py
CHANGED
|
@@ -6,13 +6,16 @@ from typing import Dict, List, Optional, Any
|
|
|
6
6
|
from urllib.parse import unquote
|
|
7
7
|
from psycopg.types.json import Jsonb
|
|
8
8
|
from pydantic import BaseModel, ConfigDict, ValidationError, field_validator, model_validator
|
|
9
|
+
from shapely import intersects
|
|
9
10
|
from werkzeug.datastructures import MultiDict
|
|
10
11
|
from uuid import UUID
|
|
11
12
|
from geovisio import errors, utils
|
|
12
13
|
from geovisio.utils import auth, db
|
|
14
|
+
from geovisio.utils.cql2 import parse_search_filter
|
|
13
15
|
from geovisio.utils.params import validation_error
|
|
14
16
|
from geovisio.utils.pictures import cleanupExif
|
|
15
|
-
from geovisio.utils.semantics import
|
|
17
|
+
from geovisio.utils.semantics import Entity, EntityType, update_tags
|
|
18
|
+
from geovisio.utils.tags import SemanticTagUpdate
|
|
16
19
|
from geovisio.web.params import (
|
|
17
20
|
as_latitude,
|
|
18
21
|
as_longitude,
|
|
@@ -138,6 +141,7 @@ def dbPictureToStacItem(seqId, dbPic):
|
|
|
138
141
|
"pers:roll": dbPic["metadata"].get("roll"),
|
|
139
142
|
"geovisio:status": dbPic.get("status"),
|
|
140
143
|
"geovisio:producer": dbPic["account_name"],
|
|
144
|
+
"geovisio:rank_in_collection": dbPic["rank"],
|
|
141
145
|
"original_file:size": dbPic["metadata"].get("originalFileSize"),
|
|
142
146
|
"original_file:name": dbPic["metadata"].get("originalFileName"),
|
|
143
147
|
"panoramax:horizontal_pixel_density": dbPic.get("h_pixel_density"),
|
|
@@ -145,7 +149,8 @@ def dbPictureToStacItem(seqId, dbPic):
|
|
|
145
149
|
"geovisio:thumbnail": _getThumbJpgPictureURL(dbPic["id"], dbPic.get("status")),
|
|
146
150
|
"exif": removeNoneInDict(cleanupExif(dbPic["exif"])),
|
|
147
151
|
"quality:horizontal_accuracy": float("{:.1f}".format(dbPic["gps_accuracy_m"])) if dbPic.get("gps_accuracy_m") else None,
|
|
148
|
-
"semantics":
|
|
152
|
+
"semantics": [s for s in dbPic.get("semantics") or [] if s],
|
|
153
|
+
"annotations": [a for a in dbPic.get("annotations") or [] if a],
|
|
149
154
|
}
|
|
150
155
|
),
|
|
151
156
|
"links": cleanNoneInList(
|
|
@@ -437,19 +442,12 @@ def getCollectionItems(collectionId):
|
|
|
437
442
|
CASE WHEN LAG(p.status) OVER othpics = 'ready' THEN ST_AsGeoJSON(LAG(p.geom) OVER othpics)::json END AS prevpicgeojson,
|
|
438
443
|
CASE WHEN LEAD(p.status) OVER othpics = 'ready' THEN LEAD(p.id) OVER othpics END AS nextpic,
|
|
439
444
|
CASE WHEN LEAD(p.status) OVER othpics = 'ready' THEN ST_AsGeoJSON(LEAD(p.geom) OVER othpics)::json END AS nextpicgeojson,
|
|
440
|
-
|
|
445
|
+
get_picture_semantics(p.id) as semantics,
|
|
446
|
+
get_picture_annotations(p.id) as annotations
|
|
441
447
|
FROM sequences_pictures sp
|
|
442
448
|
JOIN pictures p ON sp.pic_id = p.id
|
|
443
449
|
JOIN accounts a ON a.id = p.account_id
|
|
444
450
|
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
451
|
WHERE
|
|
454
452
|
{filter}
|
|
455
453
|
WINDOW othpics AS (PARTITION BY sp.seq_id ORDER BY sp.rank)
|
|
@@ -460,15 +458,14 @@ def getCollectionItems(collectionId):
|
|
|
460
458
|
|
|
461
459
|
records = cursor.execute(query, params)
|
|
462
460
|
|
|
463
|
-
bounds: Optional[Bounds] = None
|
|
464
461
|
items = []
|
|
462
|
+
first_rank, last_rank = None, None
|
|
465
463
|
for dbPic in records:
|
|
466
|
-
if
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
bounds.update(dbPic["rank"])
|
|
470
|
-
|
|
464
|
+
if first_rank is None:
|
|
465
|
+
first_rank = dbPic["rank"]
|
|
466
|
+
last_rank = dbPic["rank"]
|
|
471
467
|
items.append(dbPictureToStacItem(collectionId, dbPic))
|
|
468
|
+
bounds = Bounds(first=first_rank, last=last_rank) if records else None
|
|
472
469
|
|
|
473
470
|
links = [
|
|
474
471
|
get_root_link(),
|
|
@@ -491,8 +488,8 @@ def getCollectionItems(collectionId):
|
|
|
491
488
|
]
|
|
492
489
|
|
|
493
490
|
if paginated and items and bounds:
|
|
494
|
-
if bounds.
|
|
495
|
-
has_item_before = bounds.
|
|
491
|
+
if bounds.first:
|
|
492
|
+
has_item_before = bounds.first > seqMeta["min_rank"]
|
|
496
493
|
if has_item_before:
|
|
497
494
|
links.append(
|
|
498
495
|
{
|
|
@@ -504,7 +501,7 @@ def getCollectionItems(collectionId):
|
|
|
504
501
|
# Previous page link
|
|
505
502
|
# - If limit is set, rank is current - limit -1
|
|
506
503
|
# - If no limit is set, rank is 0 (none)
|
|
507
|
-
prevRank = bounds.
|
|
504
|
+
prevRank = bounds.first - limit - 1 if limit is not None else 0
|
|
508
505
|
if prevRank < 1:
|
|
509
506
|
prevRank = None
|
|
510
507
|
links.append(
|
|
@@ -521,7 +518,7 @@ def getCollectionItems(collectionId):
|
|
|
521
518
|
}
|
|
522
519
|
)
|
|
523
520
|
|
|
524
|
-
has_item_after = bounds.
|
|
521
|
+
has_item_after = bounds.last < seqMeta["max_rank"]
|
|
525
522
|
if has_item_after:
|
|
526
523
|
links.append(
|
|
527
524
|
{
|
|
@@ -532,7 +529,7 @@ def getCollectionItems(collectionId):
|
|
|
532
529
|
_external=True,
|
|
533
530
|
collectionId=collectionId,
|
|
534
531
|
limit=limit,
|
|
535
|
-
startAfterRank=bounds.
|
|
532
|
+
startAfterRank=bounds.last,
|
|
536
533
|
),
|
|
537
534
|
}
|
|
538
535
|
)
|
|
@@ -543,10 +540,10 @@ def getCollectionItems(collectionId):
|
|
|
543
540
|
|
|
544
541
|
lastPageRank = startAfterRank
|
|
545
542
|
if limit is not None:
|
|
546
|
-
if seqMeta["max_rank"] > bounds.
|
|
543
|
+
if seqMeta["max_rank"] > bounds.last:
|
|
547
544
|
lastPageRank = seqMeta["max_rank"] - limit
|
|
548
|
-
if lastPageRank < bounds.
|
|
549
|
-
lastPageRank = bounds.
|
|
545
|
+
if lastPageRank < bounds.last:
|
|
546
|
+
lastPageRank = bounds.last
|
|
550
547
|
|
|
551
548
|
links.append(
|
|
552
549
|
{
|
|
@@ -608,19 +605,13 @@ def _getPictureItemById(collectionId, itemId):
|
|
|
608
605
|
p.account_id AS account_id,
|
|
609
606
|
spl.prevpic, spl.prevpicgeojson, spl.nextpic, spl.nextpicgeojson, p.exif,
|
|
610
607
|
relp.related_pics, p.gps_accuracy_m, p.h_pixel_density,
|
|
611
|
-
|
|
608
|
+
|
|
609
|
+
get_picture_semantics(p.id) as semantics,
|
|
610
|
+
get_picture_annotations(p.id) as annotations
|
|
612
611
|
FROM pictures p
|
|
613
612
|
JOIN sequences_pictures sp ON sp.pic_id = p.id
|
|
614
613
|
JOIN accounts ON p.account_id = accounts.id
|
|
615
614
|
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
615
|
LEFT JOIN (
|
|
625
616
|
SELECT
|
|
626
617
|
p.id,
|
|
@@ -735,6 +726,18 @@ def getCollectionItem(collectionId, itemId):
|
|
|
735
726
|
return stacItem, picStatusToHttpCode[stacItem["properties"]["geovisio:status"]], {"Content-Type": "application/geo+json"}
|
|
736
727
|
|
|
737
728
|
|
|
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
|
+
|
|
738
741
|
@bp.route("/search", methods=["GET", "POST"])
|
|
739
742
|
def searchItems():
|
|
740
743
|
"""Search through all available items
|
|
@@ -755,6 +758,7 @@ def searchItems():
|
|
|
755
758
|
- $ref: '#/components/parameters/GeoVisio_place_position'
|
|
756
759
|
- $ref: '#/components/parameters/GeoVisio_place_distance'
|
|
757
760
|
- $ref: '#/components/parameters/GeoVisio_place_fov_tolerance'
|
|
761
|
+
- $ref: '#/components/parameters/searchCQL2_filter'
|
|
758
762
|
post:
|
|
759
763
|
requestBody:
|
|
760
764
|
required: true
|
|
@@ -788,14 +792,14 @@ def searchItems():
|
|
|
788
792
|
args = request.args
|
|
789
793
|
|
|
790
794
|
# Limit
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
795
|
+
limit = args.get("limit") or 10
|
|
796
|
+
try:
|
|
797
|
+
limit = int(limit)
|
|
798
|
+
if limit < 1 or limit > 10000:
|
|
799
|
+
raise ValueError()
|
|
800
|
+
except ValueError:
|
|
801
|
+
raise errors.InvalidAPIUsage(_("Parameter limit must be either empty or a number between 1 and 10000"), status_code=400)
|
|
802
|
+
sqlParams["limit"] = limit
|
|
799
803
|
|
|
800
804
|
# Bounding box
|
|
801
805
|
bboxarg = parse_bbox(args.getlist("bbox"))
|
|
@@ -900,7 +904,7 @@ def searchItems():
|
|
|
900
904
|
raise errors.InvalidAPIUsage(_("Parameter collections should be a JSON array of strings"), status_code=400)
|
|
901
905
|
|
|
902
906
|
# To speed up search, if it's a search by id and on only one id, we use the same code as /collections/:cid/items/:id
|
|
903
|
-
if args.get("ids") is not None
|
|
907
|
+
if args.get("ids") is not None:
|
|
904
908
|
ids = parse_list(args.get("ids"), paramName="ids")
|
|
905
909
|
if ids and len(ids) == 1:
|
|
906
910
|
picture_id = ids[0]
|
|
@@ -917,7 +921,11 @@ def searchItems():
|
|
|
917
921
|
200,
|
|
918
922
|
{"Content-Type": "application/geo+json"},
|
|
919
923
|
)
|
|
920
|
-
|
|
924
|
+
filter_param = args.get("filter")
|
|
925
|
+
if filter_param is not None:
|
|
926
|
+
cql_filter = parse_search_filter(filter_param)
|
|
927
|
+
if cql_filter is not None:
|
|
928
|
+
sqlWhere.append(cql_filter)
|
|
921
929
|
#
|
|
922
930
|
# Database query
|
|
923
931
|
#
|
|
@@ -932,19 +940,12 @@ SELECT * FROM (
|
|
|
932
940
|
accounts.name AS account_name,
|
|
933
941
|
p.account_id AS account_id,
|
|
934
942
|
p.exif, p.gps_accuracy_m, p.h_pixel_density,
|
|
935
|
-
|
|
943
|
+
get_picture_semantics(p.id) as semantics,
|
|
944
|
+
get_picture_annotations(p.id) as annotations
|
|
936
945
|
FROM pictures p
|
|
937
946
|
LEFT JOIN sequences_pictures sp ON p.id = sp.pic_id
|
|
938
947
|
LEFT JOIN sequences s ON s.id = sp.seq_id
|
|
939
948
|
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
|
|
948
949
|
WHERE {sqlWhere}
|
|
949
950
|
{orderBy}
|
|
950
951
|
LIMIT %(limit)s
|
|
@@ -1247,7 +1248,7 @@ def patchCollectionItem(collectionId, itemId, account):
|
|
|
1247
1248
|
---
|
|
1248
1249
|
tags:
|
|
1249
1250
|
- Editing
|
|
1250
|
-
-
|
|
1251
|
+
- Semantics
|
|
1251
1252
|
parameters:
|
|
1252
1253
|
- name: collectionId
|
|
1253
1254
|
in: path
|
geovisio/web/map.py
CHANGED
|
@@ -352,23 +352,35 @@ def _get_query(z: int, x: int, y: int, onlyForUser: Optional[UUID], additional_f
|
|
|
352
352
|
sql.SQL("nb_360_pictures"),
|
|
353
353
|
sql.SQL("nb_pictures - nb_360_pictures AS nb_flat_pictures"),
|
|
354
354
|
sql.SQL(
|
|
355
|
-
"""((CASE WHEN nb_pictures = 0
|
|
356
|
-
|
|
357
|
-
|
|
355
|
+
"""((CASE WHEN nb_pictures = 0
|
|
356
|
+
THEN 0
|
|
357
|
+
WHEN nb_pictures <= (SELECT PERCENTILE_CONT(0.5) WITHIN GROUP(ORDER BY nb_pictures) FILTER (WHERE nb_pictures > 0) FROM pictures_grid)
|
|
358
|
+
THEN
|
|
359
|
+
nb_pictures::float / (SELECT PERCENTILE_CONT(0.5) WITHIN GROUP(ORDER BY nb_pictures) FILTER (WHERE nb_pictures > 0) FROM pictures_grid) * 0.5
|
|
360
|
+
ELSE
|
|
361
|
+
0.5 + nb_pictures::float / (SELECT MAX(nb_pictures) FROM pictures_grid) * 0.5
|
|
358
362
|
END) * 10)::int / 10::float AS coef"""
|
|
359
363
|
),
|
|
360
364
|
sql.SQL(
|
|
361
|
-
"""((CASE WHEN nb_360_pictures = 0
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
+
"""((CASE WHEN nb_360_pictures = 0
|
|
366
|
+
THEN 0
|
|
367
|
+
WHEN (SELECT PERCENTILE_CONT(0.5) WITHIN GROUP(ORDER BY nb_360_pictures) FILTER (WHERE nb_360_pictures > 0) FROM pictures_grid) = 0
|
|
368
|
+
THEN 0
|
|
369
|
+
WHEN nb_360_pictures <= (SELECT PERCENTILE_CONT(0.5) WITHIN GROUP(ORDER BY nb_360_pictures) FILTER (WHERE nb_360_pictures > 0) FROM pictures_grid)
|
|
370
|
+
THEN nb_360_pictures::float / (SELECT PERCENTILE_CONT(0.5) WITHIN GROUP(ORDER BY nb_360_pictures) FILTER (WHERE nb_360_pictures > 0) FROM pictures_grid) * 0.5
|
|
371
|
+
ELSE
|
|
372
|
+
0.5 + nb_360_pictures::float / (SELECT MAX(nb_360_pictures) FROM pictures_grid) * 0.5
|
|
365
373
|
END) * 10)::int / 10::float AS coef_360_pictures"""
|
|
366
374
|
),
|
|
367
375
|
sql.SQL(
|
|
368
|
-
"""((CASE WHEN (nb_pictures - nb_360_pictures) = 0
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
376
|
+
"""((CASE WHEN (nb_pictures - nb_360_pictures) = 0
|
|
377
|
+
THEN 0
|
|
378
|
+
WHEN (SELECT PERCENTILE_CONT(0.5) WITHIN GROUP(ORDER BY (nb_pictures - nb_360_pictures)) FILTER (WHERE (nb_pictures - nb_360_pictures) > 0) FROM pictures_grid) = 0
|
|
379
|
+
THEN 0
|
|
380
|
+
WHEN (nb_pictures - nb_360_pictures) <= (SELECT PERCENTILE_CONT(0.5) WITHIN GROUP(ORDER BY (nb_pictures - nb_360_pictures)) FILTER (WHERE (nb_pictures - nb_360_pictures) > 0) FROM pictures_grid)
|
|
381
|
+
THEN (nb_pictures - nb_360_pictures)::float / (SELECT PERCENTILE_CONT(0.5) WITHIN GROUP(ORDER BY (nb_pictures - nb_360_pictures)) FILTER (WHERE (nb_pictures - nb_360_pictures) > 0) FROM pictures_grid) * 0.5
|
|
382
|
+
ELSE
|
|
383
|
+
0.5 + (nb_pictures - nb_360_pictures)::float / (SELECT MAX((nb_pictures - nb_360_pictures)) FROM pictures_grid) * 0.5
|
|
372
384
|
END) * 10)::int / 10::float AS coef_flat_pictures"""
|
|
373
385
|
),
|
|
374
386
|
]
|
|
@@ -616,7 +628,7 @@ def getUserTile(userId: UUID, z: int, x: int, y: int, format: str):
|
|
|
616
628
|
format: binary
|
|
617
629
|
"""
|
|
618
630
|
|
|
619
|
-
filter = params.
|
|
631
|
+
filter = params.parse_collection_filter(request.args.get("filter"))
|
|
620
632
|
return _getTile(z, x, y, format, onlyForUser=userId, filter=filter)
|
|
621
633
|
|
|
622
634
|
|
|
@@ -688,5 +700,5 @@ def getMyTile(account: Account, z: int, x: int, y: int, format: str):
|
|
|
688
700
|
type: string
|
|
689
701
|
format: binary
|
|
690
702
|
"""
|
|
691
|
-
filter = params.
|
|
703
|
+
filter = params.parse_collection_filter(request.args.get("filter"))
|
|
692
704
|
return _getTile(z, x, y, format, onlyForUser=UUID(account.id), filter=filter)
|
geovisio/web/params.py
CHANGED
|
@@ -8,8 +8,6 @@ import datetime
|
|
|
8
8
|
import re
|
|
9
9
|
from werkzeug.datastructures import MultiDict
|
|
10
10
|
from typing import Optional, Tuple, Any, List
|
|
11
|
-
from pygeofilter.backends.sql import to_sql_where
|
|
12
|
-
from pygeofilter.parsers.ecql import parse as ecql_parser
|
|
13
11
|
from pygeofilter import ast
|
|
14
12
|
from pygeofilter.backends.evaluator import Evaluator, handle
|
|
15
13
|
from psycopg import sql
|
|
@@ -17,6 +15,8 @@ from geovisio.utils.sequences import STAC_FIELD_MAPPINGS, STAC_FIELD_TO_SQL_FILT
|
|
|
17
15
|
from geovisio.utils.fields import SortBy, SQLDirection, SortByField
|
|
18
16
|
from flask_babel import gettext as _
|
|
19
17
|
|
|
18
|
+
from geovisio.utils.cql2 import parse_cql2_filter
|
|
19
|
+
|
|
20
20
|
|
|
21
21
|
RGX_SORTBY = re.compile("[+-]?[A-Za-z_].*(,[+-]?[A-Za-z_].*)*")
|
|
22
22
|
SEQUENCES_DEFAULT_FETCH = 100
|
|
@@ -325,37 +325,27 @@ def parse_list(value: Optional[Any], tryFallbacks: bool = True, paramName: Optio
|
|
|
325
325
|
return None
|
|
326
326
|
|
|
327
327
|
|
|
328
|
-
def
|
|
328
|
+
def parse_collection_filter(value: Optional[str]) -> Optional[sql.SQL]:
|
|
329
329
|
"""Reads STAC filter parameter and sends SQL condition back.
|
|
330
330
|
|
|
331
|
-
>>>
|
|
331
|
+
>>> parse_collection_filter('')
|
|
332
332
|
|
|
333
|
-
>>>
|
|
333
|
+
>>> parse_collection_filter("updated >= '2023-12-31'")
|
|
334
334
|
SQL("(s.updated_at >= '2023-12-31')")
|
|
335
|
-
>>>
|
|
335
|
+
>>> parse_collection_filter("updated >= '2023-12-31' AND created < '2023-10-31'")
|
|
336
336
|
SQL("((s.updated_at >= '2023-12-31') AND (s.inserted_at < '2023-10-31'))")
|
|
337
|
-
>>>
|
|
337
|
+
>>> parse_collection_filter("status IN ('deleted','ready')") # when we ask for deleted, we should also have hidden collections
|
|
338
338
|
SQL("s.status IN ('deleted', 'ready', 'hidden')")
|
|
339
|
-
>>>
|
|
339
|
+
>>> parse_collection_filter("status = 'deleted' OR status = 'ready'")
|
|
340
340
|
SQL("(((s.status = 'deleted') OR (s.status = 'hidden')) OR (s.status = 'ready'))")
|
|
341
|
-
>>>
|
|
341
|
+
>>> parse_collection_filter('invalid = 10') # doctest: +IGNORE_EXCEPTION_DETAIL
|
|
342
342
|
Traceback (most recent call last):
|
|
343
343
|
geovisio.errors.InvalidAPIUsage: Unsupported filter parameter
|
|
344
|
-
>>>
|
|
344
|
+
>>> parse_collection_filter('updated == 10') # doctest: +IGNORE_EXCEPTION_DETAIL
|
|
345
345
|
Traceback (most recent call last):
|
|
346
346
|
geovisio.errors.InvalidAPIUsage: Unsupported filter parameter
|
|
347
347
|
"""
|
|
348
|
-
|
|
349
|
-
try:
|
|
350
|
-
filterAst = ecql_parser(value)
|
|
351
|
-
altered_ast = _alterFilterAst(filterAst) # type: ignore
|
|
352
|
-
|
|
353
|
-
f = to_sql_where(altered_ast, STAC_FIELD_TO_SQL_FILTER).replace('"', "")
|
|
354
|
-
return sql.SQL(f) # type: ignore
|
|
355
|
-
except:
|
|
356
|
-
raise errors.InvalidAPIUsage(_("Unsupported filter parameter"), status_code=400)
|
|
357
|
-
else:
|
|
358
|
-
return None
|
|
348
|
+
return parse_cql2_filter(value, STAC_FIELD_TO_SQL_FILTER, ast_updater=_alterFilterAst)
|
|
359
349
|
|
|
360
350
|
|
|
361
351
|
def parse_picture_heading(heading: Optional[str]) -> Optional[int]:
|
geovisio/web/stac.py
CHANGED
|
@@ -20,10 +20,11 @@ from geovisio.utils.sequences import (
|
|
|
20
20
|
get_collections,
|
|
21
21
|
CollectionsRequest,
|
|
22
22
|
STAC_FIELD_MAPPINGS,
|
|
23
|
+
get_dataset_bounds,
|
|
23
24
|
get_pagination_links,
|
|
24
25
|
)
|
|
25
26
|
from geovisio.web.params import (
|
|
26
|
-
|
|
27
|
+
parse_collection_filter,
|
|
27
28
|
parse_collections_limit,
|
|
28
29
|
)
|
|
29
30
|
|
|
@@ -334,12 +335,17 @@ def getUserCatalog(userId, userIdMatchesAccount=False):
|
|
|
334
335
|
"""
|
|
335
336
|
|
|
336
337
|
collection_request = CollectionsRequest(
|
|
337
|
-
sort_by=SortBy(
|
|
338
|
+
sort_by=SortBy(
|
|
339
|
+
fields=[
|
|
340
|
+
SortByField(field=STAC_FIELD_MAPPINGS["created"], direction=SQLDirection.ASC),
|
|
341
|
+
SortByField(field=STAC_FIELD_MAPPINGS["id"], direction=SQLDirection.ASC),
|
|
342
|
+
]
|
|
343
|
+
),
|
|
338
344
|
user_id=userId,
|
|
339
345
|
userOwnsAllCollections=userIdMatchesAccount,
|
|
340
346
|
)
|
|
341
347
|
collection_request.limit = parse_collections_limit(request.args.get("limit"))
|
|
342
|
-
collection_request.pagination_filter =
|
|
348
|
+
collection_request.pagination_filter = parse_collection_filter(request.args.get("page"))
|
|
343
349
|
|
|
344
350
|
userName = None
|
|
345
351
|
meta_collection = None
|
|
@@ -350,12 +356,14 @@ def getUserCatalog(userId, userIdMatchesAccount=False):
|
|
|
350
356
|
raise errors.InvalidAPIUsage(_("Impossible to find user %(u)s", u=userId))
|
|
351
357
|
userName = userName["name"]
|
|
352
358
|
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
359
|
+
datasetBounds = get_dataset_bounds(
|
|
360
|
+
cursor.connection,
|
|
361
|
+
collection_request.sort_by,
|
|
362
|
+
additional_filters=SQL("s.account_id = %(account)s"),
|
|
363
|
+
additional_filters_params={"account": userId},
|
|
364
|
+
)
|
|
357
365
|
|
|
358
|
-
if
|
|
366
|
+
if datasetBounds is None:
|
|
359
367
|
# No data found, trying to give the most meaningfull error message
|
|
360
368
|
raise errors.InvalidAPIUsage(_("No data loaded for user %(u)s", u=userId), 404)
|
|
361
369
|
|
|
@@ -388,10 +396,9 @@ def getUserCatalog(userId, userIdMatchesAccount=False):
|
|
|
388
396
|
pagination_links = get_pagination_links(
|
|
389
397
|
route="stac.getUserCatalog",
|
|
390
398
|
routeArgs={"userId": str(userId), "limit": collection_request.limit},
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
dataBounds=db_collections.query_first_order_bounds,
|
|
399
|
+
sortBy=collection_request.sort_by,
|
|
400
|
+
datasetBounds=datasetBounds,
|
|
401
|
+
dataBounds=db_collections.query_bounds,
|
|
395
402
|
additional_filters=None,
|
|
396
403
|
)
|
|
397
404
|
|
geovisio/web/upload_set.py
CHANGED
|
@@ -98,7 +98,7 @@ def create_upload_set(params: UploadSetCreationParameter, accountId: UUID) -> Up
|
|
|
98
98
|
)
|
|
99
99
|
|
|
100
100
|
if db_upload_set is None:
|
|
101
|
-
raise Exception("Impossible to insert
|
|
101
|
+
raise Exception("Impossible to insert upload_set in database")
|
|
102
102
|
|
|
103
103
|
return db_upload_set
|
|
104
104
|
|
|
@@ -106,13 +106,6 @@ def create_upload_set(params: UploadSetCreationParameter, accountId: UUID) -> Up
|
|
|
106
106
|
def update_upload_set(upload_set_id: UUID, params: UploadSetUpdateParameter) -> UploadSet:
|
|
107
107
|
db_params = model_query.get_db_params_and_values(params)
|
|
108
108
|
|
|
109
|
-
with db.conn(current_app) as conn, conn.transaction():
|
|
110
|
-
import psycopg
|
|
111
|
-
|
|
112
|
-
cur = psycopg.ClientCursor(conn)
|
|
113
|
-
q = SQL("UPDATE upload_sets SET {fields} WHERE id = %(upload_set_id)s").format(fields=db_params.fields_for_set())
|
|
114
|
-
print(cur.mogrify(q, db_params.params_as_dict | {"upload_set_id": upload_set_id}))
|
|
115
|
-
|
|
116
109
|
with db.execute(
|
|
117
110
|
current_app,
|
|
118
111
|
SQL("UPDATE upload_sets SET {fields} WHERE id = %(upload_set_id)s").format(fields=db_params.fields_for_set()),
|
|
@@ -201,7 +194,7 @@ def patchUploadSet(upload_set_id, account=None):
|
|
|
201
194
|
content:
|
|
202
195
|
application/json:
|
|
203
196
|
schema:
|
|
204
|
-
$ref: '#/components/schemas/
|
|
197
|
+
$ref: '#/components/schemas/UploadSetUpdateParameter'
|
|
205
198
|
security:
|
|
206
199
|
- bearerToken: []
|
|
207
200
|
- cookieAuth: []
|
|
@@ -2,7 +2,7 @@ from fs.path import dirname
|
|
|
2
2
|
from PIL import Image, ImageOps
|
|
3
3
|
from flask import current_app
|
|
4
4
|
from geovisio import utils
|
|
5
|
-
from geovisio.utils import db, sequences, upload_set
|
|
5
|
+
from geovisio.utils import db, semantics, sequences, upload_set
|
|
6
6
|
import psycopg
|
|
7
7
|
from psycopg.rows import dict_row
|
|
8
8
|
from psycopg.sql import SQL
|
|
@@ -22,8 +22,6 @@ import geovisio.utils.filesystems
|
|
|
22
22
|
|
|
23
23
|
log = logging.getLogger("geovisio.runner_pictures")
|
|
24
24
|
|
|
25
|
-
PROCESS_MAX_RETRY = 5 # Number of times a job will be retryed if there is a `RecoverableProcessException` during process (like if the blurring api is not reachable).
|
|
26
|
-
|
|
27
25
|
|
|
28
26
|
class PictureBackgroundProcessor(object):
|
|
29
27
|
def __init__(self, app):
|
|
@@ -50,6 +48,10 @@ class PictureBackgroundProcessor(object):
|
|
|
50
48
|
worker = PictureProcessor(app=current_app)
|
|
51
49
|
return self.executor.submit(worker.process_jobs)
|
|
52
50
|
|
|
51
|
+
def stop(self):
|
|
52
|
+
if self.enabled:
|
|
53
|
+
self.executor.shutdown(cancel_futures=True, wait=True)
|
|
54
|
+
|
|
53
55
|
|
|
54
56
|
class ProcessTask(str, Enum):
|
|
55
57
|
prepare = "prepare"
|
|
@@ -103,6 +105,55 @@ class DbJob:
|
|
|
103
105
|
return f"{self.task} for {impacted_object}"
|
|
104
106
|
|
|
105
107
|
|
|
108
|
+
def store_detection_semantics(pic: DbPicture, metadata: Dict[str, Any], store_id: bool):
|
|
109
|
+
"""store the detection returned by the blurring API in the database.
|
|
110
|
+
|
|
111
|
+
The semantics part is stored as annotations, linked to the default account.
|
|
112
|
+
|
|
113
|
+
The blurring id, which could be used to unblur the picture later, is stored in a separate column?
|
|
114
|
+
|
|
115
|
+
Note that all old semantics tags are removed, and to know this, we check the `service_name` field returned by the blurring API, and the special qualifier tag
|
|
116
|
+
`detection_model` that is formated like a user-agent.
|
|
117
|
+
So we delete all old tags (and related qualifiers) o
|
|
118
|
+
"""
|
|
119
|
+
from geovisio.utils import annotations
|
|
120
|
+
|
|
121
|
+
tags = metadata.pop("annotations", [])
|
|
122
|
+
|
|
123
|
+
with db.conn(current_app) as conn, conn.cursor() as cursor:
|
|
124
|
+
blurring_id = metadata.get("blurring_id")
|
|
125
|
+
if blurring_id and store_id:
|
|
126
|
+
# we store the blurring id to be able to unblur the picture later
|
|
127
|
+
cursor.execute(
|
|
128
|
+
"UPDATE pictures SET blurring_id = %(blurring_id)s WHERE id = %(id)s",
|
|
129
|
+
{"blurring_id": blurring_id, "id": pic.id},
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
if not tags:
|
|
133
|
+
return
|
|
134
|
+
|
|
135
|
+
default_account_id = cursor.execute("SELECT id from accounts where is_default = true").fetchone()
|
|
136
|
+
if not default_account_id:
|
|
137
|
+
log.error("Impossible to find a default account, cannot add semantics from blurring api")
|
|
138
|
+
default_account_id = default_account_id[0]
|
|
139
|
+
|
|
140
|
+
# we want to remove all the tags added by the same bluring api previously
|
|
141
|
+
# it's especially usefull when a picture is blurred multiple times
|
|
142
|
+
# and if the detection model has been updated between the blurrings
|
|
143
|
+
semantics.delete_annotation_tags_from_service(conn, pic.id, service_name="SGBlur", account=default_account_id)
|
|
144
|
+
try:
|
|
145
|
+
annotations_to_create = [
|
|
146
|
+
annotations.AnnotationCreationParameter(**t, account_id=default_account_id, picture_id=pic.id) for t in tags
|
|
147
|
+
]
|
|
148
|
+
except Exception as e:
|
|
149
|
+
# if the detections are not in the correct format, we skip them
|
|
150
|
+
msg = errors.getMessageFromException(e)
|
|
151
|
+
log.error(f"impossible to save blurring detections, skipping it for picture {pic.id}: {msg}")
|
|
152
|
+
return
|
|
153
|
+
for a in annotations_to_create:
|
|
154
|
+
annotations.creation_annotation(a)
|
|
155
|
+
|
|
156
|
+
|
|
106
157
|
def processPictureFiles(pic: DbPicture, config):
|
|
107
158
|
"""Generates the files associated with a sequence picture.
|
|
108
159
|
|
|
@@ -140,15 +191,23 @@ def processPictureFiles(pic: DbPicture, config):
|
|
|
140
191
|
if not skipBlur:
|
|
141
192
|
with sentry_sdk.start_span(description="Blurring picture"):
|
|
142
193
|
try:
|
|
143
|
-
|
|
194
|
+
res = utils.pictures.createBlurredHDPicture(
|
|
144
195
|
fses.permanent,
|
|
145
196
|
config.get("API_BLUR_URL"),
|
|
146
197
|
pictureBytes,
|
|
147
198
|
picHdPath,
|
|
199
|
+
keep_unblured_parts=config["PICTURE_PROCESS_KEEP_UNBLURRED_PARTS"],
|
|
148
200
|
)
|
|
149
201
|
except Exception as e:
|
|
150
|
-
|
|
151
|
-
|
|
202
|
+
msg = errors.getMessageFromException(e)
|
|
203
|
+
log.error(f"impossible to blur picture {pic.id}: {msg}")
|
|
204
|
+
raise RecoverableProcessException("Blur API failure: " + msg) from e
|
|
205
|
+
if res is None:
|
|
206
|
+
picture = None
|
|
207
|
+
else:
|
|
208
|
+
picture = res.image
|
|
209
|
+
if res.metadata:
|
|
210
|
+
store_detection_semantics(pic, res.metadata, store_id=config["PICTURE_PROCESS_KEEP_UNBLURRED_PARTS"])
|
|
152
211
|
|
|
153
212
|
# Delete original unblurred file
|
|
154
213
|
geovisio.utils.filesystems.removeFsEvenNotFound(fses.tmp, picHdPath)
|
|
@@ -383,12 +442,13 @@ def _get_next_job(app):
|
|
|
383
442
|
_finalize_job(locking_transaction, job)
|
|
384
443
|
log.debug(f"Job {job.label()} processed")
|
|
385
444
|
except RecoverableProcessException as e:
|
|
386
|
-
_mark_process_as_error(locking_transaction, job, e, recoverable=True)
|
|
445
|
+
_mark_process_as_error(locking_transaction, job, e, config=app.config, recoverable=True)
|
|
387
446
|
except RetryLaterProcessException as e:
|
|
388
447
|
_mark_process_as_error(
|
|
389
448
|
locking_transaction,
|
|
390
449
|
job,
|
|
391
450
|
e,
|
|
451
|
+
config=app.config,
|
|
392
452
|
recoverable=True,
|
|
393
453
|
mark_as_error=False,
|
|
394
454
|
)
|
|
@@ -396,11 +456,11 @@ def _get_next_job(app):
|
|
|
396
456
|
log.error(f"Interruption received, stoping job {job.label()}")
|
|
397
457
|
# starts a new connection, since the current one can be corrupted by the exception
|
|
398
458
|
with app.pool.connection() as t:
|
|
399
|
-
_mark_process_as_error(t, job, interruption, recoverable=True)
|
|
459
|
+
_mark_process_as_error(t, job, interruption, config=app.config, recoverable=True)
|
|
400
460
|
error = interruption
|
|
401
461
|
except Exception as e:
|
|
402
462
|
log.exception(f"Impossible to finish job {job.label()}")
|
|
403
|
-
_mark_process_as_error(locking_transaction, job, e, recoverable=False)
|
|
463
|
+
_mark_process_as_error(locking_transaction, job, e, config=app.config, recoverable=False)
|
|
404
464
|
|
|
405
465
|
# try to finalize the sequence anyway
|
|
406
466
|
_finalize_sequence(job)
|
|
@@ -506,6 +566,7 @@ def _mark_process_as_error(
|
|
|
506
566
|
conn,
|
|
507
567
|
job: DbJob,
|
|
508
568
|
e: Exception,
|
|
569
|
+
config: Dict,
|
|
509
570
|
recoverable: bool = False,
|
|
510
571
|
mark_as_error: bool = True,
|
|
511
572
|
):
|
|
@@ -524,7 +585,7 @@ def _mark_process_as_error(
|
|
|
524
585
|
RETURNING nb_errors""",
|
|
525
586
|
{"err": str(e), "id": job.job_queue_id},
|
|
526
587
|
).fetchone()
|
|
527
|
-
if nb_error and nb_error[0] >
|
|
588
|
+
if nb_error and nb_error[0] > config["PICTURE_PROCESS_NB_RETRIES"]:
|
|
528
589
|
logging.info(f"Job {job.label()} has failed {nb_error} times, we stop trying to process it.")
|
|
529
590
|
recoverable = False
|
|
530
591
|
else:
|