umap-project 2.3.1__py3-none-any.whl → 2.4.0b0__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.
- umap/.DS_Store +0 -0
- umap/__init__.py +1 -1
- umap/locale/en/LC_MESSAGES/django.po +81 -31
- umap/locale/fr/LC_MESSAGES/django.mo +0 -0
- umap/locale/fr/LC_MESSAGES/django.po +109 -59
- umap/management/commands/run_websocket_server.py +23 -0
- umap/models.py +6 -1
- umap/settings/base.py +11 -3
- umap/static/.DS_Store +0 -0
- umap/static/umap/.DS_Store +0 -0
- umap/static/umap/base.css +53 -162
- umap/static/umap/content.css +3 -2
- umap/static/umap/css/dialog.css +18 -0
- umap/static/umap/css/icon.css +8 -0
- umap/static/umap/css/importers.css +44 -0
- umap/static/umap/css/panel.css +19 -57
- umap/static/umap/css/tooltip.css +59 -0
- umap/static/umap/css/window.css +35 -0
- umap/static/umap/favicons/.DS_Store +0 -0
- umap/static/umap/fonts/.DS_Store +0 -0
- umap/static/umap/img/.DS_Store +0 -0
- umap/static/umap/img/alert-icon-error.svg +8 -0
- umap/static/umap/img/alert-icon-info.svg +4 -0
- umap/static/umap/img/alert-icon-success.svg +3 -0
- umap/static/umap/img/icon-external-link.svg +3 -0
- umap/static/umap/img/importers/communesfr.svg +5 -0
- umap/static/umap/img/importers/datasets.svg +13 -0
- umap/static/umap/img/importers/geodatamine.svg +10 -0
- umap/static/umap/img/importers/overpass.svg +7 -0
- umap/static/umap/img/importers/random.svg +18 -0
- umap/static/umap/img/importers/random1.svg +4 -0
- umap/static/umap/img/importers/random2.svg +4 -0
- umap/static/umap/img/source/.DS_Store +0 -0
- umap/static/umap/js/components/alerts/alert.css +160 -0
- umap/static/umap/js/components/alerts/alert.js +169 -0
- umap/static/umap/js/components/base.js +54 -0
- umap/static/umap/js/modules/autocomplete.js +347 -0
- umap/static/umap/js/modules/browser.js +1 -1
- umap/static/umap/js/modules/caption.js +4 -3
- umap/static/umap/js/modules/global.js +36 -12
- umap/static/umap/js/modules/help.js +255 -0
- umap/static/umap/js/modules/importer.js +280 -0
- umap/static/umap/js/modules/importers/communesfr.js +44 -0
- umap/static/umap/js/modules/importers/datasets.js +41 -0
- umap/static/umap/js/modules/importers/geodatamine.js +95 -0
- umap/static/umap/js/modules/importers/overpass.js +84 -0
- umap/static/umap/js/modules/request.js +12 -14
- umap/static/umap/js/modules/rules.js +241 -0
- umap/static/umap/js/modules/schema.js +63 -14
- umap/static/umap/js/modules/sync/engine.js +93 -0
- umap/static/umap/js/modules/sync/updaters.js +109 -0
- umap/static/umap/js/modules/sync/websocket.js +25 -0
- umap/static/umap/js/modules/ui/dialog.js +52 -0
- umap/static/umap/js/modules/{panel.js → ui/panel.js} +25 -14
- umap/static/umap/js/modules/ui/tooltip.js +116 -0
- umap/static/umap/js/modules/utils.js +25 -18
- umap/static/umap/js/umap.controls.js +13 -14
- umap/static/umap/js/umap.core.js +1 -324
- umap/static/umap/js/umap.features.js +67 -27
- umap/static/umap/js/umap.forms.js +9 -13
- umap/static/umap/js/umap.js +220 -180
- umap/static/umap/js/umap.layer.js +142 -74
- umap/static/umap/js/umap.permissions.js +5 -9
- umap/static/umap/js/umap.tableeditor.js +8 -8
- umap/static/umap/locale/am_ET.js +51 -16
- umap/static/umap/locale/am_ET.json +51 -16
- umap/static/umap/locale/ar.js +51 -16
- umap/static/umap/locale/ar.json +51 -16
- umap/static/umap/locale/ast.js +51 -16
- umap/static/umap/locale/ast.json +51 -16
- umap/static/umap/locale/bg.js +51 -16
- umap/static/umap/locale/bg.json +51 -16
- umap/static/umap/locale/br.js +55 -20
- umap/static/umap/locale/br.json +55 -20
- umap/static/umap/locale/ca.js +51 -16
- umap/static/umap/locale/ca.json +51 -16
- umap/static/umap/locale/cs_CZ.js +93 -58
- umap/static/umap/locale/cs_CZ.json +93 -58
- umap/static/umap/locale/da.js +51 -16
- umap/static/umap/locale/da.json +51 -16
- umap/static/umap/locale/de.js +56 -21
- umap/static/umap/locale/de.json +56 -21
- umap/static/umap/locale/el.js +51 -16
- umap/static/umap/locale/el.json +51 -16
- umap/static/umap/locale/en.js +51 -16
- umap/static/umap/locale/en.json +51 -16
- umap/static/umap/locale/en_US.json +51 -16
- umap/static/umap/locale/es.js +51 -16
- umap/static/umap/locale/es.json +51 -16
- umap/static/umap/locale/et.js +51 -16
- umap/static/umap/locale/et.json +51 -16
- umap/static/umap/locale/eu.js +51 -16
- umap/static/umap/locale/eu.json +51 -16
- umap/static/umap/locale/fa_IR.js +51 -16
- umap/static/umap/locale/fa_IR.json +51 -16
- umap/static/umap/locale/fi.js +51 -16
- umap/static/umap/locale/fi.json +51 -16
- umap/static/umap/locale/fr.js +52 -17
- umap/static/umap/locale/fr.json +52 -17
- umap/static/umap/locale/gl.js +51 -16
- umap/static/umap/locale/gl.json +51 -16
- umap/static/umap/locale/he.js +51 -16
- umap/static/umap/locale/he.json +51 -16
- umap/static/umap/locale/hr.js +51 -16
- umap/static/umap/locale/hr.json +51 -16
- umap/static/umap/locale/hu.js +51 -16
- umap/static/umap/locale/hu.json +51 -16
- umap/static/umap/locale/id.js +51 -16
- umap/static/umap/locale/id.json +51 -16
- umap/static/umap/locale/is.js +51 -16
- umap/static/umap/locale/is.json +51 -16
- umap/static/umap/locale/it.js +51 -16
- umap/static/umap/locale/it.json +51 -16
- umap/static/umap/locale/ja.js +51 -16
- umap/static/umap/locale/ja.json +51 -16
- umap/static/umap/locale/ko.js +51 -16
- umap/static/umap/locale/ko.json +51 -16
- umap/static/umap/locale/lt.js +51 -16
- umap/static/umap/locale/lt.json +51 -16
- umap/static/umap/locale/ms.js +51 -16
- umap/static/umap/locale/ms.json +51 -16
- umap/static/umap/locale/nl.js +51 -16
- umap/static/umap/locale/nl.json +51 -16
- umap/static/umap/locale/no.js +51 -16
- umap/static/umap/locale/no.json +51 -16
- umap/static/umap/locale/pl.js +93 -58
- umap/static/umap/locale/pl.json +93 -58
- umap/static/umap/locale/pl_PL.json +51 -16
- umap/static/umap/locale/pt.js +215 -180
- umap/static/umap/locale/pt.json +215 -180
- umap/static/umap/locale/pt_BR.js +51 -16
- umap/static/umap/locale/pt_BR.json +51 -16
- umap/static/umap/locale/pt_PT.js +51 -16
- umap/static/umap/locale/pt_PT.json +51 -16
- umap/static/umap/locale/ro.js +51 -16
- umap/static/umap/locale/ro.json +51 -16
- umap/static/umap/locale/ru.js +51 -16
- umap/static/umap/locale/ru.json +51 -16
- umap/static/umap/locale/si.js +51 -16
- umap/static/umap/locale/si.json +51 -16
- umap/static/umap/locale/sk_SK.js +51 -16
- umap/static/umap/locale/sk_SK.json +51 -16
- umap/static/umap/locale/sl.js +51 -16
- umap/static/umap/locale/sl.json +51 -16
- umap/static/umap/locale/sr.js +51 -16
- umap/static/umap/locale/sr.json +51 -16
- umap/static/umap/locale/sv.js +51 -16
- umap/static/umap/locale/sv.json +51 -16
- umap/static/umap/locale/th_TH.js +51 -16
- umap/static/umap/locale/th_TH.json +51 -16
- umap/static/umap/locale/tr.js +51 -16
- umap/static/umap/locale/tr.json +51 -16
- umap/static/umap/locale/uk_UA.js +51 -16
- umap/static/umap/locale/uk_UA.json +51 -16
- umap/static/umap/locale/vi.js +51 -16
- umap/static/umap/locale/vi.json +51 -16
- umap/static/umap/locale/vi_VN.json +51 -16
- umap/static/umap/locale/zh.js +51 -16
- umap/static/umap/locale/zh.json +51 -16
- umap/static/umap/locale/zh_CN.json +51 -16
- umap/static/umap/locale/zh_TW.Big5.json +51 -16
- umap/static/umap/locale/zh_TW.js +51 -16
- umap/static/umap/locale/zh_TW.json +51 -16
- umap/static/umap/map.css +27 -41
- umap/static/umap/unittests/sync.js +105 -0
- umap/static/umap/unittests/utils.js +76 -34
- umap/static/umap/vars.css +18 -1
- umap/static/umap/vendors/dompurify/purify.es.js +5 -59
- umap/static/umap/vendors/dompurify/purify.es.mjs.map +1 -1
- umap/templates/umap/components/alerts/alert.html +89 -0
- umap/templates/umap/content.html +4 -3
- umap/templates/umap/css.html +4 -0
- umap/templates/umap/home.html +3 -0
- umap/templates/umap/js.html +0 -3
- umap/templates/umap/map_init.html +2 -8
- umap/templates/umap/messages.html +9 -11
- umap/templates/umap/search.html +3 -0
- umap/tests/.DS_Store +0 -0
- umap/tests/base.py +2 -0
- umap/tests/integration/.DS_Store +0 -0
- umap/tests/integration/conftest.py +30 -0
- umap/tests/integration/test_anonymous_owned_map.py +8 -13
- umap/tests/integration/test_browser.py +1 -1
- umap/tests/integration/test_conditional_rules.py +201 -0
- umap/tests/integration/test_dashboard.py +1 -1
- umap/tests/integration/test_datalayer.py +2 -3
- umap/tests/integration/test_edit_datalayer.py +4 -4
- umap/tests/integration/test_edit_map.py +1 -1
- umap/tests/integration/test_facets_browser.py +3 -3
- umap/tests/integration/test_import.py +138 -49
- umap/tests/integration/test_map.py +2 -2
- umap/tests/integration/{test_collaborative_editing.py → test_optimistic_merge.py} +7 -7
- umap/tests/integration/test_owned_map.py +1 -1
- umap/tests/integration/test_picto.py +2 -2
- umap/tests/integration/test_statics.py +1 -1
- umap/tests/integration/test_websocket_sync.py +283 -0
- umap/tests/settings.py +5 -0
- umap/tests/test_datalayer_views.py +0 -1
- umap/tests/test_views.py +53 -0
- umap/urls.py +5 -0
- umap/views.py +40 -11
- umap/websocket_server.py +92 -0
- {umap_project-2.3.1.dist-info → umap_project-2.4.0b0.dist-info}/METADATA +11 -9
- {umap_project-2.3.1.dist-info → umap_project-2.4.0b0.dist-info}/RECORD +207 -164
- {umap_project-2.3.1.dist-info → umap_project-2.4.0b0.dist-info}/WHEEL +1 -1
- umap/static/umap/js/umap.autocomplete.js +0 -341
- umap/static/umap/js/umap.importer.js +0 -187
- umap/static/umap/js/umap.ui.js +0 -190
- {umap_project-2.3.1.dist-info → umap_project-2.4.0b0.dist-info}/entry_points.txt +0 -0
- {umap_project-2.3.1.dist-info → umap_project-2.4.0b0.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
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
|
-
|
|
628
|
-
for
|
|
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
|
-
"
|
|
855
|
-
|
|
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())
|
umap/websocket_server.py
ADDED
|
@@ -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
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.1
|
|
2
2
|
Name: umap-project
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.4.0b0
|
|
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>
|
|
@@ -21,31 +21,33 @@ Requires-Dist: django-environ==0.11.2
|
|
|
21
21
|
Requires-Dist: django-probes==1.7.0
|
|
22
22
|
Requires-Dist: django==5.0.6
|
|
23
23
|
Requires-Dist: pillow==10.3.0
|
|
24
|
-
Requires-Dist: psycopg==3.1.
|
|
24
|
+
Requires-Dist: psycopg==3.1.19
|
|
25
|
+
Requires-Dist: pydantic==2.7.3
|
|
25
26
|
Requires-Dist: rcssmin==1.1.2
|
|
26
|
-
Requires-Dist: requests==2.
|
|
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.
|
|
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.
|
|
36
|
+
Requires-Dist: mkdocs-material==9.5.26; extra == 'dev'
|
|
35
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.
|
|
40
|
+
Requires-Dist: ruff==0.4.8; extra == 'dev'
|
|
39
41
|
Requires-Dist: vermin==1.6.0; extra == 'dev'
|
|
40
42
|
Provides-Extra: docker
|
|
41
|
-
Requires-Dist: uwsgi==2.0.
|
|
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
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.
|
|
50
|
+
Requires-Dist: pytest==8.2.2; extra == 'test'
|
|
49
51
|
Description-Content-Type: text/markdown
|
|
50
52
|
|
|
51
53
|
# uMap project
|