umap-project 2.3.0__py3-none-any.whl → 2.4.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of umap-project might be problematic. Click here for more details.

Files changed (211) hide show
  1. umap/__init__.py +1 -1
  2. umap/locale/en/LC_MESSAGES/django.po +81 -31
  3. umap/locale/fr/LC_MESSAGES/django.mo +0 -0
  4. umap/locale/fr/LC_MESSAGES/django.po +117 -66
  5. umap/locale/pl/LC_MESSAGES/django.mo +0 -0
  6. umap/locale/pl/LC_MESSAGES/django.po +83 -78
  7. umap/locale/pt/LC_MESSAGES/django.mo +0 -0
  8. umap/locale/pt/LC_MESSAGES/django.po +129 -123
  9. umap/management/commands/run_websocket_server.py +23 -0
  10. umap/models.py +6 -1
  11. umap/settings/base.py +11 -3
  12. umap/static/umap/base.css +68 -186
  13. umap/static/umap/content.css +3 -2
  14. umap/static/umap/css/dialog.css +18 -0
  15. umap/static/umap/css/icon.css +8 -0
  16. umap/static/umap/css/importers.css +51 -0
  17. umap/static/umap/css/panel.css +18 -57
  18. umap/static/umap/css/tooltip.css +59 -0
  19. umap/static/umap/css/window.css +35 -0
  20. umap/static/umap/img/16-white.svg +1 -3
  21. umap/static/umap/img/alert-icon-error.svg +8 -0
  22. umap/static/umap/img/alert-icon-info.svg +4 -0
  23. umap/static/umap/img/alert-icon-success.svg +3 -0
  24. umap/static/umap/img/icon-external-link.svg +3 -0
  25. umap/static/umap/img/importers/communesfr.svg +5 -0
  26. umap/static/umap/img/importers/datasets.svg +13 -0
  27. umap/static/umap/img/importers/geodatamine.svg +10 -0
  28. umap/static/umap/img/importers/overpass.svg +7 -0
  29. umap/static/umap/img/importers/random.svg +18 -0
  30. umap/static/umap/img/importers/random1.svg +4 -0
  31. umap/static/umap/img/importers/random2.svg +4 -0
  32. umap/static/umap/img/source/16-white.svg +2 -4
  33. umap/static/umap/js/components/alerts/alert.css +160 -0
  34. umap/static/umap/js/components/alerts/alert.js +169 -0
  35. umap/static/umap/js/components/base.js +54 -0
  36. umap/static/umap/js/modules/autocomplete.js +347 -0
  37. umap/static/umap/js/modules/browser.js +14 -21
  38. umap/static/umap/js/modules/caption.js +119 -0
  39. umap/static/umap/js/modules/global.js +37 -11
  40. umap/static/umap/js/modules/help.js +255 -0
  41. umap/static/umap/js/modules/importer.js +308 -0
  42. umap/static/umap/js/modules/importers/communesfr.js +44 -0
  43. umap/static/umap/js/modules/importers/datasets.js +42 -0
  44. umap/static/umap/js/modules/importers/geodatamine.js +95 -0
  45. umap/static/umap/js/modules/importers/overpass.js +84 -0
  46. umap/static/umap/js/modules/request.js +12 -14
  47. umap/static/umap/js/modules/rules.js +241 -0
  48. umap/static/umap/js/modules/schema.js +63 -14
  49. umap/static/umap/js/modules/sync/engine.js +93 -0
  50. umap/static/umap/js/modules/sync/updaters.js +109 -0
  51. umap/static/umap/js/modules/sync/websocket.js +25 -0
  52. umap/static/umap/js/modules/ui/dialog.js +52 -0
  53. umap/static/umap/js/modules/{panel.js → ui/panel.js} +37 -20
  54. umap/static/umap/js/modules/ui/tooltip.js +116 -0
  55. umap/static/umap/js/modules/utils.js +25 -18
  56. umap/static/umap/js/umap.controls.js +37 -112
  57. umap/static/umap/js/umap.core.js +1 -327
  58. umap/static/umap/js/umap.features.js +77 -29
  59. umap/static/umap/js/umap.forms.js +17 -19
  60. umap/static/umap/js/umap.js +265 -228
  61. umap/static/umap/js/umap.layer.js +154 -76
  62. umap/static/umap/js/umap.permissions.js +5 -9
  63. umap/static/umap/js/umap.popup.js +2 -1
  64. umap/static/umap/js/umap.tableeditor.js +8 -8
  65. umap/static/umap/locale/am_ET.js +51 -16
  66. umap/static/umap/locale/am_ET.json +51 -16
  67. umap/static/umap/locale/ar.js +51 -16
  68. umap/static/umap/locale/ar.json +51 -16
  69. umap/static/umap/locale/ast.js +51 -16
  70. umap/static/umap/locale/ast.json +51 -16
  71. umap/static/umap/locale/bg.js +51 -16
  72. umap/static/umap/locale/bg.json +51 -16
  73. umap/static/umap/locale/br.js +55 -20
  74. umap/static/umap/locale/br.json +55 -20
  75. umap/static/umap/locale/ca.js +51 -16
  76. umap/static/umap/locale/ca.json +51 -16
  77. umap/static/umap/locale/cs_CZ.js +93 -58
  78. umap/static/umap/locale/cs_CZ.json +93 -58
  79. umap/static/umap/locale/da.js +51 -16
  80. umap/static/umap/locale/da.json +51 -16
  81. umap/static/umap/locale/de.js +56 -21
  82. umap/static/umap/locale/de.json +56 -21
  83. umap/static/umap/locale/el.js +51 -16
  84. umap/static/umap/locale/el.json +51 -16
  85. umap/static/umap/locale/en.js +52 -16
  86. umap/static/umap/locale/en.json +52 -16
  87. umap/static/umap/locale/en_US.json +51 -16
  88. umap/static/umap/locale/es.js +51 -16
  89. umap/static/umap/locale/es.json +51 -16
  90. umap/static/umap/locale/et.js +51 -16
  91. umap/static/umap/locale/et.json +51 -16
  92. umap/static/umap/locale/eu.js +51 -16
  93. umap/static/umap/locale/eu.json +51 -16
  94. umap/static/umap/locale/fa_IR.js +51 -16
  95. umap/static/umap/locale/fa_IR.json +51 -16
  96. umap/static/umap/locale/fi.js +51 -16
  97. umap/static/umap/locale/fi.json +51 -16
  98. umap/static/umap/locale/fr.js +61 -25
  99. umap/static/umap/locale/fr.json +61 -25
  100. umap/static/umap/locale/gl.js +51 -16
  101. umap/static/umap/locale/gl.json +51 -16
  102. umap/static/umap/locale/he.js +51 -16
  103. umap/static/umap/locale/he.json +51 -16
  104. umap/static/umap/locale/hr.js +51 -16
  105. umap/static/umap/locale/hr.json +51 -16
  106. umap/static/umap/locale/hu.js +51 -16
  107. umap/static/umap/locale/hu.json +51 -16
  108. umap/static/umap/locale/id.js +51 -16
  109. umap/static/umap/locale/id.json +51 -16
  110. umap/static/umap/locale/is.js +51 -16
  111. umap/static/umap/locale/is.json +51 -16
  112. umap/static/umap/locale/it.js +51 -16
  113. umap/static/umap/locale/it.json +51 -16
  114. umap/static/umap/locale/ja.js +51 -16
  115. umap/static/umap/locale/ja.json +51 -16
  116. umap/static/umap/locale/ko.js +51 -16
  117. umap/static/umap/locale/ko.json +51 -16
  118. umap/static/umap/locale/lt.js +51 -16
  119. umap/static/umap/locale/lt.json +51 -16
  120. umap/static/umap/locale/ms.js +51 -16
  121. umap/static/umap/locale/ms.json +51 -16
  122. umap/static/umap/locale/nl.js +51 -16
  123. umap/static/umap/locale/nl.json +51 -16
  124. umap/static/umap/locale/no.js +51 -16
  125. umap/static/umap/locale/no.json +51 -16
  126. umap/static/umap/locale/pl.js +93 -58
  127. umap/static/umap/locale/pl.json +93 -58
  128. umap/static/umap/locale/pl_PL.json +51 -16
  129. umap/static/umap/locale/pt.js +215 -180
  130. umap/static/umap/locale/pt.json +215 -180
  131. umap/static/umap/locale/pt_BR.js +51 -16
  132. umap/static/umap/locale/pt_BR.json +51 -16
  133. umap/static/umap/locale/pt_PT.js +51 -16
  134. umap/static/umap/locale/pt_PT.json +51 -16
  135. umap/static/umap/locale/ro.js +51 -16
  136. umap/static/umap/locale/ro.json +51 -16
  137. umap/static/umap/locale/ru.js +51 -16
  138. umap/static/umap/locale/ru.json +51 -16
  139. umap/static/umap/locale/si.js +51 -16
  140. umap/static/umap/locale/si.json +51 -16
  141. umap/static/umap/locale/sk_SK.js +51 -16
  142. umap/static/umap/locale/sk_SK.json +51 -16
  143. umap/static/umap/locale/sl.js +51 -16
  144. umap/static/umap/locale/sl.json +51 -16
  145. umap/static/umap/locale/sr.js +51 -16
  146. umap/static/umap/locale/sr.json +51 -16
  147. umap/static/umap/locale/sv.js +51 -16
  148. umap/static/umap/locale/sv.json +51 -16
  149. umap/static/umap/locale/th_TH.js +51 -16
  150. umap/static/umap/locale/th_TH.json +51 -16
  151. umap/static/umap/locale/tr.js +51 -16
  152. umap/static/umap/locale/tr.json +51 -16
  153. umap/static/umap/locale/uk_UA.js +51 -16
  154. umap/static/umap/locale/uk_UA.json +51 -16
  155. umap/static/umap/locale/vi.js +51 -16
  156. umap/static/umap/locale/vi.json +51 -16
  157. umap/static/umap/locale/vi_VN.json +51 -16
  158. umap/static/umap/locale/zh.js +51 -16
  159. umap/static/umap/locale/zh.json +51 -16
  160. umap/static/umap/locale/zh_CN.json +51 -16
  161. umap/static/umap/locale/zh_TW.Big5.json +51 -16
  162. umap/static/umap/locale/zh_TW.js +51 -16
  163. umap/static/umap/locale/zh_TW.json +51 -16
  164. umap/static/umap/map.css +40 -53
  165. umap/static/umap/unittests/sync.js +105 -0
  166. umap/static/umap/unittests/utils.js +78 -36
  167. umap/static/umap/vars.css +19 -1
  168. umap/static/umap/vendors/dompurify/purify.es.js +50 -15
  169. umap/static/umap/vendors/dompurify/purify.es.mjs.map +1 -1
  170. umap/static/umap/vendors/formbuilder/Leaflet.FormBuilder.js +2 -2
  171. umap/templates/umap/components/alerts/alert.html +89 -0
  172. umap/templates/umap/content.html +4 -3
  173. umap/templates/umap/css.html +4 -0
  174. umap/templates/umap/home.html +3 -0
  175. umap/templates/umap/js.html +0 -3
  176. umap/templates/umap/map_init.html +2 -8
  177. umap/templates/umap/messages.html +9 -11
  178. umap/templates/umap/search.html +3 -0
  179. umap/tests/base.py +3 -0
  180. umap/tests/integration/conftest.py +30 -0
  181. umap/tests/integration/test_anonymous_owned_map.py +8 -13
  182. umap/tests/integration/test_browser.py +81 -6
  183. umap/tests/integration/test_caption.py +27 -0
  184. umap/tests/integration/test_conditional_rules.py +201 -0
  185. umap/tests/integration/test_dashboard.py +1 -1
  186. umap/tests/integration/test_datalayer.py +2 -3
  187. umap/tests/integration/test_edit_datalayer.py +32 -3
  188. umap/tests/integration/test_edit_map.py +1 -1
  189. umap/tests/integration/test_facets_browser.py +7 -4
  190. umap/tests/integration/test_import.py +185 -49
  191. umap/tests/integration/test_map.py +31 -17
  192. umap/tests/integration/{test_collaborative_editing.py → test_optimistic_merge.py} +7 -7
  193. umap/tests/integration/test_owned_map.py +1 -1
  194. umap/tests/integration/test_picto.py +2 -2
  195. umap/tests/integration/test_statics.py +1 -1
  196. umap/tests/integration/test_view_marker.py +19 -2
  197. umap/tests/integration/test_websocket_sync.py +283 -0
  198. umap/tests/settings.py +5 -0
  199. umap/tests/test_datalayer_views.py +0 -1
  200. umap/tests/test_views.py +53 -0
  201. umap/urls.py +5 -0
  202. umap/views.py +40 -11
  203. umap/websocket_server.py +92 -0
  204. {umap_project-2.3.0.dist-info → umap_project-2.4.0.dist-info}/METADATA +13 -11
  205. {umap_project-2.3.0.dist-info → umap_project-2.4.0.dist-info}/RECORD +208 -172
  206. umap/static/umap/js/umap.autocomplete.js +0 -341
  207. umap/static/umap/js/umap.importer.js +0 -187
  208. umap/static/umap/js/umap.ui.js +0 -190
  209. {umap_project-2.3.0.dist-info → umap_project-2.4.0.dist-info}/WHEEL +0 -0
  210. {umap_project-2.3.0.dist-info → umap_project-2.4.0.dist-info}/entry_points.txt +0 -0
  211. {umap_project-2.3.0.dist-info → umap_project-2.4.0.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,283 @@
1
+ import re
2
+
3
+ import pytest
4
+ from playwright.sync_api import expect
5
+
6
+ from umap.models import Map
7
+
8
+ from ..base import DataLayerFactory, MapFactory
9
+
10
+ DATALAYER_UPDATE = re.compile(r".*/datalayer/update/.*")
11
+
12
+
13
+ @pytest.mark.xdist_group(name="websockets")
14
+ def test_websocket_connection_can_sync_markers(
15
+ context, live_server, websocket_server, tilelayer
16
+ ):
17
+ map = MapFactory(name="sync", edit_status=Map.ANONYMOUS)
18
+ map.settings["properties"]["syncEnabled"] = True
19
+ map.save()
20
+ DataLayerFactory(map=map, data={})
21
+
22
+ # Create two tabs
23
+ peerA = context.new_page()
24
+ peerA.goto(f"{live_server.url}{map.get_absolute_url()}?edit")
25
+ peerB = context.new_page()
26
+ peerB.goto(f"{live_server.url}{map.get_absolute_url()}?edit")
27
+
28
+ a_marker_pane = peerA.locator(".leaflet-marker-pane > div")
29
+ b_marker_pane = peerB.locator(".leaflet-marker-pane > div")
30
+ expect(a_marker_pane).to_have_count(0)
31
+ expect(b_marker_pane).to_have_count(0)
32
+
33
+ # Add a marker from peer A
34
+ a_create_marker = peerA.get_by_title("Draw a marker")
35
+ expect(a_create_marker).to_be_visible()
36
+ a_create_marker.click()
37
+
38
+ a_map_el = peerA.locator("#map")
39
+ a_map_el.click(position={"x": 220, "y": 220})
40
+ expect(a_marker_pane).to_have_count(1)
41
+ expect(b_marker_pane).to_have_count(1)
42
+ peerA.locator("body").type("Synced name")
43
+ peerA.locator("body").press("Escape")
44
+
45
+ peerB.locator(".leaflet-marker-icon").first.click()
46
+ peerB.get_by_role("link", name="Toggle edit mode (⇧+Click)").click()
47
+ expect(peerB.locator('input[name="name"]')).to_have_value("Synced name")
48
+
49
+ a_first_marker = peerA.locator("div:nth-child(4) > div:nth-child(2)").first
50
+ b_first_marker = peerB.locator("div:nth-child(4) > div:nth-child(2)").first
51
+
52
+ # Add a second marker from peer B
53
+ b_create_marker = peerB.get_by_title("Draw a marker")
54
+ expect(b_create_marker).to_be_visible()
55
+ b_create_marker.click()
56
+
57
+ b_map_el = peerB.locator("#map")
58
+ b_map_el.click(position={"x": 225, "y": 225})
59
+ expect(a_marker_pane).to_have_count(2)
60
+ expect(b_marker_pane).to_have_count(2)
61
+
62
+ # Drag a marker on peer B and check that it moved on peer A
63
+ a_first_marker.bounding_box() == b_first_marker.bounding_box()
64
+ b_old_bbox = b_first_marker.bounding_box()
65
+ b_first_marker.drag_to(b_map_el, target_position={"x": 250, "y": 250})
66
+
67
+ assert b_old_bbox is not b_first_marker.bounding_box()
68
+ a_first_marker.bounding_box() == b_first_marker.bounding_box()
69
+
70
+ # Delete a marker from peer A and check it's been deleted on peer B
71
+ a_first_marker.click(button="right")
72
+ peerA.on("dialog", lambda dialog: dialog.accept())
73
+ peerA.get_by_role("link", name="Delete this feature").click()
74
+ expect(a_marker_pane).to_have_count(1)
75
+ expect(b_marker_pane).to_have_count(1)
76
+
77
+
78
+ @pytest.mark.xdist_group(name="websockets")
79
+ def test_websocket_connection_can_sync_polygons(
80
+ context, live_server, websocket_server, tilelayer
81
+ ):
82
+ map = MapFactory(name="sync", edit_status=Map.ANONYMOUS)
83
+ map.settings["properties"]["syncEnabled"] = True
84
+ map.save()
85
+ DataLayerFactory(map=map, data={})
86
+
87
+ # Create two tabs
88
+ peerA = context.new_page()
89
+ peerA.goto(f"{live_server.url}{map.get_absolute_url()}?edit")
90
+ peerB = context.new_page()
91
+ peerB.goto(f"{live_server.url}{map.get_absolute_url()}?edit")
92
+
93
+ b_map_el = peerB.locator("#map")
94
+
95
+ # Click on the Draw a polygon button on a new map.
96
+ create_line = peerA.locator(".leaflet-control-toolbar ").get_by_title(
97
+ "Draw a polygon"
98
+ )
99
+ create_line.click()
100
+
101
+ a_polygons = peerA.locator(".leaflet-overlay-pane path[fill='DarkBlue']")
102
+ b_polygons = peerB.locator(".leaflet-overlay-pane path[fill='DarkBlue']")
103
+ expect(a_polygons).to_have_count(0)
104
+ expect(b_polygons).to_have_count(0)
105
+
106
+ # Click on the map, it will create a polygon.
107
+ map = peerA.locator("#map")
108
+ map.click(position={"x": 200, "y": 200})
109
+ map.click(position={"x": 100, "y": 200})
110
+ map.click(position={"x": 100, "y": 100})
111
+ map.click(position={"x": 100, "y": 100})
112
+
113
+ # It is created on peerA, but not yet synced
114
+ expect(a_polygons).to_have_count(1)
115
+ expect(b_polygons).to_have_count(0)
116
+
117
+ # Escaping the edition syncs
118
+ peerA.keyboard.press("Escape")
119
+ expect(a_polygons).to_have_count(1)
120
+ expect(b_polygons).to_have_count(1)
121
+
122
+ # change the geometry by moving a point on peer B
123
+ a_polygon = peerA.locator("path")
124
+ b_polygon = peerB.locator("path")
125
+ b_polygon_bbox_t1 = b_polygon.bounding_box()
126
+ a_polygon_bbox_t1 = a_polygon.bounding_box()
127
+ assert b_polygon_bbox_t1 == a_polygon_bbox_t1
128
+
129
+ b_polygon.click()
130
+ peerB.get_by_role("link", name="Toggle edit mode (⇧+Click)").click()
131
+
132
+ edited_vertex = peerB.locator("div:nth-child(6)").first
133
+ edited_vertex.drag_to(b_map_el, target_position={"x": 233, "y": 126})
134
+ peerB.keyboard.press("Escape")
135
+
136
+ b_polygon_bbox_t2 = b_polygon.bounding_box()
137
+ a_polygon_bbox_t2 = a_polygon.bounding_box()
138
+
139
+ assert b_polygon_bbox_t2 != b_polygon_bbox_t1
140
+ assert b_polygon_bbox_t2 == a_polygon_bbox_t2
141
+
142
+ # Move the polygon on peer B and check it moved also on peer A
143
+ b_polygon.click()
144
+ peerB.get_by_role("link", name="Toggle edit mode (⇧+Click)").click()
145
+
146
+ b_polygon.drag_to(b_map_el, target_position={"x": 400, "y": 400})
147
+ peerB.keyboard.press("Escape")
148
+ b_polygon_bbox_t3 = b_polygon.bounding_box()
149
+ a_polygon_bbox_t3 = a_polygon.bounding_box()
150
+
151
+ assert b_polygon_bbox_t3 != b_polygon_bbox_t2
152
+ assert b_polygon_bbox_t3 == a_polygon_bbox_t3
153
+
154
+ # Delete a polygon from peer A and check it's been deleted on peer B
155
+ a_polygon.click(button="right")
156
+ peerA.on("dialog", lambda dialog: dialog.accept())
157
+ peerA.get_by_role("link", name="Delete this feature").click()
158
+ expect(a_polygons).to_have_count(0)
159
+ expect(b_polygons).to_have_count(0)
160
+
161
+
162
+ @pytest.mark.xdist_group(name="websockets")
163
+ def test_websocket_connection_can_sync_map_properties(
164
+ context, live_server, websocket_server, tilelayer
165
+ ):
166
+ map = MapFactory(name="sync", edit_status=Map.ANONYMOUS)
167
+ map.settings["properties"]["syncEnabled"] = True
168
+ map.save()
169
+ DataLayerFactory(map=map, data={})
170
+
171
+ # Create two tabs
172
+ peerA = context.new_page()
173
+ peerA.goto(f"{live_server.url}{map.get_absolute_url()}?edit")
174
+ peerB = context.new_page()
175
+ peerB.goto(f"{live_server.url}{map.get_absolute_url()}?edit")
176
+
177
+ # Name change is synced
178
+ peerA.get_by_role("link", name="Edit map name and caption").click()
179
+ peerA.locator('input[name="name"]').click()
180
+ peerA.locator('input[name="name"]').fill("it syncs!")
181
+
182
+ expect(peerB.locator(".map-name").last).to_have_text("it syncs!")
183
+
184
+ # Zoom control is synced
185
+ peerB.get_by_role("link", name="Map advanced properties").click()
186
+ peerB.locator("summary").filter(has_text="User interface options").click()
187
+ peerB.locator("div").filter(
188
+ has_text=re.compile(r"^Display the zoom control")
189
+ ).locator("label").nth(2).click()
190
+
191
+ expect(peerA.locator(".leaflet-control-zoom")).to_be_hidden()
192
+
193
+
194
+ @pytest.mark.xdist_group(name="websockets")
195
+ def test_websocket_connection_can_sync_datalayer_properties(
196
+ context, live_server, websocket_server, tilelayer
197
+ ):
198
+ map = MapFactory(name="sync", edit_status=Map.ANONYMOUS)
199
+ map.settings["properties"]["syncEnabled"] = True
200
+ map.save()
201
+ DataLayerFactory(map=map, data={})
202
+
203
+ # Create two tabs
204
+ peerA = context.new_page()
205
+ peerA.goto(f"{live_server.url}{map.get_absolute_url()}?edit")
206
+ peerB = context.new_page()
207
+ peerB.goto(f"{live_server.url}{map.get_absolute_url()}?edit")
208
+
209
+ # Layer addition, name and type are synced
210
+ peerA.get_by_role("link", name="Manage layers").click()
211
+ peerA.get_by_role("button", name="Add a layer").click()
212
+ peerA.locator('input[name="name"]').click()
213
+ peerA.locator('input[name="name"]').fill("synced layer!")
214
+ peerA.get_by_role("combobox").select_option("Choropleth")
215
+ peerA.locator("body").press("Escape")
216
+
217
+ peerB.get_by_role("link", name="Manage layers").click()
218
+ peerB.get_by_role("button", name="Edit").first.click()
219
+ expect(peerB.locator('input[name="name"]')).to_have_value("synced layer!")
220
+ expect(peerB.get_by_role("combobox")).to_have_value("Choropleth")
221
+
222
+
223
+ @pytest.mark.xdist_group(name="websockets")
224
+ def test_websocket_connection_can_sync_cloned_polygons(
225
+ context, live_server, websocket_server, tilelayer
226
+ ):
227
+ map = MapFactory(name="sync", edit_status=Map.ANONYMOUS)
228
+ map.settings["properties"]["syncEnabled"] = True
229
+ map.save()
230
+ DataLayerFactory(map=map, data={})
231
+
232
+ # Create two tabs
233
+ peerA = context.new_page()
234
+ peerA.goto(f"{live_server.url}{map.get_absolute_url()}?edit")
235
+ peerB = context.new_page()
236
+ peerB.goto(f"{live_server.url}{map.get_absolute_url()}?edit")
237
+
238
+ b_map_el = peerB.locator("#map")
239
+
240
+ # Click on the Draw a polygon button on a new map.
241
+ create_line = peerA.locator(".leaflet-control-toolbar ").get_by_title(
242
+ "Draw a polygon"
243
+ )
244
+ create_line.click()
245
+
246
+ a_polygons = peerA.locator(".leaflet-overlay-pane path[fill='DarkBlue']")
247
+ b_polygons = peerB.locator(".leaflet-overlay-pane path[fill='DarkBlue']")
248
+ expect(a_polygons).to_have_count(0)
249
+ expect(b_polygons).to_have_count(0)
250
+
251
+ # Click on the map, it will create a polygon.
252
+ map = peerA.locator("#map")
253
+ map.click(position={"x": 200, "y": 200})
254
+ map.click(position={"x": 100, "y": 200})
255
+ map.click(position={"x": 100, "y": 100})
256
+ map.click(position={"x": 200, "y": 100})
257
+ map.click(position={"x": 200, "y": 100})
258
+
259
+ # Escaping the edition syncs
260
+ peerA.keyboard.press("Escape")
261
+ expect(a_polygons).to_have_count(1)
262
+ expect(b_polygons).to_have_count(1)
263
+
264
+ # Save from peer A
265
+ peerA.get_by_role("button", name="Save").click()
266
+
267
+ b_polygon = peerB.locator("path")
268
+
269
+ # Clone on peer B and save
270
+ b_polygon.click(button="right")
271
+ peerB.get_by_role("link", name="Clone this feature").click()
272
+
273
+ expect(peerB.locator("path")).to_have_count(2)
274
+
275
+ peerB.locator("path").nth(1).drag_to(b_map_el, target_position={"x": 400, "y": 400})
276
+ peerB.locator("path").nth(1).click()
277
+ peerB.locator("summary").filter(has_text="Shape properties").click()
278
+ peerB.locator(".header > a:nth-child(2)").first.click()
279
+ peerB.get_by_title("Orchid", exact=True).first.click()
280
+ peerB.locator("#map").press("Escape")
281
+ peerB.get_by_role("button", name="Save").click()
282
+
283
+ expect(peerB.locator("path")).to_have_count(2)
umap/tests/settings.py CHANGED
@@ -24,3 +24,8 @@ if os.environ.get("GITHUB_ACTIONS", False) == "true":
24
24
  PASSWORD_HASHERS = [
25
25
  "django.contrib.auth.hashers.MD5PasswordHasher",
26
26
  ]
27
+
28
+
29
+ WEBSOCKET_ENABLED = True
30
+ WEBSOCKET_BACK_PORT = "8010"
31
+ WEBSOCKET_FRONT_URI = "ws://localhost:8010"
@@ -1,5 +1,4 @@
1
1
  import json
2
- import time
3
2
  from copy import deepcopy
4
3
  from pathlib import Path
5
4
 
umap/tests/test_views.py CHANGED
@@ -5,6 +5,7 @@ from datetime import datetime, timedelta
5
5
  import pytest
6
6
  from django.conf import settings
7
7
  from django.contrib.auth import get_user, get_user_model
8
+ from django.core.signing import TimestampSigner
8
9
  from django.test import RequestFactory
9
10
  from django.urls import reverse
10
11
  from django.utils.timezone import make_aware
@@ -430,3 +431,55 @@ def test_home_feed(client, settings, user, tilelayer):
430
431
  assert "A public map starred by non staff" not in content
431
432
  assert "A private map starred by staff" not in content
432
433
  assert "A reserved map starred by staff" not in content
434
+
435
+
436
+ @pytest.mark.django_db
437
+ def test_websocket_token_returns_login_required_if_not_connected(client, user, map):
438
+ token_url = reverse("map_websocket_auth_token", kwargs={"map_id": map.id})
439
+ resp = client.get(token_url)
440
+ assert "login_required" in resp.json()
441
+
442
+
443
+ @pytest.mark.django_db
444
+ def test_websocket_token_returns_403_if_unauthorized(client, user, user2, map):
445
+ client.login(username=map.owner.username, password="123123")
446
+ map.owner = user2
447
+ map.save()
448
+
449
+ token_url = reverse("map_websocket_auth_token", kwargs={"map_id": map.id})
450
+ resp = client.get(token_url)
451
+ assert resp.status_code == 403
452
+
453
+
454
+ @pytest.mark.django_db
455
+ def test_websocket_token_is_generated_for_anonymous(client, user, user2, map):
456
+ map.edit_status = Map.ANONYMOUS
457
+ map.save()
458
+
459
+ token_url = reverse("map_websocket_auth_token", kwargs={"map_id": map.id})
460
+ resp = client.get(token_url)
461
+ token = resp.json().get("token")
462
+ assert TimestampSigner().unsign_object(token, max_age=30)
463
+
464
+
465
+ @pytest.mark.django_db
466
+ def test_websocket_token_returns_a_valid_token_when_authorized(client, user, map):
467
+ client.login(username=map.owner.username, password="123123")
468
+ token_url = reverse("map_websocket_auth_token", kwargs={"map_id": map.id})
469
+ resp = client.get(token_url)
470
+ assert resp.status_code == 200
471
+ token = resp.json().get("token")
472
+ assert TimestampSigner().unsign_object(token, max_age=30)
473
+
474
+
475
+ @pytest.mark.django_db
476
+ def test_websocket_token_is_generated_for_editors(client, user, user2, map):
477
+ map.edit_status = Map.EDITORS
478
+ map.editors.add(user2)
479
+ map.save()
480
+
481
+ assert client.login(username=user2.username, password="456456")
482
+ token_url = reverse("map_websocket_auth_token", kwargs={"map_id": map.id})
483
+ resp = client.get(token_url)
484
+ token = resp.json().get("token")
485
+ assert TimestampSigner().unsign_object(token, max_age=30)
umap/urls.py CHANGED
@@ -155,6 +155,11 @@ map_urls = [
155
155
  views.UpdateDataLayerPermissions.as_view(),
156
156
  name="datalayer_permissions",
157
157
  ),
158
+ path(
159
+ "map/<int:map_id>/ws-token/",
160
+ views.get_websocket_auth_token,
161
+ name="map_websocket_auth_token",
162
+ ),
158
163
  ]
