diff --git a/umap/models.py b/umap/models.py index f8c31238..4bff5f75 100644 --- a/umap/models.py +++ b/umap/models.py @@ -1,3 +1,4 @@ +import json import os import time @@ -222,6 +223,20 @@ class Map(NamedModel): ) return map_settings + def generate_umapjson(self, request): + umapjson = self.settings + umapjson["type"] = "umap" + umapjson["uri"] = request.build_absolute_uri(self.get_absolute_url()) + datalayers = [] + for datalayer in self.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) + umapjson["layers"] = datalayers + return umapjson + def get_absolute_url(self): return reverse("map", kwargs={"slug": self.slug or "map", "map_id": self.pk}) diff --git a/umap/static/umap/content.css b/umap/static/umap/content.css index 55fb1c98..7b72ecd3 100644 --- a/umap/static/umap/content.css +++ b/umap/static/umap/content.css @@ -144,16 +144,19 @@ body.login header { } h2.section { text-transform: uppercase; - color: #666; - text-align: center; + color: #263B58; padding-top: 28px; } h2.tabs a { - color: #666; + color: #263B58; + text-decoration: underline; + text-decoration-thickness: 3px; + margin-right: 2rem; } h2.tabs a:not(.selected) { font-weight: normal; - color: #666; + color: #263B58; + text-decoration: none; } h2.tabs a:hover { text-decoration: underline; @@ -310,14 +313,135 @@ ul.umap-autocomplete { /* **************************** */ /* Dashboard */ /* **************************** */ +/* https://kittygiraudel.com/2020/12/03/a11y-advent-hiding-content/ */ +.sr-only { + border: 0 !important; + clip: rect(1px, 1px, 1px, 1px) !important; + -webkit-clip-path: inset(50%) !important; + clip-path: inset(50%) !important; + height: 1px !important; + overflow: hidden !important; + margin: -1px !important; + padding: 0 !important; + position: absolute !important; + width: 1px !important; + white-space: nowrap !important; +} +/* https://kittygiraudel.com/2020/12/06/a11y-advent-skip-to-content/ */ +.sr-only.sr-only--focusable:focus, +.sr-only.sr-only--focusable:active { + clip: auto !important; + -webkit-clip-path: auto !important; + clip-path: auto !important; + height: auto !important; + overflow: visible !important; + width: auto !important; + white-space: normal !important; +} +.icon-dashboard { + display: inline-block; + height: 36px; + width: 36px; + margin: 3px; +} +.icon-view { + background-image: url('./img/icon-view.svg'); +} +.icon-share { + background-image: url('./img/icon-share.svg'); +} +.icon-edit { + background-image: url('./img/icon-edit.svg'); +} +.icon-download { + background-image: url('./img/icon-download.svg'); +} +.icon-duplicate { + background-image: url('./img/icon-duplicate.svg'); +} +.icon-delete { + background-image: url('./img/icon-delete.svg'); +} +.table-header { + display: flex; + justify-content: space-between; + align-items: flex-end; + margin-bottom: 1rem; +} +.table-header form { + display: flex; + align-items: flex-end; +} +.table-header form input { + border: 2px solid #263B58; + border-radius: 0; + padding: .5rem 1rem; + margin-bottom: 0; + line-height: inherit; + height: 2.5rem; +} +.table-header form input[type="search"] { + width: 30ch; +} +.table-header form input[type="submit"] { + background-color: #263B58; + color: white; + font-weight: bold; +} + +.table-header .button-download { + width: inherit; + display: inline; + padding: .5rem 1rem; + border: 2px solid #263B58; + color: #263B58; + font-weight: bold; + background-color: initial; + margin-bottom: 0; + line-height: inherit; + height: 2.5rem; +} + table.maps { width: 100%; + border-collapse: collapse; } table.maps .map_fragment { display: block; height: 80vh; width: 100%; } +table.maps a, +table.maps thead { + color: #263B58; +} +table.maps a:not(.icon-link) { + text-decoration: underline; +} +table.maps button.map-icon { + padding: 0; + border: none; + background: transparent; +} +table.maps form { + display: inline; +} +table.maps input[type="submit"] { + display: inline; + background-color: transparent; + color: #263B58; + padding: 0; + width: inherit; + height: 1rem; + margin: 0; + line-height: inherit; +} +table.maps tbody tr { + border-bottom: 1px solid #BDC7D4; +} +table.maps tbody tr td { + padding: 5px 4px; +} table.maps tbody tr:nth-child(odd) { background-color: #f4f4f4; } @@ -331,7 +455,7 @@ table.maps .button { margin-bottom: 2px; padding:4px 6px; height: 36px; - line-height: 23px; + line-height: 26px; } /* **************************** */ @@ -357,12 +481,14 @@ dialog::backdrop { display: flex; flex-direction: row; justify-content: space-around; - margin: 1rem; - border-top: 1px solid gray; } .pagination > * { padding: 1rem; } +.pagination a { + color: #263B58; + text-decoration: underline; +} /* ************************************************* */ diff --git a/umap/static/umap/img/icon-delete.svg b/umap/static/umap/img/icon-delete.svg new file mode 100644 index 00000000..dbf47ce0 --- /dev/null +++ b/umap/static/umap/img/icon-delete.svg @@ -0,0 +1,4 @@ + + + + diff --git a/umap/static/umap/img/icon-download.svg b/umap/static/umap/img/icon-download.svg new file mode 100644 index 00000000..9b3ef499 --- /dev/null +++ b/umap/static/umap/img/icon-download.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/umap/static/umap/img/icon-duplicate.svg b/umap/static/umap/img/icon-duplicate.svg new file mode 100644 index 00000000..24689735 --- /dev/null +++ b/umap/static/umap/img/icon-duplicate.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/umap/static/umap/img/icon-edit.svg b/umap/static/umap/img/icon-edit.svg new file mode 100644 index 00000000..ceb1157d --- /dev/null +++ b/umap/static/umap/img/icon-edit.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/umap/static/umap/img/icon-share.svg b/umap/static/umap/img/icon-share.svg new file mode 100644 index 00000000..6cd1a1f0 --- /dev/null +++ b/umap/static/umap/img/icon-share.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/umap/static/umap/img/icon-view.svg b/umap/static/umap/img/icon-view.svg new file mode 100644 index 00000000..25fcef70 --- /dev/null +++ b/umap/static/umap/img/icon-view.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/umap/templates/auth/user_form.html b/umap/templates/auth/user_form.html index 088b3be5..88f98328 100644 --- a/umap/templates/auth/user_form.html +++ b/umap/templates/auth/user_form.html @@ -1,9 +1,10 @@ {% extends "umap/content.html" %} {% load i18n %} {% block maincontent %} -
+

- {% trans "My Dashboard" %} | {% trans "My Profile" %} + {% trans "My Maps" %} + {% trans "My Profile" %}

diff --git a/umap/templates/umap/map_table.html b/umap/templates/umap/map_table.html index 8007598b..b1aff48e 100644 --- a/umap/templates/umap/map_table.html +++ b/umap/templates/umap/map_table.html @@ -4,7 +4,8 @@ {% blocktrans %}Name{% endblocktrans %} {% blocktrans %}Preview{% endblocktrans %} - {% blocktrans %}Who can see / edit{% endblocktrans %} + {% blocktrans %}Who can see{% endblocktrans %} + {% blocktrans %}Who can edit{% endblocktrans %} {% blocktrans %}Last save{% endblocktrans %} {% blocktrans %}Owner{% endblocktrans %} {% blocktrans %}Actions{% endblocktrans %} @@ -13,13 +14,17 @@ {% for map_inst in maps %} {% with unique_id="map_"|addstr:map_inst.pk %} - {{ map_inst.preview_settings|json_script:unique_id }} {{ map_inst.name }} - + {{ map_inst.preview_settings|json_script:unique_id }} +
@@ -29,45 +34,99 @@
- {{ map_inst.get_share_status_display }} / {{ map_inst.get_edit_status_display }} + {{ map_inst.get_share_status_display }} + {{ map_inst.get_edit_status_display }} {{ map_inst.modified_at }} {{ map_inst.owner }} - {% translate "Share" %} | - {% translate "Edit" %} | - {% translate "Download" %} + + + {% translate "Share" %} + + + + {% translate "Edit" %} + + + + {% translate "Download" %} + +
+ {% csrf_token %} + +
+
+ {% csrf_token %} + + +
{% endwith %} {% endfor %} - +{% endif %} + diff --git a/umap/templates/umap/user_dashboard.html b/umap/templates/umap/user_dashboard.html index 6e5d4ac7..2ed05011 100644 --- a/umap/templates/umap/user_dashboard.html +++ b/umap/templates/umap/user_dashboard.html @@ -5,15 +5,36 @@ {% endblock head_title %} {% block maincontent %} {% trans "Search my maps" as placeholder %} -
+
- {% if maps %} +
+
+ + + + + +
+ {% if maps.object_list|length > 1 %} + {% blocktranslate with count=maps.object_list|length %} + Download {{ count }} maps + {% endblocktranslate %} + + {% endif %} +
+ {% 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(