geovisio 2.6.0__py3-none-any.whl → 2.7.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 +36 -7
- geovisio/admin_cli/cleanup.py +2 -2
- geovisio/admin_cli/db.py +1 -4
- geovisio/config_app.py +40 -1
- geovisio/db_migrations.py +24 -3
- geovisio/templates/main.html +13 -13
- geovisio/templates/viewer.html +3 -3
- geovisio/translations/de/LC_MESSAGES/messages.mo +0 -0
- geovisio/translations/de/LC_MESSAGES/messages.po +804 -0
- geovisio/translations/el/LC_MESSAGES/messages.mo +0 -0
- geovisio/translations/el/LC_MESSAGES/messages.po +685 -0
- geovisio/translations/en/LC_MESSAGES/messages.mo +0 -0
- geovisio/translations/en/LC_MESSAGES/messages.po +738 -0
- geovisio/translations/es/LC_MESSAGES/messages.mo +0 -0
- geovisio/translations/es/LC_MESSAGES/messages.po +778 -0
- geovisio/translations/fi/LC_MESSAGES/messages.mo +0 -0
- geovisio/translations/fi/LC_MESSAGES/messages.po +589 -0
- geovisio/translations/fr/LC_MESSAGES/messages.mo +0 -0
- geovisio/translations/fr/LC_MESSAGES/messages.po +814 -0
- geovisio/translations/hu/LC_MESSAGES/messages.mo +0 -0
- geovisio/translations/hu/LC_MESSAGES/messages.po +773 -0
- geovisio/translations/ko/LC_MESSAGES/messages.mo +0 -0
- geovisio/translations/ko/LC_MESSAGES/messages.po +685 -0
- geovisio/translations/messages.pot +694 -0
- geovisio/translations/nl/LC_MESSAGES/messages.mo +0 -0
- geovisio/translations/nl/LC_MESSAGES/messages.po +602 -0
- geovisio/utils/__init__.py +1 -1
- geovisio/utils/auth.py +50 -11
- geovisio/utils/db.py +65 -0
- geovisio/utils/excluded_areas.py +83 -0
- geovisio/utils/extent.py +30 -0
- geovisio/utils/fields.py +1 -1
- geovisio/utils/filesystems.py +0 -1
- geovisio/utils/link.py +14 -0
- geovisio/utils/params.py +20 -0
- geovisio/utils/pictures.py +110 -88
- geovisio/utils/reports.py +171 -0
- geovisio/utils/sequences.py +262 -126
- geovisio/utils/tokens.py +37 -42
- geovisio/utils/upload_set.py +642 -0
- geovisio/web/auth.py +37 -37
- geovisio/web/collections.py +304 -304
- geovisio/web/configuration.py +14 -0
- geovisio/web/docs.py +276 -15
- geovisio/web/excluded_areas.py +377 -0
- geovisio/web/items.py +169 -112
- geovisio/web/map.py +104 -36
- geovisio/web/params.py +69 -26
- geovisio/web/pictures.py +14 -31
- geovisio/web/reports.py +399 -0
- geovisio/web/rss.py +13 -7
- geovisio/web/stac.py +129 -134
- geovisio/web/tokens.py +98 -109
- geovisio/web/upload_set.py +771 -0
- geovisio/web/users.py +100 -73
- geovisio/web/utils.py +28 -9
- geovisio/workers/runner_pictures.py +241 -207
- {geovisio-2.6.0.dist-info → geovisio-2.7.1.dist-info}/METADATA +17 -14
- geovisio-2.7.1.dist-info/RECORD +70 -0
- {geovisio-2.6.0.dist-info → geovisio-2.7.1.dist-info}/WHEEL +1 -1
- geovisio-2.6.0.dist-info/RECORD +0 -41
- {geovisio-2.6.0.dist-info → geovisio-2.7.1.dist-info}/LICENSE +0 -0
geovisio/utils/pictures.py
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import math
|
|
2
2
|
from typing import Dict, Optional
|
|
3
|
+
from uuid import UUID
|
|
3
4
|
from flask import current_app, redirect, send_file
|
|
5
|
+
from flask_babel import gettext as _
|
|
4
6
|
import os
|
|
5
|
-
import psycopg
|
|
6
7
|
from psycopg.rows import dict_row
|
|
7
8
|
import requests
|
|
8
9
|
from PIL import Image
|
|
@@ -11,9 +12,10 @@ import fs.base
|
|
|
11
12
|
import logging
|
|
12
13
|
from dataclasses import asdict
|
|
13
14
|
from fs.path import dirname
|
|
14
|
-
from
|
|
15
|
-
from psycopg.errors import UniqueViolation
|
|
15
|
+
from psycopg.errors import UniqueViolation, InvalidParameterValue
|
|
16
16
|
from geovisio import utils, errors
|
|
17
|
+
from geopic_tag_reader import reader
|
|
18
|
+
import re
|
|
17
19
|
|
|
18
20
|
log = logging.getLogger(__name__)
|
|
19
21
|
|
|
@@ -259,7 +261,7 @@ def generatePictureDerivates(fs, picture, sizing, outputFolder, type="equirectan
|
|
|
259
261
|
return True
|
|
260
262
|
|
|
261
263
|
|
|
262
|
-
def removeAllFiles(picId:
|
|
264
|
+
def removeAllFiles(picId: UUID):
|
|
263
265
|
"""
|
|
264
266
|
Remove all picture's associated files (the picture and all its derivate)
|
|
265
267
|
"""
|
|
@@ -293,10 +295,15 @@ def _remove_empty_parent_dirs(fs: fs.base.FS, dir: str):
|
|
|
293
295
|
def checkFormatParam(format):
|
|
294
296
|
"""Verify that user asks for a valid image format"""
|
|
295
297
|
|
|
296
|
-
valid = ["jpg"
|
|
298
|
+
valid = ["jpg"]
|
|
297
299
|
if format not in valid:
|
|
298
300
|
raise errors.InvalidAPIUsage(
|
|
299
|
-
|
|
301
|
+
_(
|
|
302
|
+
"Invalid '%(format)s' format for image, only the following formats are available: %(allowed_formats)s",
|
|
303
|
+
format=format,
|
|
304
|
+
allowed_formats=", ".join(valid),
|
|
305
|
+
),
|
|
306
|
+
status_code=404,
|
|
300
307
|
)
|
|
301
308
|
|
|
302
309
|
|
|
@@ -308,11 +315,9 @@ def sendInFormat(picture, picFormat, httpFormat):
|
|
|
308
315
|
|
|
309
316
|
if picFormat == httpFormat:
|
|
310
317
|
return send_file(picture, mimetype="image/" + httpFormat)
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
imgio.seek(0)
|
|
315
|
-
return send_file(imgio, mimetype="image/" + httpFormat)
|
|
318
|
+
|
|
319
|
+
# We do not want on the fly conversions
|
|
320
|
+
raise errors.InvalidAPIUsage("Picture is not available in this format", status_code=404)
|
|
316
321
|
|
|
317
322
|
|
|
318
323
|
def getPublicDerivatePictureExternalUrl(pictureId: str, format: str, derivateFileName: str) -> Optional[str]:
|
|
@@ -390,9 +395,9 @@ def checkPictureStatus(fses, pictureId):
|
|
|
390
395
|
account = utils.auth.get_current_account()
|
|
391
396
|
accountId = account.id if account is not None else None
|
|
392
397
|
# Check picture availability + status
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
398
|
+
picMetadata = utils.db.fetchone(
|
|
399
|
+
current_app,
|
|
400
|
+
"""
|
|
396
401
|
SELECT
|
|
397
402
|
p.status,
|
|
398
403
|
(p.metadata->>'cols')::int AS cols,
|
|
@@ -405,41 +410,42 @@ def checkPictureStatus(fses, pictureId):
|
|
|
405
410
|
JOIN sequences s ON s.id = sp.seq_id
|
|
406
411
|
WHERE p.id = %s
|
|
407
412
|
""",
|
|
408
|
-
|
|
409
|
-
|
|
413
|
+
[pictureId],
|
|
414
|
+
row_factory=dict_row,
|
|
415
|
+
)
|
|
410
416
|
|
|
411
|
-
|
|
412
|
-
|
|
417
|
+
if picMetadata is None:
|
|
418
|
+
raise errors.InvalidAPIUsage(_("Picture can't be found, you may check its ID"), status_code=404)
|
|
413
419
|
|
|
414
|
-
|
|
415
|
-
|
|
420
|
+
if (picMetadata["status"] != "ready" or picMetadata["seq_status"] != "ready") and accountId != str(picMetadata["account_id"]):
|
|
421
|
+
raise errors.InvalidAPIUsage(_("Picture is not available (either hidden by admin or processing)"), status_code=403)
|
|
416
422
|
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
423
|
+
if current_app.config.get("PICTURE_PROCESS_DERIVATES_STRATEGY") == "PREPROCESS":
|
|
424
|
+
# if derivates are always generated, not need for other checks
|
|
425
|
+
return picMetadata
|
|
420
426
|
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
427
|
+
# Check original image availability
|
|
428
|
+
if not fses.permanent.exists(utils.pictures.getHDPicturePath(pictureId)):
|
|
429
|
+
raise errors.InvalidAPIUsage(_("HD Picture file is not available"), status_code=500)
|
|
424
430
|
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
431
|
+
# Check derivates availability
|
|
432
|
+
if areDerivatesAvailable(fses.derivates, pictureId, picMetadata["type"]):
|
|
433
|
+
return picMetadata
|
|
434
|
+
else:
|
|
435
|
+
picDerivates = utils.pictures.getPictureFolderPath(pictureId)
|
|
430
436
|
|
|
431
|
-
|
|
432
|
-
|
|
437
|
+
# Try to create derivates folder if it doesn't exist yet
|
|
438
|
+
fses.derivates.makedirs(picDerivates, recreate=True)
|
|
433
439
|
|
|
434
|
-
|
|
440
|
+
picture = Image.open(fses.permanent.openbin(utils.pictures.getHDPicturePath(pictureId)))
|
|
435
441
|
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
442
|
+
# Force generation of derivates
|
|
443
|
+
if utils.pictures.generatePictureDerivates(
|
|
444
|
+
fses.derivates, picture, utils.pictures.getPictureSizing(picture), picDerivates, picMetadata["type"]
|
|
445
|
+
):
|
|
446
|
+
return picMetadata
|
|
447
|
+
else:
|
|
448
|
+
raise errors.InvalidAPIUsage(_("Picture derivates file are not available"), status_code=500)
|
|
443
449
|
|
|
444
450
|
|
|
445
451
|
def sendThumbnail(pictureId, format):
|
|
@@ -456,7 +462,7 @@ def sendThumbnail(pictureId, format):
|
|
|
456
462
|
try:
|
|
457
463
|
picture = fses.derivates.openbin(utils.pictures.getPictureFolderPath(pictureId) + "/thumb.jpg")
|
|
458
464
|
except:
|
|
459
|
-
raise errors.InvalidAPIUsage("Unable to read picture on filesystem", status_code=500)
|
|
465
|
+
raise errors.InvalidAPIUsage(_("Unable to read picture on filesystem"), status_code=500)
|
|
460
466
|
|
|
461
467
|
return sendInFormat(picture, "jpeg", format)
|
|
462
468
|
|
|
@@ -492,13 +498,22 @@ class PicturePositionConflict(Exception):
|
|
|
492
498
|
super().__init__()
|
|
493
499
|
|
|
494
500
|
|
|
495
|
-
class
|
|
501
|
+
class InvalidMetadataValue(Exception):
|
|
496
502
|
def __init__(self, details):
|
|
497
503
|
super().__init__()
|
|
498
504
|
self.details = details
|
|
499
505
|
|
|
500
506
|
|
|
501
|
-
|
|
507
|
+
class MetadataReadingError(Exception):
|
|
508
|
+
def __init__(self, details, missing_mandatory_tags=[]):
|
|
509
|
+
super().__init__()
|
|
510
|
+
self.details = details
|
|
511
|
+
self.missing_mandatory_tags = missing_mandatory_tags
|
|
512
|
+
|
|
513
|
+
|
|
514
|
+
def insertNewPictureInDatabase(
|
|
515
|
+
db, sequenceId, position, pictureBytes, associatedAccountID, additionalMetadata, uploadSetID=None, lang="en"
|
|
516
|
+
):
|
|
502
517
|
"""Inserts a new 'pictures' entry in the database, from a picture file.
|
|
503
518
|
Database is not committed in this function, to make entry definitively stored
|
|
504
519
|
you have to call db.commit() after or use an autocommit connection.
|
|
@@ -526,7 +541,7 @@ def insertNewPictureInDatabase(db, sequenceId, position, pictureBytes, associate
|
|
|
526
541
|
|
|
527
542
|
# Create a fully-featured metadata object
|
|
528
543
|
picturePillow = Image.open(io.BytesIO(pictureBytes))
|
|
529
|
-
metadata = readPictureMetadata(pictureBytes) | utils.pictures.getPictureSizing(picturePillow) |
|
|
544
|
+
metadata = readPictureMetadata(pictureBytes, lang) | utils.pictures.getPictureSizing(picturePillow) | additionalMetadata
|
|
530
545
|
|
|
531
546
|
# Remove cols/rows information for flat pictures
|
|
532
547
|
if metadata["type"] == "flat":
|
|
@@ -534,37 +549,48 @@ def insertNewPictureInDatabase(db, sequenceId, position, pictureBytes, associate
|
|
|
534
549
|
metadata.pop("rows")
|
|
535
550
|
|
|
536
551
|
# Create a lighter metadata field to remove duplicates fields
|
|
537
|
-
lighterMetadata = dict(
|
|
552
|
+
lighterMetadata = dict(
|
|
553
|
+
filter(lambda v: v[0] not in ["ts", "heading", "lon", "lat", "exif", "originalContentMd5", "ts_by_source"], metadata.items())
|
|
554
|
+
)
|
|
538
555
|
if lighterMetadata.get("tagreader_warnings") is not None and len(lighterMetadata["tagreader_warnings"]) == 0:
|
|
539
556
|
del lighterMetadata["tagreader_warnings"]
|
|
540
557
|
lighterMetadata["tz"] = metadata["ts"].tzname()
|
|
558
|
+
if metadata.get("ts_by_source", {}).get("gps") is not None:
|
|
559
|
+
lighterMetadata["ts_gps"] = metadata["ts_by_source"]["gps"].isoformat()
|
|
560
|
+
if metadata.get("ts_by_source", {}).get("camera") is not None:
|
|
561
|
+
lighterMetadata["ts_camera"] = metadata["ts_by_source"]["camera"].isoformat()
|
|
541
562
|
|
|
542
563
|
exif = cleanupExif(metadata["exif"])
|
|
543
564
|
|
|
544
565
|
with db.transaction():
|
|
545
566
|
# Add picture metadata to database
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
567
|
+
try:
|
|
568
|
+
picId = db.execute(
|
|
569
|
+
"""
|
|
570
|
+
INSERT INTO pictures (ts, heading, metadata, geom, account_id, exif, original_content_md5, upload_set_id)
|
|
571
|
+
VALUES (%s, %s, %s, ST_SetSRID(ST_MakePoint(%s, %s), 4326), %s, %s, %s, %s)
|
|
572
|
+
RETURNING id
|
|
573
|
+
""",
|
|
574
|
+
(
|
|
575
|
+
metadata["ts"].isoformat(),
|
|
576
|
+
metadata["heading"],
|
|
577
|
+
Jsonb(lighterMetadata),
|
|
578
|
+
metadata["lon"],
|
|
579
|
+
metadata["lat"],
|
|
580
|
+
associatedAccountID,
|
|
581
|
+
Jsonb(exif),
|
|
582
|
+
metadata.get("originalContentMd5"),
|
|
583
|
+
uploadSetID,
|
|
584
|
+
),
|
|
585
|
+
).fetchone()[0]
|
|
586
|
+
except InvalidParameterValue as e:
|
|
587
|
+
raise InvalidMetadataValue(e.diag.message_primary) from e
|
|
562
588
|
|
|
563
589
|
# Process field of view for each pictures
|
|
564
590
|
# Flat pictures = variable fov
|
|
565
591
|
if metadata["type"] == "flat":
|
|
566
592
|
make, model = metadata.get("make"), metadata.get("model")
|
|
567
|
-
if make is not None and model is not None:
|
|
593
|
+
if make is not None and model is not None and metadata["focal_length"] != 0:
|
|
568
594
|
db.execute("SET pg_trgm.similarity_threshold = 0.9")
|
|
569
595
|
db.execute(
|
|
570
596
|
"""
|
|
@@ -594,11 +620,11 @@ def insertNewPictureInDatabase(db, sequenceId, position, pictureBytes, associate
|
|
|
594
620
|
""",
|
|
595
621
|
[picId],
|
|
596
622
|
)
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
623
|
+
if sequenceId is not None:
|
|
624
|
+
try:
|
|
625
|
+
db.execute("INSERT INTO sequences_pictures(seq_id, rank, pic_id) VALUES(%s, %s, %s)", [sequenceId, position, picId])
|
|
626
|
+
except UniqueViolation as e:
|
|
627
|
+
raise PicturePositionConflict() from e
|
|
602
628
|
|
|
603
629
|
return picId
|
|
604
630
|
|
|
@@ -606,36 +632,26 @@ def insertNewPictureInDatabase(db, sequenceId, position, pictureBytes, associate
|
|
|
606
632
|
# Note: we don't want to store and expose exif binary fields as they are difficult to use and take a lot of storage in the database (~20% for maker notes only)
|
|
607
633
|
# This list has been queried from real data (cf [this comment](https://gitlab.com/panoramax/server/api/-/merge_requests/241#note_1790580636)).
|
|
608
634
|
# Update this list (and do a sql migration) if new binary fields are added
|
|
635
|
+
# Note that tags ending in ".0xXXXX" are automatically striped by a regex
|
|
609
636
|
BLACK_LISTED_BINARY_EXIF_FIELDS = set(
|
|
610
637
|
[
|
|
611
638
|
"Exif.Photo.MakerNote",
|
|
612
|
-
"Exif.Photo.0xea1c",
|
|
613
|
-
"Exif.Image.0xea1c",
|
|
614
639
|
"Exif.Canon.CameraInfo",
|
|
615
640
|
"Exif.Image.PrintImageMatching",
|
|
616
|
-
"Exif.Image.0xc6d3",
|
|
617
641
|
"Exif.Panasonic.FaceDetInfo",
|
|
618
642
|
"Exif.Panasonic.DataDump",
|
|
619
|
-
"Exif.Image.0xc6d2",
|
|
620
643
|
"Exif.Canon.CustomFunctions",
|
|
621
644
|
"Exif.Canon.AFInfo",
|
|
622
|
-
"Exif.Canon.0x4011",
|
|
623
|
-
"Exif.Canon.0x4019",
|
|
624
645
|
"Exif.Canon.ColorData",
|
|
625
646
|
"Exif.Canon.DustRemovalData",
|
|
626
647
|
"Exif.Canon.VignettingCorr",
|
|
627
648
|
"Exif.Canon.AFInfo3",
|
|
628
|
-
"Exif.Canon.0x001f",
|
|
629
|
-
"Exif.Canon.0x0018",
|
|
630
649
|
"Exif.Canon.ContrastInfo",
|
|
631
|
-
"Exif.Canon.0x002e",
|
|
632
|
-
"Exif.Canon.0x0022",
|
|
633
|
-
"Exif.Photo.0x9aaa",
|
|
634
650
|
]
|
|
635
651
|
)
|
|
636
652
|
|
|
637
653
|
|
|
638
|
-
def readPictureMetadata(picture: bytes) -> dict:
|
|
654
|
+
def readPictureMetadata(picture: bytes, lang: Optional[str] = "en") -> dict:
|
|
639
655
|
"""Extracts metadata from picture file
|
|
640
656
|
|
|
641
657
|
Parameters
|
|
@@ -652,15 +668,16 @@ def readPictureMetadata(picture: bytes) -> dict:
|
|
|
652
668
|
"""
|
|
653
669
|
|
|
654
670
|
try:
|
|
655
|
-
metadata = asdict(reader.readPictureMetadata(picture))
|
|
656
|
-
except
|
|
657
|
-
|
|
671
|
+
metadata = asdict(reader.readPictureMetadata(picture, lang))
|
|
672
|
+
except reader.PartialExifException as e:
|
|
673
|
+
tags = [t for t in e.missing_mandatory_tags if t not in ("lon", "lat")]
|
|
674
|
+
if "lon" in e.missing_mandatory_tags or "lat" in e.missing_mandatory_tags:
|
|
675
|
+
tags.append("location") # lat/lon is too much detail for missing metadatas, we replace those by 'location'
|
|
676
|
+
raise MetadataReadingError(details=str(e), missing_mandatory_tags=tags)
|
|
658
677
|
|
|
659
678
|
# Cleanup raw EXIF tags to avoid SQL issues
|
|
660
679
|
cleanedExif = {}
|
|
661
|
-
for k, v in metadata["exif"].items():
|
|
662
|
-
if k in BLACK_LISTED_BINARY_EXIF_FIELDS:
|
|
663
|
-
continue
|
|
680
|
+
for k, v in cleanupExif(metadata["exif"]).items():
|
|
664
681
|
try:
|
|
665
682
|
if isinstance(v, bytes):
|
|
666
683
|
try:
|
|
@@ -680,15 +697,20 @@ def readPictureMetadata(picture: bytes) -> dict:
|
|
|
680
697
|
return metadata
|
|
681
698
|
|
|
682
699
|
|
|
700
|
+
EXIF_KEY_HEX_RGX = r"\.0x[0-9a-fA-F]+$"
|
|
701
|
+
|
|
702
|
+
|
|
683
703
|
def cleanupExif(exif: Optional[Dict[str, str]]) -> Optional[Dict[str, str]]:
|
|
684
|
-
"""Removes binary fields from
|
|
704
|
+
"""Removes binary or undocumented fields from EXIF tags
|
|
685
705
|
>>> cleanupExif({'A': 'B', 'Exif.Canon.AFInfo': 'Blablabla'})
|
|
686
706
|
{'A': 'B'}
|
|
687
707
|
>>> cleanupExif({'A': 'B', 'Exif.Photo.MakerNote': 'Blablabla'})
|
|
688
708
|
{'A': 'B'}
|
|
709
|
+
>>> cleanupExif({'A': 'B', 'Exif.Sony.0x1234': 'Blablabla'})
|
|
710
|
+
{'A': 'B'}
|
|
689
711
|
"""
|
|
690
712
|
|
|
691
713
|
if exif is None:
|
|
692
714
|
return None
|
|
693
715
|
|
|
694
|
-
return {k: v for k, v in exif.items() if k not in BLACK_LISTED_BINARY_EXIF_FIELDS}
|
|
716
|
+
return {k: v for k, v in exif.items() if not re.search(EXIF_KEY_HEX_RGX, k) and k not in BLACK_LISTED_BINARY_EXIF_FIELDS}
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
from enum import Enum
|
|
2
|
+
from uuid import UUID
|
|
3
|
+
from typing import Optional, List
|
|
4
|
+
from typing_extensions import Self
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
from pydantic import BaseModel, ConfigDict
|
|
7
|
+
from geovisio.utils import db
|
|
8
|
+
from geovisio.errors import InvalidAPIUsage
|
|
9
|
+
from flask import current_app
|
|
10
|
+
from psycopg.sql import SQL
|
|
11
|
+
from psycopg.rows import class_row
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class ReportType(Enum):
|
|
15
|
+
blur_missing = "blur_missing"
|
|
16
|
+
blur_excess = "blur_excess"
|
|
17
|
+
inappropriate = "inappropriate"
|
|
18
|
+
privacy = "privacy"
|
|
19
|
+
picture_low_quality = "picture_low_quality"
|
|
20
|
+
mislocated = "mislocated"
|
|
21
|
+
copyright = "copyright"
|
|
22
|
+
other = "other"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class ReportStatus(Enum):
|
|
26
|
+
open = "open"
|
|
27
|
+
open_autofix = "open_autofix"
|
|
28
|
+
waiting = "waiting"
|
|
29
|
+
closed_solved = "closed_solved"
|
|
30
|
+
closed_ignored = "closed_ignored"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class Report(BaseModel):
|
|
34
|
+
"""A Report is a problem reported from a third-party about a picture or a sequence."""
|
|
35
|
+
|
|
36
|
+
id: UUID
|
|
37
|
+
issue: ReportType
|
|
38
|
+
status: ReportStatus
|
|
39
|
+
picture_id: Optional[UUID]
|
|
40
|
+
sequence_id: Optional[UUID]
|
|
41
|
+
ts_opened: datetime
|
|
42
|
+
ts_closed: Optional[datetime]
|
|
43
|
+
reporter_account_id: Optional[UUID]
|
|
44
|
+
reporter_email: Optional[str]
|
|
45
|
+
resolver_account_id: Optional[UUID]
|
|
46
|
+
reporter_comments: Optional[str]
|
|
47
|
+
resolver_comments: Optional[str]
|
|
48
|
+
|
|
49
|
+
model_config = ConfigDict(use_enum_values=True, ser_json_timedelta="float")
|
|
50
|
+
|
|
51
|
+
def for_public(self) -> Self:
|
|
52
|
+
"""Report version for public display (without report email and admin comments)"""
|
|
53
|
+
return Report(
|
|
54
|
+
id=self.id,
|
|
55
|
+
issue=self.issue,
|
|
56
|
+
status=self.status,
|
|
57
|
+
picture_id=self.picture_id,
|
|
58
|
+
sequence_id=self.sequence_id,
|
|
59
|
+
ts_opened=self.ts_opened,
|
|
60
|
+
ts_closed=self.ts_closed,
|
|
61
|
+
reporter_account_id=self.reporter_account_id,
|
|
62
|
+
reporter_email=None,
|
|
63
|
+
resolver_account_id=self.resolver_account_id,
|
|
64
|
+
reporter_comments=self.reporter_comments,
|
|
65
|
+
resolver_comments=None,
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class Reports(BaseModel):
|
|
70
|
+
reports: List[Report]
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def get_report(id: UUID) -> Optional[Report]:
|
|
74
|
+
"""Get the Report corresponding to the ID"""
|
|
75
|
+
db_report = db.fetchone(
|
|
76
|
+
current_app,
|
|
77
|
+
SQL("SELECT * FROM reports WHERE id = %(id)s"),
|
|
78
|
+
{"id": id},
|
|
79
|
+
row_factory=class_row(Report),
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
return db_report
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def is_picture_owner(report: Report, account_id: UUID):
|
|
86
|
+
"""Check if given account is owner of picture concerned by report"""
|
|
87
|
+
|
|
88
|
+
isOwner = False
|
|
89
|
+
if report.picture_id is not None:
|
|
90
|
+
concernedPic = db.fetchone(
|
|
91
|
+
current_app,
|
|
92
|
+
SQL("SELECT id FROM pictures WHERE id = %(id)s AND account_id = %(uid)s"),
|
|
93
|
+
{"id": report.picture_id, "uid": account_id},
|
|
94
|
+
)
|
|
95
|
+
isOwner = concernedPic is not None
|
|
96
|
+
elif report.sequence_id is not None:
|
|
97
|
+
concernedSeq = db.fetchone(
|
|
98
|
+
current_app,
|
|
99
|
+
SQL("SELECT id FROM sequences WHERE id = %(id)s AND account_id = %(uid)s"),
|
|
100
|
+
{"id": report.sequence_id, "uid": account_id},
|
|
101
|
+
)
|
|
102
|
+
isOwner = concernedSeq is not None
|
|
103
|
+
return isOwner
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _parse_filter(filter: Optional[str]) -> SQL:
|
|
107
|
+
"""
|
|
108
|
+
Parse a filter string and return a SQL expression
|
|
109
|
+
|
|
110
|
+
>>> _parse_filter('')
|
|
111
|
+
SQL('TRUE')
|
|
112
|
+
>>> _parse_filter(None)
|
|
113
|
+
SQL('TRUE')
|
|
114
|
+
>>> _parse_filter("status = \'open\'")
|
|
115
|
+
SQL("(r.status = \'open\')")
|
|
116
|
+
>>> _parse_filter("status IN (\'open_autofix\', \'waiting\')")
|
|
117
|
+
SQL("r.status IN (\'open_autofix\', \'waiting\')")
|
|
118
|
+
>>> _parse_filter("reporter = \'me\'")
|
|
119
|
+
SQL('(reporter_account_id = %(account_id)s)')
|
|
120
|
+
>>> _parse_filter("owner = \'me\'")
|
|
121
|
+
SQL('(COALESCE(p.account_id, s.account_id) = %(account_id)s)')
|
|
122
|
+
>>> _parse_filter("status IN (\'open\', \'open_autofix\', \'waiting\') AND (owner = \'me\' OR reporter = \'me\')")
|
|
123
|
+
SQL("(r.status IN (\'open\', \'open_autofix\', \'waiting\') AND ((COALESCE(p.account_id, s.account_id) = %(account_id)s) OR (reporter_account_id = %(account_id)s)))")
|
|
124
|
+
"""
|
|
125
|
+
if not filter:
|
|
126
|
+
return SQL("TRUE")
|
|
127
|
+
from pygeofilter.backends.sql import to_sql_where
|
|
128
|
+
from pygeofilter.parsers.ecql import parse as ecql_parser
|
|
129
|
+
|
|
130
|
+
try:
|
|
131
|
+
filterAst = ecql_parser(filter)
|
|
132
|
+
fieldsToFilter = {
|
|
133
|
+
"status": "r.status",
|
|
134
|
+
"reporter": "reporter_account_id",
|
|
135
|
+
"owner": "COALESCE(p.account_id, s.account_id)",
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
f = to_sql_where(filterAst, fieldsToFilter).replace('"', "").replace("'me'", "%(account_id)s") # type: ignore
|
|
139
|
+
return SQL(f) # type: ignore
|
|
140
|
+
except Exception as e:
|
|
141
|
+
print(e)
|
|
142
|
+
raise InvalidAPIUsage(_("Unsupported filter parameter"), status_code=400)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def list_reports(account_id: UUID, limit: int = 100, filter: Optional[str] = None, forceAccount: bool = True) -> Reports:
|
|
146
|
+
filter_sql = _parse_filter(filter)
|
|
147
|
+
if forceAccount:
|
|
148
|
+
filter_sql = SQL(" ").join(
|
|
149
|
+
[SQL("(COALESCE(p.account_id, s.account_id) = %(account_id)s OR reporter_account_id = %(account_id)s) AND "), filter_sql]
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
l = db.fetchall(
|
|
153
|
+
current_app,
|
|
154
|
+
SQL(
|
|
155
|
+
"""
|
|
156
|
+
SELECT
|
|
157
|
+
r.*,
|
|
158
|
+
COALESCE(p.account_id, s.account_id) AS owner_account_id
|
|
159
|
+
FROM reports r
|
|
160
|
+
LEFT JOIN pictures p ON r.picture_id = p.id
|
|
161
|
+
LEFT JOIN sequences s ON r.sequence_id = s.id
|
|
162
|
+
WHERE {filter}
|
|
163
|
+
ORDER BY ts_opened DESC
|
|
164
|
+
LIMIT %(limit)s
|
|
165
|
+
"""
|
|
166
|
+
).format(filter=filter_sql),
|
|
167
|
+
{"account_id": account_id, "limit": limit},
|
|
168
|
+
row_factory=class_row(Report),
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
return Reports(reports=l)
|