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.
Files changed (66) hide show
  1. geovisio/__init__.py +25 -4
  2. geovisio/admin_cli/__init__.py +3 -1
  3. geovisio/admin_cli/user.py +75 -0
  4. geovisio/config_app.py +86 -4
  5. geovisio/templates/main.html +2 -2
  6. geovisio/templates/viewer.html +3 -3
  7. geovisio/translations/br/LC_MESSAGES/messages.mo +0 -0
  8. geovisio/translations/br/LC_MESSAGES/messages.po +762 -0
  9. geovisio/translations/da/LC_MESSAGES/messages.mo +0 -0
  10. geovisio/translations/da/LC_MESSAGES/messages.po +859 -0
  11. geovisio/translations/de/LC_MESSAGES/messages.mo +0 -0
  12. geovisio/translations/de/LC_MESSAGES/messages.po +106 -1
  13. geovisio/translations/el/LC_MESSAGES/messages.mo +0 -0
  14. geovisio/translations/en/LC_MESSAGES/messages.mo +0 -0
  15. geovisio/translations/en/LC_MESSAGES/messages.po +218 -133
  16. geovisio/translations/eo/LC_MESSAGES/messages.mo +0 -0
  17. geovisio/translations/eo/LC_MESSAGES/messages.po +856 -0
  18. geovisio/translations/es/LC_MESSAGES/messages.mo +0 -0
  19. geovisio/translations/es/LC_MESSAGES/messages.po +4 -3
  20. geovisio/translations/fi/LC_MESSAGES/messages.mo +0 -0
  21. geovisio/translations/fr/LC_MESSAGES/messages.mo +0 -0
  22. geovisio/translations/fr/LC_MESSAGES/messages.po +66 -3
  23. geovisio/translations/hu/LC_MESSAGES/messages.mo +0 -0
  24. geovisio/translations/hu/LC_MESSAGES/messages.po +4 -3
  25. geovisio/translations/it/LC_MESSAGES/messages.mo +0 -0
  26. geovisio/translations/it/LC_MESSAGES/messages.po +884 -0
  27. geovisio/translations/ja/LC_MESSAGES/messages.mo +0 -0
  28. geovisio/translations/ja/LC_MESSAGES/messages.po +807 -0
  29. geovisio/translations/ko/LC_MESSAGES/messages.mo +0 -0
  30. geovisio/translations/messages.pot +191 -122
  31. geovisio/translations/nl/LC_MESSAGES/messages.mo +0 -0
  32. geovisio/translations/pl/LC_MESSAGES/messages.mo +0 -0
  33. geovisio/translations/pl/LC_MESSAGES/messages.po +728 -0
  34. geovisio/translations/zh_Hant/LC_MESSAGES/messages.mo +0 -0
  35. geovisio/translations/zh_Hant/LC_MESSAGES/messages.po +719 -0
  36. geovisio/utils/auth.py +80 -8
  37. geovisio/utils/link.py +3 -2
  38. geovisio/utils/loggers.py +14 -0
  39. geovisio/utils/model_query.py +55 -0
  40. geovisio/utils/params.py +7 -4
  41. geovisio/utils/pictures.py +12 -43
  42. geovisio/utils/semantics.py +120 -0
  43. geovisio/utils/sequences.py +10 -1
  44. geovisio/utils/tokens.py +5 -3
  45. geovisio/utils/upload_set.py +71 -22
  46. geovisio/utils/website.py +53 -0
  47. geovisio/web/annotations.py +17 -0
  48. geovisio/web/auth.py +11 -6
  49. geovisio/web/collections.py +217 -61
  50. geovisio/web/configuration.py +17 -1
  51. geovisio/web/docs.py +67 -67
  52. geovisio/web/items.py +220 -96
  53. geovisio/web/map.py +48 -18
  54. geovisio/web/pages.py +240 -0
  55. geovisio/web/params.py +17 -0
  56. geovisio/web/prepare.py +165 -0
  57. geovisio/web/stac.py +17 -4
  58. geovisio/web/tokens.py +14 -4
  59. geovisio/web/upload_set.py +108 -14
  60. geovisio/web/users.py +203 -44
  61. geovisio/workers/runner_pictures.py +61 -22
  62. {geovisio-2.7.1.dist-info → geovisio-2.8.1.dist-info}/METADATA +8 -6
  63. geovisio-2.8.1.dist-info/RECORD +92 -0
  64. {geovisio-2.7.1.dist-info → geovisio-2.8.1.dist-info}/WHEEL +1 -1
  65. geovisio-2.7.1.dist-info/RECORD +0 -70
  66. {geovisio-2.7.1.dist-info → geovisio-2.8.1.dist-info/licenses}/LICENSE +0 -0
