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.
Files changed (62) hide show
  1. geovisio/__init__.py +36 -7
  2. geovisio/admin_cli/cleanup.py +2 -2
  3. geovisio/admin_cli/db.py +1 -4
  4. geovisio/config_app.py +40 -1
  5. geovisio/db_migrations.py +24 -3
  6. geovisio/templates/main.html +13 -13
  7. geovisio/templates/viewer.html +3 -3
  8. geovisio/translations/de/LC_MESSAGES/messages.mo +0 -0
  9. geovisio/translations/de/LC_MESSAGES/messages.po +804 -0
  10. geovisio/translations/el/LC_MESSAGES/messages.mo +0 -0
  11. geovisio/translations/el/LC_MESSAGES/messages.po +685 -0
  12. geovisio/translations/en/LC_MESSAGES/messages.mo +0 -0
  13. geovisio/translations/en/LC_MESSAGES/messages.po +738 -0
  14. geovisio/translations/es/LC_MESSAGES/messages.mo +0 -0
  15. geovisio/translations/es/LC_MESSAGES/messages.po +778 -0
  16. geovisio/translations/fi/LC_MESSAGES/messages.mo +0 -0
  17. geovisio/translations/fi/LC_MESSAGES/messages.po +589 -0
  18. geovisio/translations/fr/LC_MESSAGES/messages.mo +0 -0
  19. geovisio/translations/fr/LC_MESSAGES/messages.po +814 -0
  20. geovisio/translations/hu/LC_MESSAGES/messages.mo +0 -0
  21. geovisio/translations/hu/LC_MESSAGES/messages.po +773 -0
  22. geovisio/translations/ko/LC_MESSAGES/messages.mo +0 -0
  23. geovisio/translations/ko/LC_MESSAGES/messages.po +685 -0
  24. geovisio/translations/messages.pot +694 -0
  25. geovisio/translations/nl/LC_MESSAGES/messages.mo +0 -0
  26. geovisio/translations/nl/LC_MESSAGES/messages.po +602 -0
  27. geovisio/utils/__init__.py +1 -1
  28. geovisio/utils/auth.py +50 -11
  29. geovisio/utils/db.py +65 -0
  30. geovisio/utils/excluded_areas.py +83 -0
  31. geovisio/utils/extent.py +30 -0
  32. geovisio/utils/fields.py +1 -1
  33. geovisio/utils/filesystems.py +0 -1
  34. geovisio/utils/link.py +14 -0
  35. geovisio/utils/params.py +20 -0
  36. geovisio/utils/pictures.py +110 -88
  37. geovisio/utils/reports.py +171 -0
  38. geovisio/utils/sequences.py +262 -126
  39. geovisio/utils/tokens.py +37 -42
  40. geovisio/utils/upload_set.py +642 -0
  41. geovisio/web/auth.py +37 -37
  42. geovisio/web/collections.py +304 -304
  43. geovisio/web/configuration.py +14 -0
  44. geovisio/web/docs.py +276 -15
  45. geovisio/web/excluded_areas.py +377 -0
  46. geovisio/web/items.py +169 -112
  47. geovisio/web/map.py +104 -36
  48. geovisio/web/params.py +69 -26
  49. geovisio/web/pictures.py +14 -31
  50. geovisio/web/reports.py +399 -0
  51. geovisio/web/rss.py +13 -7
  52. geovisio/web/stac.py +129 -134
  53. geovisio/web/tokens.py +98 -109
  54. geovisio/web/upload_set.py +771 -0
  55. geovisio/web/users.py +100 -73
  56. geovisio/web/utils.py +28 -9
  57. geovisio/workers/runner_pictures.py +241 -207
  58. {geovisio-2.6.0.dist-info → geovisio-2.7.1.dist-info}/METADATA +17 -14
  59. geovisio-2.7.1.dist-info/RECORD +70 -0
  60. {geovisio-2.6.0.dist-info → geovisio-2.7.1.dist-info}/WHEEL +1 -1
  61. geovisio-2.6.0.dist-info/RECORD +0 -41
  62. {geovisio-2.6.0.dist-info → geovisio-2.7.1.dist-info}/LICENSE +0 -0
