umap-project 2.6.3__py3-none-any.whl → 2.7.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.
- umap/__init__.py +1 -1
- umap/admin.py +64 -1
- umap/asgi.py +15 -0
- umap/context_processors.py +1 -0
- umap/locale/cs_CZ/LC_MESSAGES/django.mo +0 -0
- umap/locale/cs_CZ/LC_MESSAGES/django.po +96 -92
- umap/locale/de/LC_MESSAGES/django.mo +0 -0
- umap/locale/de/LC_MESSAGES/django.po +19 -18
- umap/locale/en/LC_MESSAGES/django.po +47 -43
- umap/locale/es/LC_MESSAGES/django.mo +0 -0
- umap/locale/es/LC_MESSAGES/django.po +134 -128
- umap/locale/fr/LC_MESSAGES/django.mo +0 -0
- umap/locale/fr/LC_MESSAGES/django.po +51 -47
- umap/locale/pt/LC_MESSAGES/django.mo +0 -0
- umap/locale/pt/LC_MESSAGES/django.po +64 -60
- umap/management/commands/clean_tilelayer.py +152 -0
- umap/management/commands/purge_purgatory.py +28 -0
- umap/models.py +27 -2
- umap/settings/base.py +3 -1
- umap/static/umap/base.css +4 -4
- umap/static/umap/css/contextmenu.css +6 -1
- umap/static/umap/css/icon.css +7 -2
- umap/static/umap/css/importers.css +4 -0
- umap/static/umap/img/16-white.svg +9 -2
- umap/static/umap/img/16.svg +1 -181
- umap/static/umap/img/24-white.svg +1 -0
- umap/static/umap/img/24.svg +1 -0
- umap/static/umap/img/importers/cadastrefr.svg +23 -0
- umap/static/umap/img/source/16-white.svg +10 -3
- umap/static/umap/img/source/16.svg +753 -197
- umap/static/umap/img/source/24-white.svg +3 -2
- umap/static/umap/img/source/24.svg +3 -2
- umap/static/umap/js/modules/autocomplete.js +7 -3
- umap/static/umap/js/modules/browser.js +54 -1
- umap/static/umap/js/modules/caption.js +16 -5
- umap/static/umap/js/modules/data/features.js +176 -2
- umap/static/umap/js/modules/data/layer.js +57 -40
- umap/static/umap/js/modules/formatter.js +3 -2
- umap/static/umap/js/modules/global.js +2 -0
- umap/static/umap/js/modules/importer.js +3 -0
- umap/static/umap/js/modules/importers/cadastrefr.js +62 -0
- umap/static/umap/js/modules/importers/communesfr.js +15 -3
- umap/static/umap/js/modules/permissions.js +123 -93
- umap/static/umap/js/modules/rendering/layers/classified.js +2 -0
- umap/static/umap/js/modules/rendering/ui.js +60 -213
- umap/static/umap/js/modules/share.js +1 -3
- umap/static/umap/js/modules/slideshow.js +1 -1
- umap/static/umap/js/modules/sync/engine.js +371 -14
- umap/static/umap/js/modules/sync/hlc.js +106 -0
- umap/static/umap/js/modules/sync/updaters.js +18 -6
- umap/static/umap/js/modules/sync/websocket.js +1 -1
- umap/static/umap/js/modules/tableeditor.js +1 -1
- umap/static/umap/js/modules/ui/base.js +2 -2
- umap/static/umap/js/modules/ui/contextmenu.js +51 -18
- umap/static/umap/js/modules/urls.js +5 -1
- umap/static/umap/js/modules/utils.js +28 -4
- umap/static/umap/js/umap.controls.js +73 -52
- umap/static/umap/js/umap.core.js +3 -3
- umap/static/umap/js/umap.forms.js +3 -1
- umap/static/umap/js/umap.js +115 -124
- umap/static/umap/locale/br.js +13 -4
- umap/static/umap/locale/br.json +13 -4
- umap/static/umap/locale/ca.js +28 -15
- umap/static/umap/locale/ca.json +28 -15
- umap/static/umap/locale/cs_CZ.js +87 -78
- umap/static/umap/locale/cs_CZ.json +87 -78
- umap/static/umap/locale/de.js +17 -8
- umap/static/umap/locale/de.json +17 -8
- umap/static/umap/locale/en.js +13 -2
- umap/static/umap/locale/en.json +13 -2
- umap/static/umap/locale/es.js +330 -319
- umap/static/umap/locale/es.json +330 -319
- umap/static/umap/locale/eu.js +10 -3
- umap/static/umap/locale/eu.json +10 -3
- umap/static/umap/locale/fa_IR.js +11 -4
- umap/static/umap/locale/fa_IR.json +11 -4
- umap/static/umap/locale/fr.js +15 -4
- umap/static/umap/locale/fr.json +15 -4
- umap/static/umap/locale/hu.js +10 -3
- umap/static/umap/locale/hu.json +10 -3
- umap/static/umap/locale/pt.js +17 -8
- umap/static/umap/locale/pt.json +17 -8
- umap/static/umap/locale/pt_PT.js +13 -4
- umap/static/umap/locale/pt_PT.json +13 -4
- umap/static/umap/locale/zh_TW.js +13 -4
- umap/static/umap/locale/zh_TW.json +13 -4
- umap/static/umap/map.css +44 -29
- umap/static/umap/unittests/hlc.js +165 -0
- umap/static/umap/unittests/sync.js +321 -15
- umap/static/umap/unittests/utils.js +47 -0
- umap/static/umap/vars.css +2 -1
- umap/static/umap/vendors/colorbrewer/colorbrewer.js +309 -317
- umap/static/umap/vendors/dompurify/purify.es.js +15 -16
- umap/static/umap/vendors/dompurify/purify.es.mjs.map +1 -1
- umap/static/umap/vendors/georsstogeojson/GeoRSSToGeoJSON.js +111 -80
- umap/static/umap/vendors/locatecontrol/L.Control.Locate.min.js +2 -2
- umap/static/umap/vendors/locatecontrol/L.Control.Locate.min.js.map +1 -1
- umap/static/umap/vendors/simple-statistics/simple-statistics.min.js +1 -1
- umap/static/umap/vendors/simple-statistics/simple-statistics.min.js.map +1 -1
- umap/templates/umap/css.html +0 -2
- umap/templates/umap/dashboard_menu.html +4 -2
- umap/templates/umap/js.html +0 -5
- umap/templates/umap/map_detail.html +2 -2
- umap/tests/fixtures/test_upload_data.csv +2 -2
- umap/tests/integration/test_anonymous_owned_map.py +1 -0
- umap/tests/integration/test_basics.py +1 -1
- umap/tests/integration/test_browser.py +69 -7
- umap/tests/integration/test_caption.py +3 -3
- umap/tests/integration/test_circles_layer.py +12 -0
- umap/tests/integration/test_datalayer.py +2 -1
- umap/tests/integration/test_draw_polygon.py +17 -9
- umap/tests/integration/test_draw_polyline.py +12 -8
- umap/tests/integration/test_edit_datalayer.py +5 -8
- umap/tests/integration/test_edit_map.py +2 -2
- umap/tests/integration/test_edit_marker.py +1 -1
- umap/tests/integration/test_facets_browser.py +3 -3
- umap/tests/integration/test_import.py +1 -0
- umap/tests/integration/test_map.py +1 -0
- umap/tests/integration/test_owned_map.py +1 -1
- umap/tests/integration/test_view_marker.py +63 -0
- umap/tests/integration/test_view_polygon.py +12 -12
- umap/tests/integration/test_websocket_sync.py +65 -3
- umap/tests/test_clean_tilelayer.py +83 -0
- umap/tests/test_datalayer.py +24 -0
- umap/tests/test_map_views.py +20 -0
- umap/tests/test_purge_purgatory.py +25 -0
- umap/tests/test_websocket_server.py +22 -0
- umap/urls.py +5 -1
- umap/views.py +6 -3
- umap/websocket_server.py +130 -27
- {umap_project-2.6.3.dist-info → umap_project-2.7.0.dist-info}/METADATA +18 -14
- {umap_project-2.6.3.dist-info → umap_project-2.7.0.dist-info}/RECORD +135 -127
- umap/static/umap/vendors/contextmenu/leaflet.contextmenu.min.css +0 -1
- umap/static/umap/vendors/contextmenu/leaflet.contextmenu.min.js +0 -7
- {umap_project-2.6.3.dist-info → umap_project-2.7.0.dist-info}/WHEEL +0 -0
- {umap_project-2.6.3.dist-info → umap_project-2.7.0.dist-info}/entry_points.txt +0 -0
- {umap_project-2.6.3.dist-info → umap_project-2.7.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import re
|
|
2
|
+
from copy import deepcopy
|
|
2
3
|
|
|
3
4
|
import pytest
|
|
4
5
|
from playwright.sync_api import expect
|
|
@@ -33,19 +34,9 @@ DATALAYER_DATA = {
|
|
|
33
34
|
}
|
|
34
35
|
|
|
35
36
|
|
|
36
|
-
|
|
37
|
-
def bootstrap(map, live_server):
|
|
38
|
-
map.settings["properties"]["zoom"] = 6
|
|
39
|
-
map.settings["geometry"] = {
|
|
40
|
-
"type": "Point",
|
|
41
|
-
"coordinates": [8.429, 53.239],
|
|
42
|
-
}
|
|
43
|
-
map.save()
|
|
37
|
+
def test_should_open_popup_on_click(live_server, map, page):
|
|
44
38
|
DataLayerFactory(map=map, data=DATALAYER_DATA)
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
def test_should_open_popup_on_click(live_server, map, page, bootstrap):
|
|
48
|
-
page.goto(f"{live_server.url}{map.get_absolute_url()}")
|
|
39
|
+
page.goto(f"{live_server.url}{map.get_absolute_url()}#6/53.239/8.429")
|
|
49
40
|
polygon = page.locator("path").first
|
|
50
41
|
expect(polygon).to_have_attribute("fill-opacity", "0.3")
|
|
51
42
|
polygon.click()
|
|
@@ -57,3 +48,12 @@ def test_should_open_popup_on_click(live_server, map, page, bootstrap):
|
|
|
57
48
|
# Close popup
|
|
58
49
|
page.locator("#map").click()
|
|
59
50
|
expect(polygon).to_have_attribute("fill-opacity", "0.3")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def test_should_not_react_to_click_if_interactive_false(live_server, map, page):
|
|
54
|
+
data = deepcopy(DATALAYER_DATA)
|
|
55
|
+
data["features"][0]["properties"]["_umap_options"] = {"interactive": False}
|
|
56
|
+
DataLayerFactory(map=map, data=data)
|
|
57
|
+
page.goto(f"{live_server.url}{map.get_absolute_url()}#6/53.239/8.429")
|
|
58
|
+
polygon = page.locator("path").first
|
|
59
|
+
expect(polygon).to_have_css("pointer-events", "none")
|
|
@@ -69,7 +69,7 @@ def test_websocket_connection_can_sync_markers(
|
|
|
69
69
|
|
|
70
70
|
# Delete a marker from peer A and check it's been deleted on peer B
|
|
71
71
|
a_first_marker.click(button="right")
|
|
72
|
-
peerA.get_by_role("
|
|
72
|
+
peerA.get_by_role("button", name="Delete this feature").click()
|
|
73
73
|
peerA.locator("dialog").get_by_role("button", name="OK").click()
|
|
74
74
|
expect(a_marker_pane).to_have_count(1)
|
|
75
75
|
expect(b_marker_pane).to_have_count(1)
|
|
@@ -153,7 +153,7 @@ def test_websocket_connection_can_sync_polygons(
|
|
|
153
153
|
|
|
154
154
|
# Delete a polygon from peer A and check it's been deleted on peer B
|
|
155
155
|
a_polygon.click(button="right")
|
|
156
|
-
peerA.get_by_role("
|
|
156
|
+
peerA.get_by_role("button", name="Delete this feature").click()
|
|
157
157
|
peerA.locator("dialog").get_by_role("button", name="OK").click()
|
|
158
158
|
expect(a_polygons).to_have_count(0)
|
|
159
159
|
expect(b_polygons).to_have_count(0)
|
|
@@ -268,7 +268,7 @@ def test_websocket_connection_can_sync_cloned_polygons(
|
|
|
268
268
|
|
|
269
269
|
# Clone on peer B and save
|
|
270
270
|
b_polygon.click(button="right")
|
|
271
|
-
peerB.get_by_role("
|
|
271
|
+
peerB.get_by_role("button", name="Clone this feature").click()
|
|
272
272
|
|
|
273
273
|
expect(peerB.locator("path")).to_have_count(2)
|
|
274
274
|
|
|
@@ -281,3 +281,65 @@ def test_websocket_connection_can_sync_cloned_polygons(
|
|
|
281
281
|
peerB.get_by_role("button", name="Save").click()
|
|
282
282
|
|
|
283
283
|
expect(peerB.locator("path")).to_have_count(2)
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
@pytest.mark.xdist_group(name="websockets")
|
|
287
|
+
def test_websocket_connection_can_sync_late_joining_peer(
|
|
288
|
+
new_page, live_server, websocket_server, tilelayer
|
|
289
|
+
):
|
|
290
|
+
map = MapFactory(name="sync", edit_status=Map.ANONYMOUS)
|
|
291
|
+
map.settings["properties"]["syncEnabled"] = True
|
|
292
|
+
map.save()
|
|
293
|
+
DataLayerFactory(map=map, data={})
|
|
294
|
+
|
|
295
|
+
# Create first peer (A) and have it join immediately
|
|
296
|
+
peerA = new_page("Page A")
|
|
297
|
+
peerA.goto(f"{live_server.url}{map.get_absolute_url()}?edit")
|
|
298
|
+
|
|
299
|
+
# Add a marker from peer A
|
|
300
|
+
a_create_marker = peerA.get_by_title("Draw a marker")
|
|
301
|
+
expect(a_create_marker).to_be_visible()
|
|
302
|
+
a_create_marker.click()
|
|
303
|
+
|
|
304
|
+
a_map_el = peerA.locator("#map")
|
|
305
|
+
a_map_el.click(position={"x": 220, "y": 220})
|
|
306
|
+
peerA.locator("body").type("First marker")
|
|
307
|
+
peerA.locator("body").press("Escape")
|
|
308
|
+
|
|
309
|
+
# Add a polygon from peer A
|
|
310
|
+
create_polygon = peerA.locator(".leaflet-control-toolbar ").get_by_title(
|
|
311
|
+
"Draw a polygon"
|
|
312
|
+
)
|
|
313
|
+
create_polygon.click()
|
|
314
|
+
|
|
315
|
+
a_map_el.click(position={"x": 200, "y": 200})
|
|
316
|
+
a_map_el.click(position={"x": 100, "y": 200})
|
|
317
|
+
a_map_el.click(position={"x": 100, "y": 100})
|
|
318
|
+
a_map_el.click(position={"x": 200, "y": 100})
|
|
319
|
+
a_map_el.click(position={"x": 200, "y": 100})
|
|
320
|
+
peerA.keyboard.press("Escape")
|
|
321
|
+
|
|
322
|
+
# Now create peer B and have it join
|
|
323
|
+
peerB = new_page("Page B")
|
|
324
|
+
peerB.goto(f"{live_server.url}{map.get_absolute_url()}?edit")
|
|
325
|
+
|
|
326
|
+
# Check if peer B has received all the updates
|
|
327
|
+
b_marker_pane = peerB.locator(".leaflet-marker-pane > div")
|
|
328
|
+
b_polygons = peerB.locator(".leaflet-overlay-pane path[fill='DarkBlue']")
|
|
329
|
+
|
|
330
|
+
expect(b_marker_pane).to_have_count(1)
|
|
331
|
+
expect(b_polygons).to_have_count(1)
|
|
332
|
+
|
|
333
|
+
# Verify marker properties
|
|
334
|
+
peerB.locator(".leaflet-marker-icon").first.click()
|
|
335
|
+
peerB.get_by_role("link", name="Toggle edit mode (⇧+Click)").click()
|
|
336
|
+
expect(peerB.locator('input[name="name"]')).to_have_value("First marker")
|
|
337
|
+
|
|
338
|
+
# Verify polygon exists (we've already checked the count)
|
|
339
|
+
b_polygon = peerB.locator("path")
|
|
340
|
+
expect(b_polygon).to_be_visible()
|
|
341
|
+
|
|
342
|
+
# Optional: Verify polygon properties if you have any specific ones set
|
|
343
|
+
|
|
344
|
+
# Clean up: close edit mode
|
|
345
|
+
peerB.locator("body").press("Escape")
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
from django.core.management import call_command
|
|
3
|
+
|
|
4
|
+
from umap.models import Map
|
|
5
|
+
|
|
6
|
+
pytestmark = pytest.mark.django_db
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def test_can_delete_tilelayer_from_settings(map):
|
|
10
|
+
map.settings["properties"]["tilelayer"] = {
|
|
11
|
+
"name": "My TileLayer",
|
|
12
|
+
"maxZoom": 18,
|
|
13
|
+
"minZoom": 0,
|
|
14
|
+
"attribution": "My attribution",
|
|
15
|
+
"url_template": "http://{s}.foo.bar.baz/{z}/{x}/{y}.png",
|
|
16
|
+
}
|
|
17
|
+
map.save()
|
|
18
|
+
# Make sure its saved
|
|
19
|
+
map = Map.objects.get(pk=map.pk)
|
|
20
|
+
assert "tilelayer" in map.settings["properties"]
|
|
21
|
+
call_command(
|
|
22
|
+
"clean_tilelayer", "http://{s}.foo.bar.baz/{z}/{x}/{y}.png", "--no-input"
|
|
23
|
+
)
|
|
24
|
+
map = Map.objects.get(pk=map.pk)
|
|
25
|
+
assert "tilelayer" not in map.settings["properties"]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def test_can_replace_tilelayer_url_in_map_settings(map):
|
|
29
|
+
map.settings["properties"]["tilelayer"] = {
|
|
30
|
+
"name": "My TileLayer",
|
|
31
|
+
"maxZoom": 18,
|
|
32
|
+
"minZoom": 0,
|
|
33
|
+
"attribution": "My attribution",
|
|
34
|
+
"url_template": "http://{s}.foo.bar.baz/{z}/{x}/{y}.png",
|
|
35
|
+
}
|
|
36
|
+
map.save()
|
|
37
|
+
new = "https://{s}.foo.bar.baz/{z}/{x}/{y}.png"
|
|
38
|
+
call_command(
|
|
39
|
+
"clean_tilelayer",
|
|
40
|
+
"http://{s}.foo.bar.baz/{z}/{x}/{y}.png",
|
|
41
|
+
new,
|
|
42
|
+
"--no-input",
|
|
43
|
+
)
|
|
44
|
+
map = Map.objects.get(pk=map.pk)
|
|
45
|
+
assert map.settings["properties"]["tilelayer"]["url_template"] == new
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def test_can_replace_tilelayer_by_name_in_map_settings(map, tilelayer):
|
|
49
|
+
map.settings["properties"]["tilelayer"] = {
|
|
50
|
+
"name": "My TileLayer",
|
|
51
|
+
"maxZoom": 18,
|
|
52
|
+
"minZoom": 0,
|
|
53
|
+
"attribution": "My attribution",
|
|
54
|
+
"url_template": "http://{s}.foo.bar.baz/{z}/{x}/{y}.png",
|
|
55
|
+
}
|
|
56
|
+
map.save()
|
|
57
|
+
call_command(
|
|
58
|
+
"clean_tilelayer",
|
|
59
|
+
"http://{s}.foo.bar.baz/{z}/{x}/{y}.png",
|
|
60
|
+
tilelayer.name,
|
|
61
|
+
"--no-input",
|
|
62
|
+
)
|
|
63
|
+
map = Map.objects.get(pk=map.pk)
|
|
64
|
+
assert map.settings["properties"]["tilelayer"] == tilelayer.json
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def test_can_replace_tilelayer_by_id_in_map_settings(map, tilelayer):
|
|
68
|
+
map.settings["properties"]["tilelayer"] = {
|
|
69
|
+
"name": "My TileLayer",
|
|
70
|
+
"maxZoom": 18,
|
|
71
|
+
"minZoom": 0,
|
|
72
|
+
"attribution": "My attribution",
|
|
73
|
+
"url_template": "http://{s}.foo.bar.baz/{z}/{x}/{y}.png",
|
|
74
|
+
}
|
|
75
|
+
map.save()
|
|
76
|
+
call_command(
|
|
77
|
+
"clean_tilelayer",
|
|
78
|
+
"http://{s}.foo.bar.baz/{z}/{x}/{y}.png",
|
|
79
|
+
tilelayer.pk,
|
|
80
|
+
"--no-input",
|
|
81
|
+
)
|
|
82
|
+
map = Map.objects.get(pk=map.pk)
|
|
83
|
+
assert map.settings["properties"]["tilelayer"] == tilelayer.json
|
umap/tests/test_datalayer.py
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import tempfile
|
|
1
2
|
from pathlib import Path
|
|
2
3
|
|
|
3
4
|
import pytest
|
|
@@ -269,3 +270,26 @@ def test_anonymous_can_edit_in_inherit_mode_and_map_in_public_mode(
|
|
|
269
270
|
map.save()
|
|
270
271
|
fake_request.user = AnonymousUser()
|
|
271
272
|
assert datalayer.can_edit(fake_request)
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def test_should_remove_all_versions_on_delete(map, settings):
|
|
276
|
+
settings.UMAP_PURGATORY_ROOT = tempfile.mkdtemp()
|
|
277
|
+
datalayer = DataLayerFactory(uuid="0f1161c0-c07f-4ba4-86c5-8d8981d8a813", old_id=17)
|
|
278
|
+
root = Path(datalayer.storage_root())
|
|
279
|
+
before = len(datalayer.geojson.storage.listdir(root)[1])
|
|
280
|
+
other = "123456_1440918637.geojson"
|
|
281
|
+
files = [
|
|
282
|
+
f"{datalayer.pk}_1440924889.geojson",
|
|
283
|
+
f"{datalayer.pk}_1440923687.geojson",
|
|
284
|
+
f"{datalayer.pk}_1440918637.geojson",
|
|
285
|
+
f"{datalayer.old_id}_1440918537.geojson",
|
|
286
|
+
other,
|
|
287
|
+
]
|
|
288
|
+
for path in files:
|
|
289
|
+
datalayer.geojson.storage.save(root / path, ContentFile("{}"))
|
|
290
|
+
datalayer.geojson.storage.save(root / f"{path}.gz", ContentFile("{}"))
|
|
291
|
+
assert len(datalayer.geojson.storage.listdir(root)[1]) == 10 + before
|
|
292
|
+
datalayer.delete()
|
|
293
|
+
found = datalayer.geojson.storage.listdir(root)[1]
|
|
294
|
+
assert found == [other, f"{other}.gz"]
|
|
295
|
+
assert len(list(Path(settings.UMAP_PURGATORY_ROOT).iterdir())) == 4 + before
|
umap/tests/test_map_views.py
CHANGED
|
@@ -368,6 +368,7 @@ def test_anonymous_create(cookieclient, post_data):
|
|
|
368
368
|
assert (
|
|
369
369
|
created_map.get_anonymous_edit_url() in j["permissions"]["anonymous_edit_url"]
|
|
370
370
|
)
|
|
371
|
+
assert j["user"]["is_owner"] is True
|
|
371
372
|
assert created_map.name == name
|
|
372
373
|
key, value = created_map.signed_cookie_elements
|
|
373
374
|
assert key in cookieclient.cookies
|
|
@@ -860,3 +861,22 @@ def test_ogp_links(client, map, datalayer):
|
|
|
860
861
|
assert f'<meta property="og:title" content="{map.name}" />' in content
|
|
861
862
|
assert f'<meta property="og:description" content="{map.description}" />' in content
|
|
862
863
|
assert '<meta property="og:site_name" content="uMap" />' in content
|
|
864
|
+
|
|
865
|
+
|
|
866
|
+
def test_non_public_map_should_have_noindex_meta(client, map, datalayer):
|
|
867
|
+
map.share_status = Map.OPEN
|
|
868
|
+
map.save()
|
|
869
|
+
response = client.get(map.get_absolute_url())
|
|
870
|
+
assert response.status_code == 200
|
|
871
|
+
assert (
|
|
872
|
+
'<meta name="robots" content="noindex,nofollow">' in response.content.decode()
|
|
873
|
+
)
|
|
874
|
+
|
|
875
|
+
|
|
876
|
+
def test_demo_instance_should_have_noindex(client, map, datalayer, settings):
|
|
877
|
+
settings.UMAP_DEMO_SITE = True
|
|
878
|
+
response = client.get(map.get_absolute_url())
|
|
879
|
+
assert response.status_code == 200
|
|
880
|
+
assert (
|
|
881
|
+
'<meta name="robots" content="noindex,nofollow">' in response.content.decode()
|
|
882
|
+
)
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import tempfile
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from django.core.management import call_command
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def test_purge_purgatory(settings):
|
|
9
|
+
settings.UMAP_PURGATORY_ROOT = tempfile.mkdtemp()
|
|
10
|
+
root = Path(settings.UMAP_PURGATORY_ROOT)
|
|
11
|
+
old = root / "old.json"
|
|
12
|
+
old.write_text("{}")
|
|
13
|
+
stat = old.stat()
|
|
14
|
+
os.utime(old, times=(stat.st_mtime - 31 * 86400, stat.st_mtime - 31 * 86400))
|
|
15
|
+
recent = root / "recent.json"
|
|
16
|
+
recent.write_text("{}")
|
|
17
|
+
stat = recent.stat()
|
|
18
|
+
os.utime(recent, times=(stat.st_mtime - 8 * 86400, stat.st_mtime - 8 * 86400))
|
|
19
|
+
now = root / "now.json"
|
|
20
|
+
now.write_text("{}")
|
|
21
|
+
assert {f.name for f in root.iterdir()} == {"old.json", "recent.json", "now.json"}
|
|
22
|
+
call_command("purge_purgatory")
|
|
23
|
+
assert {f.name for f in root.iterdir()} == {"recent.json", "now.json"}
|
|
24
|
+
call_command("purge_purgatory", "--days=7")
|
|
25
|
+
assert {f.name for f in root.iterdir()} == {"now.json"}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
from umap.websocket_server import OperationMessage, PeerMessage, Request, ServerRequest
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def test_messages_are_parsed_correctly():
|
|
5
|
+
server = Request.model_validate(dict(kind="Server", action="list-peers")).root
|
|
6
|
+
assert type(server) is ServerRequest
|
|
7
|
+
|
|
8
|
+
operation = Request.model_validate(
|
|
9
|
+
dict(
|
|
10
|
+
kind="OperationMessage",
|
|
11
|
+
verb="upsert",
|
|
12
|
+
subject="map",
|
|
13
|
+
metadata={},
|
|
14
|
+
key="key",
|
|
15
|
+
)
|
|
16
|
+
).root
|
|
17
|
+
assert type(operation) is OperationMessage
|
|
18
|
+
|
|
19
|
+
peer_message = Request.model_validate(
|
|
20
|
+
dict(kind="PeerMessage", sender="Alice", recipient="Bob", message={})
|
|
21
|
+
).root
|
|
22
|
+
assert type(peer_message) is PeerMessage
|
umap/urls.py
CHANGED
|
@@ -115,11 +115,15 @@ i18n_urls += decorated_patterns(
|
|
|
115
115
|
name="map_star",
|
|
116
116
|
),
|
|
117
117
|
path("me", views.user_dashboard, name="user_dashboard"),
|
|
118
|
-
path("me/profile", views.user_profile, name="user_profile"),
|
|
119
118
|
path("me/download", views.user_download, name="user_download"),
|
|
120
119
|
path("me/teams", views.UserTeams.as_view(), name="user_teams"),
|
|
121
120
|
path("team/create/", views.TeamNew.as_view(), name="team_new"),
|
|
122
121
|
)
|
|
122
|
+
|
|
123
|
+
if settings.UMAP_ALLOW_EDIT_PROFILE:
|
|
124
|
+
i18n_urls.append(
|
|
125
|
+
path("me/profile", login_required(views.user_profile), name="user_profile")
|
|
126
|
+
)
|
|
123
127
|
i18n_urls += decorated_patterns(
|
|
124
128
|
[login_required, team_members_only],
|
|
125
129
|
path("team/<int:pk>/edit/", views.TeamUpdate.as_view(), name="team_update"),
|
umap/views.py
CHANGED
|
@@ -863,15 +863,17 @@ class MapCreate(FormLessEditMixin, PermissionsMixin, SessionMixin, CreateView):
|
|
|
863
863
|
form.instance.owner = self.request.user
|
|
864
864
|
self.object = form.save()
|
|
865
865
|
permissions = self.get_permissions()
|
|
866
|
+
user_data = self.get_user_data()
|
|
866
867
|
# User does not have the cookie yet.
|
|
867
868
|
if not self.object.owner:
|
|
868
869
|
anonymous_url = self.object.get_anonymous_edit_url()
|
|
869
870
|
permissions["anonymous_edit_url"] = anonymous_url
|
|
871
|
+
user_data["is_owner"] = True
|
|
870
872
|
response = simple_json_response(
|
|
871
873
|
id=self.object.pk,
|
|
872
874
|
url=self.object.get_absolute_url(),
|
|
873
875
|
permissions=permissions,
|
|
874
|
-
user=
|
|
876
|
+
user=user_data,
|
|
875
877
|
)
|
|
876
878
|
if not self.request.user.is_authenticated:
|
|
877
879
|
key, value = self.object.signed_cookie_elements
|
|
@@ -908,7 +910,7 @@ def get_websocket_auth_token(request, map_id, map_inst):
|
|
|
908
910
|
return simple_json_response(token=signed_token)
|
|
909
911
|
|
|
910
912
|
|
|
911
|
-
class MapUpdate(FormLessEditMixin, PermissionsMixin, UpdateView):
|
|
913
|
+
class MapUpdate(FormLessEditMixin, PermissionsMixin, SessionMixin, UpdateView):
|
|
912
914
|
model = Map
|
|
913
915
|
form_class = MapSettingsForm
|
|
914
916
|
pk_url_kwarg = "map_id"
|
|
@@ -920,6 +922,7 @@ class MapUpdate(FormLessEditMixin, PermissionsMixin, UpdateView):
|
|
|
920
922
|
id=self.object.pk,
|
|
921
923
|
url=self.object.get_absolute_url(),
|
|
922
924
|
permissions=self.get_permissions(),
|
|
925
|
+
user=self.get_user_data(),
|
|
923
926
|
)
|
|
924
927
|
|
|
925
928
|
|
|
@@ -1359,7 +1362,7 @@ def logout(request):
|
|
|
1359
1362
|
|
|
1360
1363
|
class LoginPopupEnd(TemplateView):
|
|
1361
1364
|
"""
|
|
1362
|
-
End of a
|
|
1365
|
+
End of a login process in popup.
|
|
1363
1366
|
Basically close the popup.
|
|
1364
1367
|
"""
|
|
1365
1368
|
|
umap/websocket_server.py
CHANGED
|
@@ -1,70 +1,173 @@
|
|
|
1
1
|
#!/usr/bin/env python
|
|
2
2
|
|
|
3
3
|
import asyncio
|
|
4
|
+
import logging
|
|
5
|
+
import uuid
|
|
4
6
|
from collections import defaultdict
|
|
5
|
-
from typing import Literal, Optional
|
|
7
|
+
from typing import Literal, Optional, Union
|
|
6
8
|
|
|
7
9
|
import websockets
|
|
8
10
|
from django.conf import settings
|
|
9
11
|
from django.core.signing import TimestampSigner
|
|
10
|
-
from pydantic import BaseModel, ValidationError
|
|
12
|
+
from pydantic import BaseModel, Field, RootModel, ValidationError
|
|
11
13
|
from websockets import WebSocketClientProtocol
|
|
12
14
|
from websockets.server import serve
|
|
13
15
|
|
|
14
|
-
|
|
16
|
+
|
|
17
|
+
class Connections:
|
|
18
|
+
def __init__(self) -> None:
|
|
19
|
+
self._connections: set[WebSocketClientProtocol] = set()
|
|
20
|
+
self._ids: dict[WebSocketClientProtocol, str] = dict()
|
|
21
|
+
|
|
22
|
+
def join(self, websocket: WebSocketClientProtocol) -> str:
|
|
23
|
+
self._connections.add(websocket)
|
|
24
|
+
_id = str(uuid.uuid4())
|
|
25
|
+
self._ids[websocket] = _id
|
|
26
|
+
return _id
|
|
27
|
+
|
|
28
|
+
def leave(self, websocket: WebSocketClientProtocol) -> None:
|
|
29
|
+
self._connections.remove(websocket)
|
|
30
|
+
del self._ids[websocket]
|
|
31
|
+
|
|
32
|
+
def get(self, id) -> WebSocketClientProtocol:
|
|
33
|
+
# use an iterator to stop iterating as soon as we found
|
|
34
|
+
return next(k for k, v in self._ids.items() if v == id)
|
|
35
|
+
|
|
36
|
+
def get_id(self, websocket: WebSocketClientProtocol):
|
|
37
|
+
return self._ids[websocket]
|
|
38
|
+
|
|
39
|
+
def get_other_peers(
|
|
40
|
+
self, websocket: WebSocketClientProtocol
|
|
41
|
+
) -> set[WebSocketClientProtocol]:
|
|
42
|
+
return self._connections - {websocket}
|
|
43
|
+
|
|
44
|
+
def get_all_peers(self) -> set[WebSocketClientProtocol]:
|
|
45
|
+
return self._connections
|
|
46
|
+
|
|
15
47
|
|
|
16
48
|
# Contains the list of websocket connections handled by this process.
|
|
17
49
|
# It's a mapping of map_id to a set of the active websocket connections
|
|
18
|
-
CONNECTIONS = defaultdict(
|
|
50
|
+
CONNECTIONS: defaultdict[int, Connections] = defaultdict(Connections)
|
|
19
51
|
|
|
20
52
|
|
|
21
|
-
class
|
|
22
|
-
kind:
|
|
53
|
+
class JoinRequest(BaseModel):
|
|
54
|
+
kind: Literal["JoinRequest"] = "JoinRequest"
|
|
23
55
|
token: str
|
|
24
56
|
|
|
25
57
|
|
|
26
58
|
class OperationMessage(BaseModel):
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
59
|
+
"""Message sent from one peer to all the others"""
|
|
60
|
+
|
|
61
|
+
kind: Literal["OperationMessage"] = "OperationMessage"
|
|
62
|
+
verb: Literal["upsert", "update", "delete"]
|
|
63
|
+
subject: Literal["map", "datalayer", "feature"]
|
|
30
64
|
metadata: Optional[dict] = None
|
|
31
65
|
key: Optional[str] = None
|
|
32
66
|
|
|
33
67
|
|
|
68
|
+
class PeerMessage(BaseModel):
|
|
69
|
+
"""Message sent from a specific peer to another one"""
|
|
70
|
+
|
|
71
|
+
kind: Literal["PeerMessage"] = "PeerMessage"
|
|
72
|
+
sender: str
|
|
73
|
+
recipient: str
|
|
74
|
+
# The message can be whatever the peers want. It's not checked by the server.
|
|
75
|
+
message: dict
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class ServerRequest(BaseModel):
|
|
79
|
+
"""A request towards the server"""
|
|
80
|
+
|
|
81
|
+
kind: Literal["Server"] = "Server"
|
|
82
|
+
action: Literal["list-peers"]
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class Request(RootModel):
|
|
86
|
+
"""Any message coming from the websocket should be one of these, and will be rejected otherwise."""
|
|
87
|
+
|
|
88
|
+
root: Union[ServerRequest, PeerMessage, OperationMessage] = Field(
|
|
89
|
+
discriminator="kind"
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class JoinResponse(BaseModel):
|
|
94
|
+
"""Server response containing the list of peers"""
|
|
95
|
+
|
|
96
|
+
kind: Literal["JoinResponse"] = "JoinResponse"
|
|
97
|
+
peers: list
|
|
98
|
+
uuid: str
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class ListPeersResponse(BaseModel):
|
|
102
|
+
kind: Literal["ListPeersResponse"] = "ListPeersResponse"
|
|
103
|
+
peers: list
|
|
104
|
+
|
|
105
|
+
|
|
34
106
|
async def join_and_listen(
|
|
35
107
|
map_id: int, permissions: list, user: str | int, websocket: WebSocketClientProtocol
|
|
36
108
|
):
|
|
37
|
-
"""Join a "room"
|
|
109
|
+
"""Join a "room" with other connected peers, and wait for messages."""
|
|
110
|
+
logging.debug(f"{user} joined room #{map_id}")
|
|
111
|
+
connections: Connections = CONNECTIONS[map_id]
|
|
112
|
+
_id: str = connections.join(websocket)
|
|
113
|
+
|
|
114
|
+
# Assign an ID to the joining peer and return it the list of connected peers.
|
|
115
|
+
peers: list[WebSocketClientProtocol] = [
|
|
116
|
+
connections.get_id(p) for p in connections.get_all_peers()
|
|
117
|
+
]
|
|
118
|
+
response = JoinResponse(uuid=_id, peers=peers)
|
|
119
|
+
await websocket.send(response.model_dump_json())
|
|
120
|
+
|
|
121
|
+
# Notify all other peers of the new list of connected peers.
|
|
122
|
+
message = ListPeersResponse(peers=peers)
|
|
123
|
+
websockets.broadcast(
|
|
124
|
+
connections.get_other_peers(websocket), message.model_dump_json()
|
|
125
|
+
)
|
|
38
126
|
|
|
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
127
|
try:
|
|
44
128
|
async for raw_message in websocket:
|
|
45
|
-
# recompute the peers
|
|
129
|
+
# recompute the peers list at the time of message-sending.
|
|
46
130
|
# as doing so beforehand would miss new connections
|
|
47
|
-
|
|
48
|
-
# Only relay valid "operation" messages
|
|
131
|
+
other_peers = connections.get_other_peers(websocket)
|
|
49
132
|
try:
|
|
50
|
-
|
|
51
|
-
websockets.broadcast(peers, raw_message)
|
|
133
|
+
incoming = Request.model_validate_json(raw_message)
|
|
52
134
|
except ValidationError as e:
|
|
53
|
-
error = f"An error occurred when receiving
|
|
54
|
-
|
|
135
|
+
error = f"An error occurred when receiving the following message: {raw_message!r}"
|
|
136
|
+
logging.error(error, e)
|
|
137
|
+
else:
|
|
138
|
+
match incoming.root:
|
|
139
|
+
# Broadcast all operation messages to connected peers
|
|
140
|
+
case OperationMessage():
|
|
141
|
+
websockets.broadcast(other_peers, raw_message)
|
|
142
|
+
|
|
143
|
+
# Send peer messages to the proper peer
|
|
144
|
+
case PeerMessage(recipient=_id):
|
|
145
|
+
peer = connections.get(_id)
|
|
146
|
+
if peer:
|
|
147
|
+
await peer.send(raw_message)
|
|
148
|
+
|
|
55
149
|
finally:
|
|
56
|
-
|
|
150
|
+
# On disconnect, remove the connection from the pool
|
|
151
|
+
connections.leave(websocket)
|
|
152
|
+
|
|
153
|
+
# TODO: refactor this in a separate method.
|
|
154
|
+
# Notify all other peers of the new list of connected peers.
|
|
155
|
+
peers = [connections.get_id(p) for p in connections.get_all_peers()]
|
|
156
|
+
message = ListPeersResponse(peers=peers)
|
|
157
|
+
websockets.broadcast(
|
|
158
|
+
connections.get_other_peers(websocket), message.model_dump_json()
|
|
159
|
+
)
|
|
57
160
|
|
|
58
161
|
|
|
59
|
-
async def handler(websocket):
|
|
162
|
+
async def handler(websocket: WebSocketClientProtocol):
|
|
60
163
|
"""Main WebSocket handler.
|
|
61
164
|
|
|
62
|
-
|
|
165
|
+
Check if the permission is granted and let the peer enter a room.
|
|
63
166
|
"""
|
|
64
167
|
raw_message = await websocket.recv()
|
|
65
168
|
|
|
66
169
|
# The first event should always be 'join'
|
|
67
|
-
message:
|
|
170
|
+
message: JoinRequest = JoinRequest.model_validate_json(raw_message)
|
|
68
171
|
signed = TimestampSigner().unsign_object(message.token, max_age=30)
|
|
69
172
|
user, map_id, permissions = signed.values()
|
|
70
173
|
|
|
@@ -73,7 +176,7 @@ async def handler(websocket):
|
|
|
73
176
|
await join_and_listen(map_id, permissions, user, websocket)
|
|
74
177
|
|
|
75
178
|
|
|
76
|
-
def run(host, port):
|
|
179
|
+
def run(host: str, port: int):
|
|
77
180
|
if not settings.WEBSOCKET_ENABLED:
|
|
78
181
|
msg = (
|
|
79
182
|
"WEBSOCKET_ENABLED should be set to True to run the WebSocket Server. "
|
|
@@ -86,7 +189,7 @@ def run(host, port):
|
|
|
86
189
|
|
|
87
190
|
async def _serve():
|
|
88
191
|
async with serve(handler, host, port):
|
|
89
|
-
|
|
192
|
+
logging.debug(f"Waiting for connections on {host}:{port}")
|
|
90
193
|
await asyncio.Future() # run forever
|
|
91
194
|
|
|
92
195
|
asyncio.run(_serve())
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.3
|
|
2
2
|
Name: umap-project
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.7.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,35 +19,39 @@ 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.1.
|
|
23
|
-
Requires-Dist: pillow==
|
|
24
|
-
Requires-Dist: psycopg==3.2.
|
|
25
|
-
Requires-Dist:
|
|
26
|
-
Requires-Dist: rcssmin==1.1.2
|
|
22
|
+
Requires-Dist: django==5.1.2
|
|
23
|
+
Requires-Dist: pillow==11.0.0
|
|
24
|
+
Requires-Dist: psycopg==3.2.3
|
|
25
|
+
Requires-Dist: rcssmin==1.1.3
|
|
27
26
|
Requires-Dist: requests==2.32.3
|
|
28
|
-
Requires-Dist: rjsmin==1.2.
|
|
27
|
+
Requires-Dist: rjsmin==1.2.3
|
|
29
28
|
Requires-Dist: social-auth-app-django==5.4.2
|
|
30
29
|
Requires-Dist: social-auth-core==4.5.4
|
|
31
|
-
Requires-Dist: websockets==13.0.1
|
|
32
30
|
Provides-Extra: dev
|
|
33
31
|
Requires-Dist: djlint==1.35.2; extra == 'dev'
|
|
34
|
-
Requires-Dist: hatch==1.
|
|
32
|
+
Requires-Dist: hatch==1.13.0; extra == 'dev'
|
|
35
33
|
Requires-Dist: isort==5.13.2; extra == 'dev'
|
|
36
|
-
Requires-Dist: mkdocs-material==9.5.
|
|
34
|
+
Requires-Dist: mkdocs-material==9.5.42; extra == 'dev'
|
|
37
35
|
Requires-Dist: mkdocs-static-i18n==1.2.3; extra == 'dev'
|
|
38
36
|
Requires-Dist: mkdocs==1.6.1; extra == 'dev'
|
|
39
|
-
Requires-Dist: pymdown-extensions==10.
|
|
40
|
-
Requires-Dist: ruff==0.
|
|
37
|
+
Requires-Dist: pymdown-extensions==10.11.2; extra == 'dev'
|
|
38
|
+
Requires-Dist: ruff==0.7.0; extra == 'dev'
|
|
41
39
|
Requires-Dist: vermin==1.6.0; extra == 'dev'
|
|
42
40
|
Provides-Extra: docker
|
|
43
|
-
Requires-Dist: uwsgi==2.0.
|
|
41
|
+
Requires-Dist: uwsgi==2.0.27; extra == 'docker'
|
|
42
|
+
Provides-Extra: sync
|
|
43
|
+
Requires-Dist: channels==4.1.0; extra == 'sync'
|
|
44
|
+
Requires-Dist: daphne==4.1.2; extra == 'sync'
|
|
45
|
+
Requires-Dist: pydantic==2.9.2; extra == 'sync'
|
|
46
|
+
Requires-Dist: websockets==13.1; extra == 'sync'
|
|
44
47
|
Provides-Extra: test
|
|
45
48
|
Requires-Dist: factory-boy==3.3.1; extra == 'test'
|
|
46
49
|
Requires-Dist: playwright>=1.39; extra == 'test'
|
|
47
50
|
Requires-Dist: pytest-django==4.9.0; extra == 'test'
|
|
48
51
|
Requires-Dist: pytest-playwright==0.5.2; extra == 'test'
|
|
52
|
+
Requires-Dist: pytest-rerunfailures==14.0; extra == 'test'
|
|
49
53
|
Requires-Dist: pytest-xdist<4,>=3.5.0; extra == 'test'
|
|
50
|
-
Requires-Dist: pytest==8.3.
|
|
54
|
+
Requires-Dist: pytest==8.3.3; extra == 'test'
|
|
51
55
|
Description-Content-Type: text/markdown
|
|
52
56
|
|
|
53
57
|
# uMap project
|