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