159
164
  if settings.DEFAULT_FROM_EMAIL:
160
165
  map_urls.append(
umap/views.py CHANGED
@@ -24,7 +24,7 @@ from django.contrib.staticfiles.storage import staticfiles_storage
24
24
  from django.core.exceptions import PermissionDenied
25
25
  from django.core.mail import send_mail
26
26
  from django.core.paginator import EmptyPage, PageNotAnInteger, Paginator
27
- from django.core.signing import BadSignature, Signer
27
+ from django.core.signing import BadSignature, Signer, TimestampSigner
28
28
  from django.core.validators import URLValidator, ValidationError
29
29
  from django.http import (
30
30
  Http404,
@@ -40,7 +40,6 @@ from django.shortcuts import get_object_or_404
40
40
  from django.urls import resolve, reverse, reverse_lazy
41
41
  from django.utils import translation
42
42
  from django.utils.encoding import smart_bytes
43
- from django.utils.http import http_date
44
43
  from django.utils.timezone import make_aware
45
44
  from django.utils.translation import gettext as _
46
45
  from django.views.decorators.cache import cache_control
@@ -504,6 +503,9 @@ class MapDetailMixin:
504
503
  ],
505
504
  "umap_version": VERSION,
506
505
  "featuresHaveOwner": settings.UMAP_DEFAULT_FEATURES_HAVE_OWNERS,
506
+ "websocketEnabled": settings.WEBSOCKET_ENABLED,
507
+ "websocketURI": settings.WEBSOCKET_FRONT_URI,
508
+ "importers": settings.UMAP_IMPORTERS,
507
509
  }