@@ -17,6 +17,8 @@ from flask import current_app
17
17
  from flask_babel import gettext as _
18
18
  from geopic_tag_reader import sequence as geopic_sequence, reader
19
19
 
20
+ from geovisio.utils.loggers import getLoggerWithExtra
21
+
20
22
 
21
23
  class AggregatedStatus(BaseModel):
22
24
  """Aggregated status"""
@@ -147,6 +149,7 @@ class UploadSetFile(BaseModel):
147
149
  """File uploaded in an UploadSet"""
148
150
 
149
151
  picture_id: Optional[UUID] = None
152
+ """ID of the picture this file belongs to. Can only be seen by the owner of the File"""
150
153
  file_name: str
151
154
  content_md5: Optional[UUID] = None
152
155
  inserted_at: datetime
@@ -434,6 +437,7 @@ def dispatch(upload_set_id: UUID):
434
437
  if not db_upload_set:
435
438
  raise Exception(f"Upload set {upload_set_id} not found")
436
439
 
440
+ logger = getLoggerWithExtra("geovisio.upload_set", {"upload_set_id": str(upload_set_id)})
437
441
  with db.conn(current_app) as conn:
438
442
  with conn.transaction(), conn.cursor(row_factory=dict_row) as cursor:
439
443
 
@@ -448,16 +452,25 @@ def dispatch(upload_set_id: UUID):
448
452
  p.heading as heading,
449
453
  p.metadata->>'originalFileName' as file_name,
450
454
  p.metadata,
451
- s.id as sequence_id
455
+ s.id as sequence_id,
456
+ f is null as has_no_file
452
457
  FROM pictures p
453
458
  LEFT JOIN sequences_pictures sp ON sp.pic_id = p.id
454
459
  LEFT JOIN sequences s ON s.id = sp.seq_id
460
+ LEFT JOIN files f ON f.picture_id = p.id
455
461
  WHERE p.upload_set_id = %(upload_set_id)s"""
456
462
  ),
457
463
  {"upload_set_id": upload_set_id},
458
464
  ).fetchall()
459
465
 
466
+ # there is currently a bug where 2 pictures can be uploaded for the same file, so only 1 is associated to it.
467
+ # we want to delete one of them
468
+ # Those duplicates happen when a client send an upload that timeouts, but the client retries the upload and the server is not aware of this timeout (the connection is not closed).
469
+ # Note: later, if we are confident the bug has been removed, we might clean this code.
470
+ pics_to_delete_bug = [p["id"] for p in db_pics if p["has_no_file"]]
471
+ db_pics = [p for p in db_pics if p["has_no_file"] is False] # pictures without files will be deleted, we don't need them
460
472
  pics_by_filename = {p["file_name"]: p for p in db_pics}
473
+
461
474
  pics = [
462
475
  geopic_sequence.Picture(
463
476
  p["file_name"],
@@ -483,16 +496,19 @@ WHERE p.upload_set_id = %(upload_set_id)s"""
483
496
  maxDistance=db_upload_set.duplicate_distance, maxRotationAngle=db_upload_set.duplicate_rotation
484
497
  ),
485
498
  sortMethod=db_upload_set.sort_method,
486
- splitParams=geopic_sequence.SplitParams(maxDistance=db_upload_set.split_distance, maxTime=db_upload_set.split_time.seconds),
499
+ splitParams=geopic_sequence.SplitParams(
500
+ maxDistance=db_upload_set.split_distance, maxTime=db_upload_set.split_time.total_seconds()
501
+ ),
487
502
  )
488
503
  reused_sequence = set()
489
504
 
490
- pics_to_delete = [pics_by_filename[p.filename]["id"] for p in report.duplicate_pictures or []]
505
+ pics_to_delete_duplicates = [pics_by_filename[p.filename]["id"] for p in report.duplicate_pictures or []]
506
+ pics_to_delete = pics_to_delete_duplicates + pics_to_delete_bug
491
507
  if pics_to_delete:
