Merge pull request #1430 from umap-project/download-all-from-dashboard

Ability to clone, delete and download all maps from user’s dashboard
This commit is contained in:
David Larlet 2024-02-08 09:51:01 -05:00 committed by GitHub
commit afdc732204
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 500 additions and 103 deletions

View file

@ -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})

View file

@ -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;
}
/* ************************************************* */

View file

@ -0,0 +1,4 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 36 36">
<path fill="#263B58" d="M0 0h36v36H0z"/>
<path d="M10.5 13h15M14.7 13v-1.7a1.7 1.7 0 0 1 1.6-1.6h3.4a1.7 1.7 0 0 1 1.6 1.6V13m2.5 0v11.7a1.7 1.7 0 0 1-1.6 1.6h-8.4a1.7 1.7 0 0 1-1.6-1.6V13h11.6ZM19.7 17.2v5M16.3 17.2v5" stroke="#fff" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 386 B

View file

@ -0,0 +1,13 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 36 36">
<path fill="#263B58" d="M0 0h36v36H0z"/>
<g clip-path="url(#a)" stroke="#fff" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
<path d="M19.7 10.5v3.3a.8.8 0 0 0 .8.9h3.3"/>
<path d="M22.2 25.5h-8.4a1.7 1.7 0 0 1-1.6-1.7V12.2a1.7 1.7 0 0 1 1.6-1.7h5.9l4.1 4.2v9.1a1.7 1.7 0 0 1-1.6 1.7ZM18 22.2v-5"/>
<path d="m16 20 2 2.2 2-2.1"/>
</g>
<defs>
<clipPath id="a">
<path fill="#fff" d="M8 8h20v20H8z"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 554 B

View file

@ -0,0 +1,5 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 36 36">
<path fill="#263B58" d="M0 0h36v36H0z"/>
<path d="M24.7 15.5h-7.5c-1 0-1.7.7-1.7 1.7v7.5c0 .9.7 1.6 1.7 1.6h7.5c.9 0 1.6-.7 1.6-1.6v-7.5c0-1-.7-1.7-1.6-1.7Z" stroke="#fff" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M12.2 20.5h-.9a1.7 1.7 0 0 1-1.6-1.7v-7.5a1.7 1.7 0 0 1 1.6-1.6h7.5a1.7 1.7 0 0 1 1.7 1.6v.9" stroke="#fff" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 511 B

View file

@ -0,0 +1,12 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 36 36">
<path fill="#263B58" d="M0 0h36v36H0z"/>
<g clip-path="url(#a)" stroke="#fff" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
<path d="M13.8 13.8H13a1.7 1.7 0 0 0-1.7 1.7V23a1.7 1.7 0 0 0 1.7 1.7h7.5a1.7 1.7 0 0 0 1.7-1.7v-.8"/>
<path d="M25 13.5a1.8 1.8 0 0 0-2.5-2.5l-7 7v2.5H18l7-7ZM21.3 12.2l2.5 2.5"/>
</g>
<defs>
<clipPath id="a">
<path fill="#fff" d="M8 8h20v20H8z"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 526 B

View file

@ -0,0 +1,11 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 36 36">
<path fill="#263B58" d="M0 0h36v36H0z"/>
<g clip-path="url(#a)" stroke="#fff" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
<path d="M10.5 18a2.5 2.5 0 1 0 5 0 2.5 2.5 0 0 0-5 0ZM20.5 13a2.5 2.5 0 1 0 5 0 2.5 2.5 0 0 0-5 0ZM20.5 23a2.5 2.5 0 1 0 5 0 2.5 2.5 0 0 0-5 0ZM15.3 17l5.4-3M15.3 19l5.4 3"/>
</g>
<defs>
<clipPath id="a">
<path fill="#fff" d="M8 8h20v20H8z"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 517 B

View file