508
510
  created = bool(getattr(self, "object", None))
509
511
  if (created and self.object.owner) or (not created and not user.is_anonymous):
@@ -624,8 +626,8 @@ class MapView(MapDetailMixin, PermissionsMixin, DetailView):
624
626
 
625
627
  def get_datalayers(self):
626
628
  return [
627
- l.metadata(self.request.user, self.request)
628
- for l in self.object.datalayer_set.all()
629
+ dl.metadata(self.request.user, self.request)
630
+ for dl in self.object.datalayer_set.all()
629
631
  ]
630
632
 
631
633
  @property
@@ -778,6 +780,33 @@ class MapCreate(FormLessEditMixin, PermissionsMixin, CreateView):
778
780
  return response
779
781
 
780
782
 
783
+ def get_websocket_auth_token(request, map_id, map_inst):
784
+ """Return an signed authentication token for the currently
785
+ connected user, allowing edits for this map over WebSocket.
786
+
787
+ If the user is anonymous, return a signed token with the map id.
788
+
789
+ The returned token is a signed object with the following keys:
790
+ - user: user primary key OR "anonymous"
791
+ - map_id: the map id
792
+ - permissions: a list of allowed permissions for this user and this map
793
+ """
794
+ map_object: Map = Map.objects.get(pk=map_id)
795
+
796
+ permissions = ["edit"]
797
+ if map_object.is_owner(request.user, request):
798
+ permissions.append("owner")
799
+
800
+ if request.user.is_authenticated:
801
+ user = request.user.pk
802
+ else:
803
+ user = "anonymous"
804
+ signed_token = TimestampSigner().sign_object(
805
+ {"user": user, "map_id": map_id, "permissions": permissions}
806
+ )
807
+ return simple_json_response(token=signed_token)
808
+
809
+
781
810
  class MapUpdate(FormLessEditMixin, PermissionsMixin, UpdateView):