@@ -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 geopic_tag_reader import reader
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: str):
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", "webp"]
298
+ valid = ["jpg"]
297
299
  if format not in valid:
298
300
  raise errors.InvalidAPIUsage(
299
- "Invalid '" + format + "' format for image, only the following formats are available: " + ", ".join(valid), status_code=404
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
- else:
312
- imgio = io.BytesIO()
313
- Image.open(picture).save(imgio, format=httpFormat, quality=90)
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
- with psycopg.connect(current_app.config["DB_URL"], row_factory=dict_row) as db:
394
- picMetadata = db.execute(
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
- [pictureId],
409
- ).fetchone()
413
+ [pictureId],
414
+ row_factory=dict_row,
415
+ )
410
416
 
411
- if picMetadata is None:
412
- raise errors.InvalidAPIUsage("Picture can't be found, you may check its ID", status_code=404)
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
- if (picMetadata["status"] != "ready" or picMetadata["seq_status"] != "ready") and accountId != str(picMetadata["account_id"]):
415
- raise errors.InvalidAPIUsage("Picture is not available (either hidden by admin or processing)", status_code=403)
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
- if current_app.config.get("PICTURE_PROCESS_DERIVATES_STRATEGY") == "PREPROCESS":
418
- # if derivates are always generated, not need for other checks
419
- return picMetadata
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
- # Check original image availability
422
- if not fses.permanent.exists(utils.pictures.getHDPicturePath(pictureId)):
423
- raise errors.InvalidAPIUsage("HD Picture file is not available", status_code=500)
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
- # Check derivates availability
426
- if areDerivatesAvailable(fses.derivates, pictureId, picMetadata["type"]):
427
- return picMetadata
428
- else:
429
- picDerivates = utils.pictures.getPictureFolderPath(pictureId)
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
- # Try to create derivates folder if it doesn't exist yet
432
- fses.derivates.makedirs(picDerivates, recreate=True)
437
+ # Try to create derivates folder if it doesn't exist yet
438
+ fses.derivates.makedirs(picDerivates, recreate=True)
433
439
 
434
- picture = Image.open(fses.permanent.openbin(utils.pictures.getHDPicturePath(pictureId)))
440
+ picture = Image.open(fses.permanent.openbin(utils.pictures.getHDPicturePath(pictureId)))
435
441
 
436
- # Force generation of derivates
437
- if utils.pictures.generatePictureDerivates(
438
- fses.derivates, picture, utils.pictures.getPictureSizing(picture), picDerivates, picMetadata["type"]
439
- ):
440
- return picMetadata
441
- else:
442
- raise errors.InvalidAPIUsage("Picture derivates file are not available", status_code=500)
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 MetadataReadingError(Exception):
501
+ class InvalidMetadataValue(Exception):
496
502
  def __init__(self, details):
497
503
  super().__init__()
498
504
  self.details = details
499
505
 
500
506
 
501
- def insertNewPictureInDatabase(db, sequenceId, position, pictureBytes, associatedAccountID, addtionalMetadata):
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) | addtionalMetadata
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(filter(lambda v: v[0] not in ["ts", "heading", "lon", "lat", "exif"], metadata.items()))
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
- picId = db.execute(
547
- """
548
- INSERT INTO pictures (ts, heading, metadata, geom, account_id, exif)
549
- VALUES (%s, %s, %s, ST_SetSRID(ST_MakePoint(%s, %s), 4326), %s, %s)
550
- RETURNING id
551
- """,
552
- (
553
- metadata["ts"].isoformat(),
554
- metadata["heading"],
555
- Jsonb(lighterMetadata),
556
- metadata["lon"],
557
- metadata["lat"],
558
- associatedAccountID,
559
- Jsonb(exif),
560
- ),
561
- ).fetchone()[0]
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
- try:
599
- db.execute("INSERT INTO sequences_pictures(seq_id, rank, pic_id) VALUES(%s, %s, %s)", [sequenceId, position, picId])
600
- except UniqueViolation as e:
601
- raise PicturePositionConflict() from e
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 Exception as e:
657
- raise MetadataReadingError(details=str(e))
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 exif
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)