@ -0,0 +1,12 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 36 36">
<path fill="#263B58" d="M0 0h36v36H0z"/>
<g clip-path="url(#a)" stroke="#fff" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
<path d="M16.3 18a1.7 1.7 0 1 0 3.4 0 1.7 1.7 0 0 0-3.4 0Z"/>
<path d="M25.5 18c-2 3.3-4.5 5-7.5 5s-5.5-1.7-7.5-5c2-3.3 4.5-5 7.5-5s5.5 1.7 7.5 5Z"/>
</g>
<defs>
<clipPath id="a">
<path fill="#fff" d="M8 8h20v20H8z"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 495 B

View file

@ -1,9 +1,10 @@
{% extends "umap/content.html" %}
{% load i18n %}
{% block maincontent %}
<div class="col wide">
<div class="row">
<h2 class="section tabs">
<a href="{% url "user_dashboard" %}">{% trans "My Dashboard" %}</a> | <a class="selected" href="{% url 'user_profile' %}">{% trans "My Profile" %}</a>
<a href="{% url "user_dashboard" %}">{% trans "My Maps" %}</a>
<a class="selected" href="{% url 'user_profile' %}">{% trans "My Profile" %}</a>
</h2>
</div>
<div class="wrapper">

View file

@ -4,7 +4,8 @@
<tr>
<th>{% blocktrans %}Name{% endblocktrans %}</th>
<th>{% blocktrans %}Preview{% endblocktrans %}</th>
<th>{% blocktrans %}Who can see / edit{% endblocktrans %}</th>
<th>{% blocktrans %}Who can see{% endblocktrans %}</th>
<th>{% blocktrans %}Who can edit{% endblocktrans %}</th>
<th>{% blocktrans %}Last save{% endblocktrans %}</th>
<th>{% blocktrans %}Owner{% endblocktrans %}</th>
<th>{% blocktrans %}Actions{% endblocktrans %}</th>
@ -13,13 +14,17 @@
<tbody>
{% for map_inst in maps %}
{% with unique_id="map_"|addstr:map_inst.pk %}
{{ map_inst.preview_settings|json_script:unique_id }}
<tr>
<td>
<a href="{{ map_inst.get_absolute_url }}">{{ map_inst.name }}</a>
</td>
<td>
<button class="button map-opener neutral" data-map-id="{{ unique_id }}">{% blocktranslate %}Open preview{% endblocktranslate %}</button>
{{ map_inst.preview_settings|json_script:unique_id }}
<button class="map-icon map-opener" data-map-id="{{ unique_id }}"
title="{% translate "Open preview" %}">
<span class="icon-dashboard icon-view"></span>
<span class="sr-only">{% translate "Open preview" %}</span>
</button>
<dialog>
<form method="dialog">
<div id="{{ unique_id }}_target" class="map_fragment"></div>
@ -29,21 +34,53 @@
</form>
</dialog>
</td>
<td>{{ map_inst.get_share_status_display }} / {{ map_inst.get_edit_status_display }}</td>
<td>{{ map_inst.get_share_status_display }}</td>
<td>{{ map_inst.get_edit_status_display }}</td>
<td>{{ map_inst.modified_at }}</td>
<td>
<a href="{{ map_inst.owner.get_url }}">{{ map_inst.owner }}</a>
</td>
<td>
<a href="{{ map_inst.get_absolute_url }}?share">{% translate "Share" %}</a> |
<a href="{{ map_inst.get_absolute_url }}?edit">{% translate "Edit" %}</a> |
<a href="{% url 'map_download' map_inst.pk %}">{% translate "Download" %}</a>
<a href="{{ map_inst.get_absolute_url }}?share" class="icon-link"
title="{% translate "Share" %}">
<span class="icon-dashboard icon-share"></span>
<span class="sr-only">{% translate "Share" %}</span>
</a>
<a href="{{ map_inst.get_absolute_url }}?edit" class="icon-link"
title="{% translate "Edit" %}">
<span class="icon-dashboard icon-edit"></span>
<span class="sr-only">{% translate "Edit" %}</span>
</a>
<a href="{% url 'map_download' map_inst.pk %}" class="icon-link"
title="{% translate "Download" %}">
<span class="icon-dashboard icon-download"></span>
<span class="sr-only">{% translate "Download" %}</span>
</a>
<form action="{% url 'map_clone' map_inst.pk %}" method="post">
{% csrf_token %}
<button class="map-icon" type="submit"
title="{% translate "Clone" %}">
<span class="icon-dashboard icon-duplicate"></span>
<span class="sr-only">{% translate "Clone" %}</span>
</button>
</form>
<form action="{% url 'map_delete' map_inst.pk %}"
method="post" class="map-delete">
{% csrf_token %}
<input type="hidden" name="next" value="{% url 'user_dashboard' %}">
<button class="map-icon" type="submit"
title="{% translate "Delete" %}">
<span class="icon-dashboard icon-delete"></span>
<span class="sr-only">{% translate "Delete" %}</span>
</button>
</form>
</td>
</tr>
{% endwith %}
{% endfor %}
</tbody>
</table>
{% if maps.has_other_pages %}
<div class="pagination">
{% if maps.has_previous %}
<a href="?p=1{% if q %}&q={{ q }}{% endif %}">« {% translate "first" %}</a>
@ -70,4 +107,26 @@
<span></span>
{# djlint:on #}
{% endif %}
<span>
{% blocktranslate with per_page=maps.paginator.per_page %}
Lines per page: {{ per_page }}
{% endblocktranslate %}
</span>
<span>
{% blocktranslate with count=maps.paginator.count %}
{{ count }} maps
{% endblocktranslate %}
</span>
</div>
{% endif %}
<script>
!(function () {
for (const deleteForm of document.querySelectorAll('table.maps form.map-delete')) {
deleteForm.addEventListener('submit', (event) => {
if (!confirm(L._('Are you sure you want to delete this map?'))) {
event.preventDefault()
}
})
}
})()
</script>

View file

@ -5,15 +5,36 @@
{% endblock head_title %}
{% block maincontent %}
{% trans "Search my maps" as placeholder %}
<div class="col wide">
<div class="row">
<h2 class="section tabs">
<a class="selected" href="{% url 'user_dashboard' %}">{% trans "My Dashboard" %}</a> | <a href="{% url 'user_profile' %}">{% trans "My profile" %}</a>
<a class="selected" href="{% url 'user_dashboard' %}"
>{% blocktranslate with count=maps.paginator.count %}My Maps ({{ count }}){% endblocktranslate %}
</a>
<a href="{% url 'user_profile' %}">{% trans "My profile" %}</a>
</h2>
{% include "umap/search_bar.html" with action=request.get_full_path placeholder=placeholder %}
</div>
<div class="wrapper">
<div class="row">
{% if maps %}
<div class="table-header">
<form action="{{ request.get_full_path }}" method="get">
<span>
<label for="q">{% blocktranslate %}Maps title{% endblocktranslate %}</label>
<input id="q" name="q" type="search"
value="{{ request.GET.q|default:"" }}" />
</span>
<input type="submit" value="{% trans "Search" %}" />
</form>
{% if maps.object_list|length > 1 %}
<a href="{% url 'user_download' %}?{% spaceless %}
{% for map_inst in maps %}map_id={{ map_inst.pk }}{% if not forloop.last %}&{% endif %}{% endfor %}
{% endspaceless %}" class="button button-download"
>{% blocktranslate with count=maps.object_list|length %}
Download {{ count }} maps
{% endblocktranslate %}
</a>
{% endif %}
</div>
{% if maps or request.GET.q %}
{% include "umap/map_table.html" %}
{% else %}
<div>
@ -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)

View file

@ -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

View file

@ -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

View file

@ -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()}")

View file

@ -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

View file

@ -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(

View file

@ -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)
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(