782
811
  model = Map
783
812
  form_class = MapSettingsForm
@@ -850,11 +879,10 @@ class SendEditLink(FormLessEditMixin, FormView):
850
879
  return HttpResponseBadRequest("Invalid")
851
880
  link = self.object.get_anonymous_edit_url()
852
881
 
853
- subject = _(
854
- "The uMap edit link for your map: %(map_name)s"
855
- % {"map_name": self.object.name}
856
- )
857
- body = _("Here is your secret edit link: %(link)s" % {"link": link})
882
+ subject = _("The uMap edit link for your map: %(map_name)s") % {
883
+ "map_name": self.object.name
884
+ }
885
+ body = _("Here is your secret edit link: %(link)s") % {"link": link}
858
886
  try:
859
887
  send_mail(
860
888
  subject, body, settings.DEFAULT_FROM_EMAIL, [email], fail_silently=False
@@ -864,7 +892,7 @@ class SendEditLink(FormLessEditMixin, FormView):
864
892
  error=_("Can't send email to %(email)s" % {"email": email})
865
893
  )
866
894
  return simple_json_response(
867
- info=_("Email sent to %(email)s" % {"email": email})
895
+ info=_("Email sent to %(email)s") % {"email": email}
868
896
  )
869
897
 
870
898
 