492
- logging.debug(f"For uploadset '{upload_set_id}', nb duplicate pictures {len(pics_to_delete)}")
493
- logging.debug(
494
- f"For uploadset '{upload_set_id}', duplicate pictures {[p.filename for p in report.duplicate_pictures or []]}"
508
+ logger.debug(
509
+ f"nb duplicate pictures {len(pics_to_delete_duplicates)} {f' and {len(pics_to_delete_bug)} pictures without files' if pics_to_delete_bug else ''}"
495
510
  )
511
+ logger.debug(f"duplicate pictures {[p.filename for p in report.duplicate_pictures or []]}")
496
512
 
497
513
  cursor.execute(SQL("CREATE TEMPORARY TABLE tmp_duplicates(picture_id UUID) ON COMMIT DROP"))
498
514
  with cursor.copy("COPY tmp_duplicates(picture_id) FROM stdin;") as copy:
@@ -507,16 +523,17 @@ WHERE p.upload_set_id = %(upload_set_id)s"""
507
523
  # delete all pictures (the DB triggers will also add background jobs to delete the associated files)
508
524
  cursor.execute(SQL("DELETE FROM pictures WHERE id IN (select picture_id FROM tmp_duplicates)"))
509
525
 
510
- for s in report.sequences:
526
+ number_title = len(report.sequences) > 1
527
+ existing_sequences = set(p["sequence_id"] for p in db_pics if p["sequence_id"])
528
+ new_sequence_ids = set()
529
+ for i, s in enumerate(report.sequences, start=1):
511
530
  existing_sequence = next(
512
531
  (seq for p in s.pictures if (seq := pics_by_filename[p.filename]["sequence_id"]) not in reused_sequence),
513
532
  None,
514
533
  )
515
534
  # if some of the pictures were already in a sequence, we should not create a new one
516
535
  if existing_sequence:
517
- logging.info(
518
- f"For uploadset '{upload_set_id}', sequence {existing_sequence} already contains pictures, we will not create a new one"
519
- )
536
+ logger.info(f"sequence {existing_sequence} already contains pictures, we will not create a new one")
520
537
  # we should wipe the sequences_pictures though
521
538
  seq_id = existing_sequence
522
539
  cursor.execute(
@@ -525,6 +542,7 @@ WHERE p.upload_set_id = %(upload_set_id)s"""
525
542
  )
526
543
  reused_sequence.add(seq_id)
527
544
  else:
545
+ new_title = f"{db_upload_set.title}{f'-{i}' if number_title else ''}"
528
546
  seq_id = cursor.execute(
529
547
  SQL(
530
548
  """INSERT INTO sequences(account_id, metadata, user_agent)
@@ -533,12 +551,14 @@ RETURNING id"""
533
551
  ),
534
552
  {
535
553
  "account_id": db_upload_set.account_id,
536
- "metadata": Jsonb({"title": db_upload_set.title}),
554
+ "metadata": Jsonb({"title": new_title}),
537
555
  "user_agent": db_upload_set.user_agent,
538
556
  },
539
557
  ).fetchone()
540
558
  seq_id = seq_id["id"]
541
559
 
560
+ new_sequence_ids.add(seq_id)
561
+
542
562
  with cursor.copy("COPY sequences_pictures(seq_id, pic_id, rank) FROM stdin;") as copy:
543
563
  for i, p in enumerate(s.pictures, 1):
