- {% if maps %}
+
+ {% if maps or request.GET.q %}
{% include "umap/map_table.html" %}
{% else %}
@@ -31,8 +52,9 @@
const CACHE = {}
for (const mapOpener of document.querySelectorAll("button.map-opener")) {
mapOpener.addEventListener('click', (event) => {
- event.target.nextElementSibling.showModal()
- const mapId = event.target.dataset.mapId
+ const button = event.target.closest('button')
+ button.nextElementSibling.showModal()
+ const mapId = button.dataset.mapId
if (!document.querySelector(`#${mapId}_target`).hasChildNodes()) {
const previewSettings = JSON.parse(document.getElementById(mapId).textContent)
const map = new L.U.Map(`${mapId}_target`, previewSettings)
diff --git a/umap/tests/integration/conftest.py b/umap/tests/integration/conftest.py
new file mode 100644
index 00000000..2b4ddca1
--- /dev/null
+++ b/umap/tests/integration/conftest.py
@@ -0,0 +1,18 @@
+import pytest
+
+
+@pytest.fixture
+def login(context, settings, live_server):
+ def do_login(user):
+ # TODO use storage state to do login only once per session
+ # https://playwright.dev/python/docs/auth
+ settings.ENABLE_ACCOUNT_LOGIN = True
+ page = context.new_page()
+ page.goto(f"{live_server.url}/en/")
+ page.locator(".login").click()
+ page.get_by_placeholder("Username").fill(user.username)
+ page.get_by_placeholder("Password").fill("123123")
+ page.locator('#login_form input[type="submit"]').click()
+ return page
+
+ return do_login
diff --git a/umap/tests/integration/test_dashboard.py b/umap/tests/integration/test_dashboard.py
new file mode 100644
index 00000000..654d0bc7
--- /dev/null
+++ b/umap/tests/integration/test_dashboard.py
@@ -0,0 +1,17 @@
+import pytest
+from playwright.sync_api import expect
+
+from umap.models import Map
+
+pytestmark = pytest.mark.django_db
+
+
+def test_owner_can_delete_map_after_confirmation(map, live_server, login):
+ page = login(map.owner)
+ page.goto(f"{live_server.url}/en/me")
+ delete_button = page.get_by_title("Delete")
+ expect(delete_button).to_be_visible()
+ page.on("dialog", lambda dialog: dialog.accept())
+ with page.expect_navigation():
+ delete_button.click()
+ assert Map.objects.all().count() == 0
diff --git a/umap/tests/integration/test_owned_map.py b/umap/tests/integration/test_owned_map.py
index a0767560..f6b84584 100644
--- a/umap/tests/integration/test_owned_map.py
+++ b/umap/tests/integration/test_owned_map.py
@@ -8,23 +8,6 @@ from umap.models import DataLayer, Map
pytestmark = pytest.mark.django_db
-@pytest.fixture
-def login(context, settings, live_server):
- def do_login(user):
- # TODO use storage state to do login only once per session
- # https://playwright.dev/python/docs/auth
- settings.ENABLE_ACCOUNT_LOGIN = True
- page = context.new_page()
- page.goto(f"{live_server.url}/en/")
- page.locator(".login").click()
- page.get_by_placeholder("Username").fill(user.username)
- page.get_by_placeholder("Password").fill("123123")
- page.locator('#login_form input[type="submit"]').click()
- return page
-
- return do_login
-
-
def test_map_update_with_owner(map, live_server, login):
page = login(map.owner)
page.goto(f"{live_server.url}{map.get_absolute_url()}")
diff --git a/umap/tests/test_map_views.py b/umap/tests/test_map_views.py
index 3b87fb0d..2670cecf 100644
--- a/umap/tests/test_map_views.py
+++ b/umap/tests/test_map_views.py
@@ -1,4 +1,6 @@
import json
+import zipfile
+from io import BytesIO
import pytest
from django.contrib.auth import get_user_model
@@ -8,7 +10,7 @@ from django.urls import reverse
from umap.models import DataLayer, Map, Star
-from .base import login_required
+from .base import MapFactory, UserFactory, login_required
pytestmark = pytest.mark.django_db
User = get_user_model()
@@ -107,7 +109,9 @@ def test_update(client, map, post_data):
def test_delete(client, map, datalayer):
url = reverse("map_delete", args=(map.pk,))
client.login(username=map.owner.username, password="123123")
- response = client.post(url, {}, follow=True)
+ response = client.post(
+ url, headers={"X-Requested-With": "XMLHttpRequest"}, follow=True
+ )
assert response.status_code == 200
assert not Map.objects.filter(pk=map.pk).exists()
assert not DataLayer.objects.filter(pk=datalayer.pk).exists()
@@ -156,9 +160,23 @@ def test_clone_map_should_create_a_new_instance(client, map):
url = reverse("map_clone", kwargs={"map_id": map.pk})
client.login(username=map.owner.username, password="123123")
response = client.post(url)
+ assert response.status_code == 302
+ assert Map.objects.count() == 2
+ clone = Map.objects.latest("pk")
+ assert response["Location"] == clone.get_absolute_url()
+ assert clone.pk != map.pk
+ assert clone.name == "Clone of " + map.name
+
+
+def test_clone_map_should_be_possible_via_ajax(client, map):
+ assert Map.objects.count() == 1
+ url = reverse("map_clone", kwargs={"map_id": map.pk})
+ client.login(username=map.owner.username, password="123123")
+ response = client.post(url, headers={"X-Requested-With": "XMLHttpRequest"})
assert response.status_code == 200
assert Map.objects.count() == 2
clone = Map.objects.latest("pk")
+ assert response.json() == {"redirect": clone.get_absolute_url()}
assert clone.pk != map.pk
assert clone.name == "Clone of " + map.name
@@ -189,7 +207,7 @@ def test_clone_should_set_cloner_as_owner(client, map, user):
map.save()
client.login(username=user.username, password="123123")
response = client.post(url)
- assert response.status_code == 200
+ assert response.status_code == 302
assert Map.objects.count() == 2
clone = Map.objects.latest("pk")
assert clone.pk != map.pk
@@ -296,7 +314,9 @@ def test_only_owner_can_delete(client, map, user):
map.editors.add(user)
url = reverse("map_delete", kwargs={"map_id": map.pk})
client.login(username=user.username, password="123123")
- response = client.post(url, {}, follow=True)
+ response = client.post(
+ url, headers={"X-Requested-With": "XMLHttpRequest"}, follow=True
+ )
assert response.status_code == 403
@@ -368,7 +388,9 @@ def test_anonymous_update_with_cookie_should_work(cookieclient, anonymap, post_d
@pytest.mark.usefixtures("allow_anonymous")
def test_anonymous_delete(cookieclient, anonymap):
url = reverse("map_delete", args=(anonymap.pk,))
- response = cookieclient.post(url, {}, follow=True)
+ response = cookieclient.post(
+ url, headers={"X-Requested-With": "XMLHttpRequest"}, follow=True
+ )
assert response.status_code == 200
assert not Map.objects.filter(pk=anonymap.pk).count()
# Test response is a json
@@ -379,7 +401,9 @@ def test_anonymous_delete(cookieclient, anonymap):
@pytest.mark.usefixtures("allow_anonymous")
def test_no_cookie_cant_delete(client, anonymap):
url = reverse("map_delete", args=(anonymap.pk,))
- response = client.post(url, {}, follow=True)
+ response = client.post(
+ url, headers={"X-Requested-With": "XMLHttpRequest"}, follow=True
+ )
assert response.status_code == 403
@@ -440,9 +464,10 @@ def test_clone_map_should_be_possible_if_edit_status_is_anonymous(client, anonym
anonymap.edit_status = anonymap.ANONYMOUS
anonymap.save()
response = client.post(url)
- assert response.status_code == 200
+ assert response.status_code == 302
assert Map.objects.count() == 2
clone = Map.objects.latest("pk")
+ assert response["Location"] == clone.get_absolute_url()
assert clone.pk != anonymap.pk
assert clone.name == "Clone of " + anonymap.name
assert clone.owner is None
@@ -656,6 +681,64 @@ def test_download(client, map, datalayer):
]
+def test_download_multiple_maps(client, map, datalayer):
+ map.share_status = Map.PRIVATE
+ map.save()
+ another_map = MapFactory(
+ owner=map.owner, name="Another map", share_status=Map.PUBLIC
+ )
+ client.login(username=map.owner.username, password="123123")
+ url = reverse("user_download")
+ response = client.get(f"{url}?map_id={map.id}&map_id={another_map.id}")
+ assert response.status_code == 200
+ with zipfile.ZipFile(file=BytesIO(response.content), mode="r") as f:
+ assert len(f.infolist()) == 2
+ assert f.infolist()[0].filename == f"umap_backup_test-map_{another_map.id}.umap"
+ assert f.infolist()[1].filename == f"umap_backup_test-map_{map.id}.umap"
+ with f.open(f.infolist()[1]) as umap_file:
+ umapjson = json.loads(umap_file.read().decode())
+ assert list(umapjson.keys()) == [
+ "type",
+ "geometry",
+ "properties",
+ "uri",
+ "layers",
+ ]
+ assert umapjson["type"] == "umap"
+ assert umapjson["uri"] == f"http://testserver/en/map/test-map_{map.id}"
+
+
+def test_download_multiple_maps_unauthorized(client, map, datalayer):
+ map.share_status = Map.PRIVATE
+ map.save()
+ user1 = UserFactory(username="user1")
+ another_map = MapFactory(owner=user1, name="Another map", share_status=Map.PUBLIC)
+ client.login(username=map.owner.username, password="123123")
+ url = reverse("user_download")
+ response = client.get(f"{url}?map_id={map.id}&map_id={another_map.id}")
+ assert response.status_code == 200
+ with zipfile.ZipFile(file=BytesIO(response.content), mode="r") as f:
+ assert len(f.infolist()) == 1
+ assert f.infolist()[0].filename == f"umap_backup_test-map_{map.id}.umap"
+
+
+def test_download_multiple_maps_editor(client, map, datalayer):
+ map.share_status = Map.PRIVATE
+ map.save()
+ user1 = UserFactory(username="user1")
+ another_map = MapFactory(owner=user1, name="Another map", share_status=Map.PUBLIC)
+ another_map.editors.add(map.owner)
+ another_map.save()
+ client.login(username=map.owner.username, password="123123")
+ url = reverse("user_download")
+ response = client.get(f"{url}?map_id={map.id}&map_id={another_map.id}")
+ assert response.status_code == 200
+ with zipfile.ZipFile(file=BytesIO(response.content), mode="r") as f:
+ assert len(f.infolist()) == 2
+ assert f.infolist()[0].filename == f"umap_backup_test-map_{another_map.id}.umap"
+ assert f.infolist()[1].filename == f"umap_backup_test-map_{map.id}.umap"
+
+
@pytest.mark.parametrize("share_status", [Map.PRIVATE, Map.BLOCKED])
def test_download_shared_status_map(client, map, datalayer, share_status):
map.share_status = share_status
diff --git a/umap/urls.py b/umap/urls.py
index a62c1efc..11f752f3 100644
--- a/umap/urls.py
+++ b/umap/urls.py
@@ -110,16 +110,9 @@ i18n_urls += decorated_patterns(
views.ToggleMapStarStatus.as_view(),
name="map_star",
),
- re_path(
- r"^me$",
- views.user_dashboard,
- name="user_dashboard",
- ),
- re_path(
- r"^me/profile$",
- views.user_profile,
- name="user_profile",
- ),
+ re_path(r"^me$", views.user_dashboard, name="user_dashboard"),
+ re_path(r"^me/profile$", views.user_profile, name="user_profile"),
+ re_path(r"^me/download$", views.user_download, name="user_download"),
)
map_urls = [
re_path(
diff --git a/umap/views.py b/umap/views.py
index 0d670058..cfdeee87 100644
--- a/umap/views.py
+++ b/umap/views.py
@@ -1,8 +1,10 @@
+import io
import json
import mimetypes
import os
import re
import socket
+import zipfile
from datetime import datetime, timedelta
from http.client import InvalidURL
from io import BytesIO
@@ -288,20 +290,44 @@ class UserDashboard(PaginatorMixin, DetailView, SearchMixin):
return qs.order_by("-modified_at")
def get_context_data(self, **kwargs):
- kwargs.update(
- {
- "q": self.request.GET.get("q"),
- "maps": self.paginate(
- self.get_maps(), settings.UMAP_MAPS_PER_PAGE_OWNER
- ),
- }
- )
+ page = self.paginate(self.get_maps(), settings.UMAP_MAPS_PER_PAGE_OWNER)
+ kwargs.update({"q": self.request.GET.get("q"), "maps": page})
return super().get_context_data(**kwargs)
user_dashboard = UserDashboard.as_view()
+class UserDownload(DetailView, SearchMixin):
+ model = User
+
+ def get_object(self):
+ return self.get_queryset().get(pk=self.request.user.pk)
+
+ def get_maps(self):
+ qs = Map.objects.filter(id__in=self.request.GET.getlist("map_id"))
+ qs = qs.filter(owner=self.object).union(qs.filter(editors=self.object))
+ return qs.order_by("-modified_at")
+
+ def render_to_response(self, context, *args, **kwargs):
+ zip_buffer = io.BytesIO()
+ with zipfile.ZipFile(zip_buffer, "a", zipfile.ZIP_DEFLATED, False) as zip_file:
+ for map_ in self.get_maps():
+ umapjson = map_.generate_umapjson(self.request)
+ geojson_file = io.StringIO(json.dumps(umapjson))
+ file_name = f"umap_backup_{map_.slug}_{map_.pk}.umap"
+ zip_file.writestr(file_name, geojson_file.getvalue())
+
+ response = HttpResponse(zip_buffer.getvalue(), content_type="application/zip")
+ response[
+ "Content-Disposition"
+ ] = 'attachment; filename="umap_backup_complete.zip"'
+ return response
+
+
+user_download = UserDownload.as_view()
+
+
class MapsShowCase(View):
def get(self, *args, **kwargs):
maps = Map.public.filter(center__distance_gt=(DEFAULT_CENTER, D(km=1)))
@@ -637,18 +663,8 @@ class MapDownload(DetailView):
return reverse("map_download", args=(self.object.pk,))
def render_to_response(self, context, *args, **kwargs):
- geojson = self.object.settings
- geojson["type"] = "umap"
- geojson["uri"] = self.request.build_absolute_uri(self.object.get_absolute_url())
- datalayers = []
- for datalayer in self.object.datalayer_set.all():
- with open(datalayer.geojson.path, "rb") as f:
- layer = json.loads(f.read())
- if datalayer.settings:
- layer["_umap_options"] = datalayer.settings
- datalayers.append(layer)
- geojson["layers"] = datalayers
- response = simple_json_response(**geojson)
+ umapjson = self.object.generate_umapjson(self.request)
+ response = simple_json_response(**umapjson)
response[
"Content-Disposition"
] = f'attachment; filename="umap_backup_{self.object.slug}.umap"'
@@ -845,7 +861,11 @@ class MapDelete(DeleteView):
if not self.object.can_delete(self.request.user, self.request):
return HttpResponseForbidden(_("Only its owner can delete the map."))
self.object.delete()
- return simple_json_response(redirect="/")
+ home_url = reverse("home")
+ if is_ajax(self.request):
+ return simple_json_response(redirect=home_url)
+ else:
+ return HttpResponseRedirect(form.data.get("next") or home_url)
class MapClone(PermissionsMixin, View):
@@ -857,7 +877,10 @@ class MapClone(PermissionsMixin, View):
return HttpResponseForbidden()
owner = self.request.user if self.request.user.is_authenticated else None
self.object = kwargs["map_inst"].clone(owner=owner)
- response = simple_json_response(redirect=self.object.get_absolute_url())
+ if is_ajax(self.request):
+ response = simple_json_response(redirect=self.object.get_absolute_url())
+ else:
+ response = HttpResponseRedirect(self.object.get_absolute_url())
if not self.request.user.is_authenticated:
key, value = self.object.signed_cookie_elements
response.set_signed_cookie(