@@ -878,6 +906,7 @@ class MapDelete(DeleteView):
878
906
  return HttpResponseForbidden(_("Only its owner can delete the map."))
879
907
  self.object.delete()
880
908
  home_url = reverse("home")
909
+ messages.info(self.request, _("Map successfully deleted."))
881
910
  if is_ajax(self.request):
882
911
  return simple_json_response(redirect=home_url)
883
912
  else:
@@ -1081,7 +1110,7 @@ class DataLayerUpdate(FormLessEditMixin, GZipMixin, UpdateView):
1081
1110
  reference = json.loads(f.read())
1082
1111
  break
1083
1112
  else:
1084
- # If the document is not found, we can't merge.
1113
+ # If the reference document is not found, we can't merge.
1085
1114
  return None
1086
1115
  # New data received in the request.
1087
1116
  incoming = json.loads(self.request.FILES["geojson"].read())
@@ -0,0 +1,92 @@
1
+ #!/usr/bin/env python
2
+
3
+ import asyncio
4
+ from collections import defaultdict
5
+ from typing import Literal, Optional
6
+
7
+ import websockets
8
+ from django.conf import settings
9
+ from django.core.signing import TimestampSigner
10
+ from pydantic import BaseModel, ValidationError
11
+ from websockets import WebSocketClientProtocol
12
+ from websockets.server import serve
13
+
14
+ from umap.models import Map, User # NOQA
15
+
16
+ # Contains the list of websocket connections handled by this process.
17
+ # It's a mapping of map_id to a set of the active websocket connections
18
+ CONNECTIONS = defaultdict(set)
19
+
20
+
21
+ class JoinMessage(BaseModel):
22
+ kind: str = "join"
23
+ token: str
24
+
25
+
26
+ class OperationMessage(BaseModel):
27
+ kind: str = "operation"
28
+ verb: str = Literal["upsert", "update", "delete"]
29
+ subject: str = Literal["map", "layer", "feature"]
30
+ metadata: Optional[dict] = None
31
+ key: Optional[str] = None
32
+
33
+
34
+ async def join_and_listen(
35
+ map_id: int, permissions: list, user: str | int, websocket: WebSocketClientProtocol
36
+ ):
37
+ """Join a "room" whith other connected peers.
38
+
39
+ New messages will be broadcasted to other connected peers.
40
+ """
41
+ print(f"{user} joined room #{map_id}")
42
+ CONNECTIONS[map_id].add(websocket)
43
+ try:
44
+ async for raw_message in websocket:
45
+ # recompute the peers-list at the time of message-sending.
46
+ # as doing so beforehand would miss new connections
47
+ peers = CONNECTIONS[map_id] - {websocket}
48
+ # Only relay valid "operation" messages
49
+ try:
50
+ OperationMessage.model_validate_json(raw_message)
51
+ websockets.broadcast(peers, raw_message)
52
+ except ValidationError as e:
53
+ error = f"An error occurred when receiving this message: {raw_message}"
54
+ print(error, e)
55
+ finally:
56
+ CONNECTIONS[map_id].remove(websocket)
57
+
58
+
59
+ async def handler(websocket):
60
+ """Main WebSocket handler.
61
+
62
+ If permissions are granted, let the peer enter a room.
63
+ """
64
+ raw_message = await websocket.recv()
65
+
66
+ # The first event should always be 'join'
67
+ message: JoinMessage = JoinMessage.model_validate_json(raw_message)
68
+ signed = TimestampSigner().unsign_object(message.token, max_age=30)
69
+ user, map_id, permissions = signed.values()
70
+
71
+ # Check if permissions for this map have been granted by the server
72
+ if "edit" in signed["permissions"]:
73
+ await join_and_listen(map_id, permissions, user, websocket)
74
+
75
+
76
+ def run(host, port):
77
+ if not settings.WEBSOCKET_ENABLED:
78
+ msg = (
79
+ "WEBSOCKET_ENABLED should be set to True to run the WebSocket Server. "
80
+ "See the documentation at "
81
+ "https://docs.umap-project.org/en/stable/config/settings/#websocket_enabled "
82
+ "for more information."
83
+ )
84
+ print(msg)
85
+ exit(1)
86
+
87
+ async def _serve():
88
+ async with serve(handler, host, port):
89
+ print(f"Waiting for connections on {host}:{port}")
90
+ await asyncio.Future() # run forever
91
+
92
+ asyncio.run(_serve())
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: umap-project
3
- Version: 2.3.0
3
+ Version: 2.4.0
4
4
  Summary: Create maps with OpenStreetMap layers in a minute and embed them in your site.