544
564
  copy.write_row(
@@ -547,8 +567,17 @@ RETURNING id"""
547
567
 
548
568
  sequences.add_finalization_job(cursor=cursor, seqId=seq_id)
549
569
 
570
+ # we can delete all the old sequences
571
+ sequences_to_delete = existing_sequences - new_sequence_ids
572
+ if sequences_to_delete:
573
+ logger.debug(f"sequences to delete = {sequences_to_delete} (existing = {existing_sequences}, new = {new_sequence_ids})")
574
+ conn.execute(SQL("DELETE FROM sequences_pictures WHERE seq_id = ANY(%(seq_ids)s)"), {"seq_ids": list(sequences_to_delete)})
575
+ conn.execute(
576
+ SQL("UPDATE sequences SET status = 'deleted' WHERE id = ANY(%(seq_ids)s)"), {"seq_ids": list(sequences_to_delete)}
577
+ )
578
+
550
579
  for s in report.sequences_splits or []:
551
- logging.debug(f"For uploadset '{upload_set_id}', split = {s.prevPic.filename} -> {s.nextPic.filename} : {s.reason}")
580
+ logger.debug(f"split = {s.prevPic.filename} -> {s.nextPic.filename} : {s.reason}")
552
581
  conn.execute(SQL("UPDATE upload_sets SET dispatched = true WHERE id = %(upload_set_id)s"), {"upload_set_id": db_upload_set.id})
553
582
 
554
583
 
@@ -567,19 +596,39 @@ def insertFileInDatabase(
567
596
  ) -> UploadSetFile:
568
597
  """Insert a file linked to an UploadSet into the database"""
569
598
 
599
+ # we check if there is already a file with this name in the upload set with an associated picture.
600
+ # If there is no picture (because the picture has been rejected), we accept that the file is overridden
601
+ existing_file = cursor.execute(
602
+ SQL(
603
+ """SELECT picture_id, rejection_status
604
+ FROM files
605
+ WHERE upload_set_id = %(upload_set_id)s AND file_name = %(file_name)s AND picture_id IS NOT NULL"""
606
+ ),
607
+ params={
608
+ "upload_set_id": upload_set_id,
609
+ "file_name": file_name,
610
+ },
611
+ ).fetchone()
612
+ if existing_file:
613
+ raise errors.InvalidAPIUsage(
614
+ _("A different picture with the same name has already been added to this uploadset"),
615
+ status_code=409,
616
+ payload={"existing_item": {"id": existing_file["picture_id"]}},
617
+ )
618
+
570
619
  f = cursor.execute(
571
620
  SQL(
572
621
  """INSERT INTO files(
573
- upload_set_id, picture_id, file_type, file_name,
574
- size, content_md5, rejection_status, rejection_message, rejection_details)
575
- VALUES (
576
- %(upload_set_id)s, %(picture_id)s, %(type)s, %(file_name)s,
577
- %(size)s, %(content_md5)s, %(rejection_status)s, %(rejection_message)s, %(rejection_details)s)
578
- ON CONFLICT (upload_set_id, file_name)
579
- DO UPDATE SET picture_id = %(picture_id)s, size = %(size)s, content_md5 = %(content_md5)s,
580
- rejection_status = %(rejection_status)s, rejection_message = %(rejection_message)s, rejection_details = %(rejection_details)s
581
- RETURNING *
582
- """
622
+ upload_set_id, picture_id, file_type, file_name,
623
+ size, content_md5, rejection_status, rejection_message, rejection_details)
624
+ VALUES (
625
+ %(upload_set_id)s, %(picture_id)s, %(type)s, %(file_name)s,
626
+ %(size)s, %(content_md5)s, %(rejection_status)s, %(rejection_message)s, %(rejection_details)s)
627
+ ON CONFLICT (upload_set_id, file_name)
628
+ DO UPDATE SET picture_id = %(picture_id)s, size = %(size)s, content_md5 = %(content_md5)s,
629
+ rejection_status = %(rejection_status)s, rejection_message = %(rejection_message)s, rejection_details = %(rejection_details)s
630
+ WHERE files.picture_id IS NULL -- check again that we do not override an existing picture
631
+ RETURNING *"""
583
632
  ),