5
5
  Author-email: Yohan Boniface <yb@enix.org>
6
6
  Maintainer-email: David Larlet <david@larlet.fr>
@@ -19,33 +19,35 @@ Requires-Python: >=3.10
19
19
  Requires-Dist: django-agnocomplete==2.2.0
20
20
  Requires-Dist: django-environ==0.11.2
21
21
  Requires-Dist: django-probes==1.7.0
22
- Requires-Dist: django==5.0.5
22
+ Requires-Dist: django==5.0.6
23
23
  Requires-Dist: pillow==10.3.0
24
- Requires-Dist: psycopg==3.1.18
24
+ Requires-Dist: psycopg==3.1.19
25
+ Requires-Dist: pydantic==2.7.4
25
26
  Requires-Dist: rcssmin==1.1.2
26
- Requires-Dist: requests==2.31.0
27
+ Requires-Dist: requests==2.32.3
27
28
  Requires-Dist: rjsmin==1.2.2
28
29
  Requires-Dist: social-auth-app-django==5.4.1
29
30
  Requires-Dist: social-auth-core==4.5.4
31
+ Requires-Dist: websockets==12.0
30
32
  Provides-Extra: dev
31
33
  Requires-Dist: djlint==1.34.1; extra == 'dev'
32
- Requires-Dist: hatch==1.10.0; extra == 'dev'
34
+ Requires-Dist: hatch==1.12.0; extra == 'dev'
33
35
  Requires-Dist: isort==5.13.2; extra == 'dev'
34
- Requires-Dist: mkdocs-material==9.5.21; extra == 'dev'
35
- Requires-Dist: mkdocs-static-i18n==1.2.2; extra == 'dev'
36
+ Requires-Dist: mkdocs-material==9.5.27; extra == 'dev'
37
+ Requires-Dist: mkdocs-static-i18n==1.2.3; extra == 'dev'
36
38
  Requires-Dist: mkdocs==1.6.0; extra == 'dev'
37
39
  Requires-Dist: pymdown-extensions==10.8.1; extra == 'dev'
38
- Requires-Dist: ruff==0.4.3; extra == 'dev'
40
+ Requires-Dist: ruff==0.4.9; extra == 'dev'
39
41
  Requires-Dist: vermin==1.6.0; extra == 'dev'
40
42
  Provides-Extra: docker
41
- Requires-Dist: uwsgi==2.0.25.1; extra == 'docker'
43
+ Requires-Dist: uwsgi==2.0.26; extra == 'docker'
42
44
  Provides-Extra: test
43
45
  Requires-Dist: factory-boy==3.2.1; extra == 'test'
44
46
  Requires-Dist: playwright>=1.39; extra == 'test'
45
47
  Requires-Dist: pytest-django==4.8.0; extra == 'test'
46
- Requires-Dist: pytest-playwright==0.4.4; extra == 'test'
48
+ Requires-Dist: pytest-playwright==0.5.0; extra == 'test'
47
49
  Requires-Dist: pytest-xdist<4,>=3.5.0; extra == 'test'
48
- Requires-Dist: pytest==8.2.0; extra == 'test'
50
+ Requires-Dist: pytest==8.2.2; extra == 'test'
49
51
  Description-Content-Type: text/markdown
50
52
 
51
53
  # uMap project