584
633
  params={
585
634
  "upload_set_id": upload_set_id,
@@ -0,0 +1,53 @@
1
+ from typing import Optional, Dict
2
+
3
+ from flask import url_for
4
+
5
+ from geovisio import web
6
+
7
+ WEBSITE_UNDER_SAME_HOST = "same-host"
8
+
9
+ TOKEN_ACCEPTED_PAGE = "token-accepted"
10
+ TOS_VALIDATION_PAGE = "tos-validation"
11
+
12
+
13
+ class Website:
14
+ """Website associated to the API.
15
+ This wrapper will define the routes we expect from the website.
16
+
17
+ We should limit the interraction from the api to the website, but for some flow (especially auth flows), it's can be useful to redirect to website's page
18
+
19
+ If the url is:
20
+ * set to `false`, there is no associated website
21
+ * set to `same-host`, the website is assumed to be on the same host as the API (and will respect the host of the current request)
22
+ * else it should be a valid url
23
+ """
24
+
25
+ def __init__(self, website_url: str):
26
+ if website_url == WEBSITE_UNDER_SAME_HOST:
27
+ self.url = WEBSITE_UNDER_SAME_HOST
28
+ elif website_url == "false":
29
+ self.url = None
30
+ elif website_url.startswith("http"):
31
+ self.url = website_url
32
+ if not self.url.endswith("/"):
33
+ self.url += "/"
34
+ else:
35
+ raise Exception(
36
+ "API_WEBSITE_URL should either be `same-host` (and the website will be assumed to be on the same host), set to `false` if there is no website, or a valid URL"
37
+ )
38
+
39
+ def _to_url(self, route: str, params: Optional[Dict[str, str]] = None):
40
+ if not self.url:
41
+ return None
42
+
43
+ base_url = self.url if self.url != WEBSITE_UNDER_SAME_HOST else url_for("index", _external=True)
44
+
45
+ from urllib.parse import urlencode
46
+
47
+ return f"{base_url}{route}{f'?{urlencode(params)}' if params else ''}"
48
+
49
+ def tos_validation_page(self, params: Optional[Dict[str, str]] = None):
50
+ return self._to_url(TOS_VALIDATION_PAGE, params)
51
+
52
+ def cli_token_accepted_page(self, params: Optional[Dict[str, str]] = None):
53
+ return self._to_url(TOKEN_ACCEPTED_PAGE, params)
@@ -0,0 +1,17 @@
1
+ from geovisio.utils import auth
2
+ from psycopg.rows import dict_row, class_row
3
+ from psycopg.sql import SQL
4
+ from geovisio.utils.semantics import Entity, EntityType, SemanticTagUpdate, update_tags
5
+ from geovisio.web.utils import accountIdOrDefault
6
+ from psycopg.types.json import Jsonb
7
+ from geovisio.utils import db
8
+ from geovisio.utils.params import validation_error
9
+ from geovisio import errors
10
+ from pydantic import BaseModel, ConfigDict, ValidationError
11
+ from uuid import UUID
12
+ from typing import List, Optional
13
+ from flask import Blueprint, request, current_app
14
+ from flask_babel import gettext as _
15
+
16
+
17
+ bp = Blueprint("annotations", __name__, url_prefix="/api")
geovisio/web/auth.py CHANGED
@@ -70,7 +70,7 @@ def auth():
70
70
  oauth_info = utils.auth.oauth_provider.get_user_oauth_info(tokenResponse)
71
71
  with db.cursor(current_app) as cursor:
72
72
  res = cursor.execute(
73
- "INSERT INTO accounts (name, oauth_provider, oauth_id) VALUES (%(name)s, %(provider)s, %(id)s) ON CONFLICT (oauth_provider, oauth_id) DO UPDATE SET name = %(name)s RETURNING id, name",
73
+ "INSERT INTO accounts (name, oauth_provider, oauth_id) VALUES (%(name)s, %(provider)s, %(id)s) ON CONFLICT (oauth_provider, oauth_id) DO UPDATE SET name = %(name)s RETURNING id, name, tos_accepted",
74
74
  {
75
75
  "provider": utils.auth.oauth_provider.name,
76
76
  "id": oauth_info.id,
@@ -79,21 +79,26 @@ def auth():
79
79
  ).fetchone()
80
80
  if res is None:
81
81
  raise Exception("Impossible to insert user in database")
82
- id, name = res
82
+ id, name, tos_accepted = res
83
83
  account = Account(
84
84
  id=str(id), # convert uuid to string for serialization
85
85
  name=name,
86
86
  oauth_provider=utils.auth.oauth_provider.name,
87
87
  oauth_id=oauth_info.id,
88
+ tos_accepted=tos_accepted,
88
89
  )
89
90
  session[ACCOUNT_KEY] = account.model_dump(exclude_none=True)
90
91
  session.permanent = True
91
92
 
92
93
  next_url = session.pop(NEXT_URL_KEY, None)
93
- if next_url:
94
- response = flask.make_response(redirect(next_url))
95
- else:
96
- response = flask.make_response(redirect("/"))
94
+ if not tos_accepted and current_app.config["API_ENFORCE_TOS_ACCEPTANCE"]:
95
+ args = {"next_url": next_url} if next_url else None
96
+ next_url = current_app.config["API_WEBSITE_URL"].tos_validation_page(args)
97
+
98
+ if next_url is None:
99
+ next_url = "/"
100
+
101
+ response = flask.make_response(redirect(next_url))
97
102
 
98
103
  # also store id/name in cookies for the front end to use those
99
104
  max_age = current_app.config["PERMANENT_SESSION_LIFETIME"]