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 os
import time import time
@ -222,6 +223,20 @@ class Map(NamedModel):
) )
return map_settings 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): def get_absolute_url(self):
return reverse("map", kwargs={"slug": self.slug or "map", "map_id": self.pk}) return reverse("map", kwargs={"slug": self.slug or "map", "map_id": self.pk})

View file

@ -144,16 +144,19 @@ body.login header {
} }
h2.section { h2.section {
text-transform: uppercase; text-transform: uppercase;
color: #666; color: #263B58;
text-align: center;
padding-top: 28px; padding-top: 28px;
} }
h2.tabs a { h2.tabs a {
color: #666; color: #263B58;
text-decoration: underline;
text-decoration-thickness: 3px;
margin-right: 2rem;
} }
h2.tabs a:not(.selected) { h2.tabs a:not(.selected) {
font-weight: normal; font-weight: normal;
color: #666; color: #263B58;
text-decoration: none;
} }
h2.tabs a:hover { h2.tabs a:hover {
text-decoration: underline; text-decoration: underline;
@ -310,14 +313,135 @@ ul.umap-autocomplete {
/* **************************** */ /* **************************** */
/* Dashboard */ /* 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 { table.maps {
width: 100%; width: 100%;
border-collapse: collapse;
} }
table.maps .map_fragment { table.maps .map_fragment {
display: block; display: block;
height: 80vh; height: 80vh;
width: 100%; 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) { table.maps tbody tr:nth-child(odd) {
background-color: #f4f4f4; background-color: #f4f4f4;
} }
@ -331,7 +455,7 @@ table.maps .button {
margin-bottom: 2px; margin-bottom: 2px;
padding:4px 6px; padding:4px 6px;
height: 36px; height: 36px;
line-height: 23px; line-height: 26px;
} }
/* **************************** */ /* **************************** */
@ -357,12 +481,14 @@ dialog::backdrop {
display: flex; display: flex;
flex-direction: row; flex-direction: row;
justify-content: space-around; justify-content: space-around;
margin: 1rem;
border-top: 1px solid gray;
} }
.pagination > * { .pagination > * {
padding: 1rem; 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" %} {% extends "umap/content.html" %}
{% load i18n %} {% load i18n %}
{% block maincontent %} {% block maincontent %}
<div class="col wide"> <div class="row">
<h2 class="section tabs"> <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> </h2>
</div> </div>
<div class="wrapper"> <div class="wrapper">

View file

@ -4,7 +4,8 @@
<tr> <tr>
<th>{% blocktrans %}Name{% endblocktrans %}</th> <th>{% blocktrans %}Name{% endblocktrans %}</th>
<th>{% blocktrans %}Preview{% 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 %}Last save{% endblocktrans %}</th>
<th>{% blocktrans %}Owner{% endblocktrans %}</th> <th>{% blocktrans %}Owner{% endblocktrans %}</th>
<th>{% blocktrans %}Actions{% endblocktrans %}</th> <th>{% blocktrans %}Actions{% endblocktrans %}</th>
@ -13,13 +14,17 @@
<tbody> <tbody>
{% for map_inst in maps %} {% for map_inst in maps %}
{% with unique_id="map_"|addstr:map_inst.pk %} {% with unique_id="map_"|addstr:map_inst.pk %}
{{ map_inst.preview_settings|json_script:unique_id }}
<tr> <tr>
<td> <td>
<a href="{{ map_inst.get_absolute_url }}">{{ map_inst.name }}</a> <a href="{{ map_inst.get_absolute_url }}">{{ map_inst.name }}</a>
</td> </td>
<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> <dialog>
<form method="dialog"> <form method="dialog">
<div id="{{ unique_id }}_target" class="map_fragment"></div> <div id="{{ unique_id }}_target" class="map_fragment"></div>
@ -29,21 +34,53 @@
</form> </form>
</dialog> </dialog>
</td> </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>{{ map_inst.modified_at }}</td>
<td> <td>
<a href="{{ map_inst.owner.get_url }}">{{ map_inst.owner }}</a> <a href="{{ map_inst.owner.get_url }}">{{ map_inst.owner }}</a>
</td> </td>
<td> <td>
<a href="{{ map_inst.get_absolute_url }}?share">{% translate "Share" %}</a> | <a href="{{ map_inst.get_absolute_url }}?share" class="icon-link"
<a href="{{ map_inst.get_absolute_url }}?edit">{% translate "Edit" %}</a> | title="{% translate "Share" %}">
<a href="{% url 'map_download' map_inst.pk %}">{% translate "Download" %}</a> <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> </td>
</tr> </tr>
{% endwith %} {% endwith %}
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>
{% if maps.has_other_pages %}
<div class="pagination"> <div class="pagination">
{% if maps.has_previous %} {% if maps.has_previous %}
<a href="?p=1{% if q %}&q={{ q }}{% endif %}">« {% translate "first" %}</a> <a href="?p=1{% if q %}&q={{ q }}{% endif %}">« {% translate "first" %}</a>
@ -70,4 +107,26 @@
<span></span> <span></span>
{# djlint:on #} {# djlint:on #}
{% endif %} {% 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> </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 %} {% endblock head_title %}
{% block maincontent %} {% block maincontent %}
{% trans "Search my maps" as placeholder %} {% trans "Search my maps" as placeholder %}
<div class="col wide"> <div class="row">
<h2 class="section tabs"> <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> </h2>
{% include "umap/search_bar.html" with action=request.get_full_path placeholder=placeholder %}
</div> </div>
<div class="wrapper"> <div class="wrapper">
<div class="row"> <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" %} {% include "umap/map_table.html" %}
{% else %} {% else %}
<div> <div>
@ -31,8 +52,9 @@
const CACHE = {} const CACHE = {}
for (const mapOpener of document.querySelectorAll("button.map-opener")) { for (const mapOpener of document.querySelectorAll("button.map-opener")) {
mapOpener.addEventListener('click', (event) => { mapOpener.addEventListener('click', (event) => {
event.target.nextElementSibling.showModal() const button = event.target.closest('button')
const mapId = event.target.dataset.mapId button.nextElementSibling.showModal()
const mapId = button.dataset.mapId
if (!document.querySelector(`#${mapId}_target`).hasChildNodes()) { if (!document.querySelector(`#${mapId}_target`).hasChildNodes()) {
const previewSettings = JSON.parse(document.getElementById(mapId).textContent) const previewSettings = JSON.parse(document.getElementById(mapId).textContent)
const map = new L.U.Map(`${mapId}_target`, previewSettings) 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 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): def test_map_update_with_owner(map, live_server, login):
page = login(map.owner) page = login(map.owner)
page.goto(f"{live_server.url}{map.get_absolute_url()}") page.goto(f"{live_server.url}{map.get_absolute_url()}")

View file

@ -1,4 +1,6 @@
import json import json
import zipfile
from io import BytesIO
import pytest import pytest
from django.contrib.auth import get_user_model 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 umap.models import DataLayer, Map, Star
from .base import login_required from .base import MapFactory, UserFactory, login_required
pytestmark = pytest.mark.django_db pytestmark = pytest.mark.django_db
User = get_user_model() User = get_user_model()
@ -107,7 +109,9 @@ def test_update(client, map, post_data):
def test_delete(client, map, datalayer): def test_delete(client, map, datalayer):
url = reverse("map_delete", args=(map.pk,)) url = reverse("map_delete", args=(map.pk,))
client.login(username=map.owner.username, password="123123") 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 response.status_code == 200
assert not Map.objects.filter(pk=map.pk).exists() assert not Map.objects.filter(pk=map.pk).exists()
assert not DataLayer.objects.filter(pk=datalayer.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}) url = reverse("map_clone", kwargs={"map_id": map.pk})
client.login(username=map.owner.username, password="123123") client.login(username=map.owner.username, password="123123")
response = client.post(url) 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 response.status_code == 200
assert Map.objects.count() == 2 assert Map.objects.count() == 2
clone = Map.objects.latest("pk") clone = Map.objects.latest("pk")
assert response.json() == {"redirect": clone.get_absolute_url()}
assert clone.pk != map.pk assert clone.pk != map.pk
assert clone.name == "Clone of " + map.name assert clone.name == "Clone of " + map.name
@ -189,7 +207,7 @@ def test_clone_should_set_cloner_as_owner(client, map, user):
map.save() map.save()
client.login(username=user.username, password="123123") client.login(username=user.username, password="123123")
response = client.post(url) response = client.post(url)
assert response.status_code == 200 assert response.status_code == 302
assert Map.objects.count() == 2 assert Map.objects.count() == 2
clone = Map.objects.latest("pk") clone = Map.objects.latest("pk")
assert clone.pk != map.pk assert clone.pk != map.pk
@ -296,7 +314,9 @@ def test_only_owner_can_delete(client, map, user):
map.editors.add(user) map.editors.add(user)
url = reverse("map_delete", kwargs={"map_id": map.pk}) url = reverse("map_delete", kwargs={"map_id": map.pk})
client.login(username=user.username, password="123123") 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 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") @pytest.mark.usefixtures("allow_anonymous")
def test_anonymous_delete(cookieclient, anonymap): def test_anonymous_delete(cookieclient, anonymap):
url = reverse("map_delete", args=(anonymap.pk,)) 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 response.status_code == 200
assert not Map.objects.filter(pk=anonymap.pk).count() assert not Map.objects.filter(pk=anonymap.pk).count()
# Test response is a json # Test response is a json
@ -379,7 +401,9 @@ def test_anonymous_delete(cookieclient, anonymap):
@pytest.mark.usefixtures("allow_anonymous") @pytest.mark.usefixtures("allow_anonymous")
def test_no_cookie_cant_delete(client, anonymap): def test_no_cookie_cant_delete(client, anonymap):
url = reverse("map_delete", args=(anonymap.pk,)) 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 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.edit_status = anonymap.ANONYMOUS
anonymap.save() anonymap.save()
response = client.post(url) response = client.post(url)
assert response.status_code == 200 assert response.status_code == 302
assert Map.objects.count() == 2 assert Map.objects.count() == 2
clone = Map.objects.latest("pk") clone = Map.objects.latest("pk")
assert response["Location"] == clone.get_absolute_url()
assert clone.pk != anonymap.pk assert clone.pk != anonymap.pk
assert clone.name == "Clone of " + anonymap.name assert clone.name == "Clone of " + anonymap.name
assert clone.owner is None 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]) @pytest.mark.parametrize("share_status", [Map.PRIVATE, Map.BLOCKED])
def test_download_shared_status_map(client, map, datalayer, share_status): def test_download_shared_status_map(client, map, datalayer, share_status):
map.share_status = share_status map.share_status = share_status

View file

@ -110,16 +110,9 @@ i18n_urls += decorated_patterns(
views.ToggleMapStarStatus.as_view(), views.ToggleMapStarStatus.as_view(),
name="map_star", name="map_star",
), ),
re_path( re_path(r"^me$", views.user_dashboard, name="user_dashboard"),
r"^me$", re_path(r"^me/profile$", views.user_profile, name="user_profile"),
views.user_dashboard, re_path(r"^me/download$", views.user_download, name="user_download"),
name="user_dashboard",
),
re_path(
r"^me/profile$",
views.user_profile,
name="user_profile",
),
) )
map_urls = [ map_urls = [
re_path( re_path(

View file

@ -1,8 +1,10 @@
import io
import json import json
import mimetypes import mimetypes
import os import os
import re import re
import socket import socket
import zipfile
from datetime import datetime, timedelta from datetime import datetime, timedelta
from http.client import InvalidURL from http.client import InvalidURL
from io import BytesIO from io import BytesIO
@ -288,20 +290,44 @@ class UserDashboard(PaginatorMixin, DetailView, SearchMixin):
return qs.order_by("-modified_at") return qs.order_by("-modified_at")
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
kwargs.update( page = self.paginate(self.get_maps(), settings.UMAP_MAPS_PER_PAGE_OWNER)
{ kwargs.update({"q": self.request.GET.get("q"), "maps": page})
"q": self.request.GET.get("q"),
"maps": self.paginate(
self.get_maps(), settings.UMAP_MAPS_PER_PAGE_OWNER
),
}
)
return super().get_context_data(**kwargs) return super().get_context_data(**kwargs)
user_dashboard = UserDashboard.as_view() 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): class MapsShowCase(View):
def get(self, *args, **kwargs): def get(self, *args, **kwargs):
maps = Map.public.filter(center__distance_gt=(DEFAULT_CENTER, D(km=1))) 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,)) return reverse("map_download", args=(self.object.pk,))
def render_to_response(self, context, *args, **kwargs): def render_to_response(self, context, *args, **kwargs):
geojson = self.object.settings umapjson = self.object.generate_umapjson(self.request)
geojson["type"] = "umap" response = simple_json_response(**umapjson)
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)
response[ response[
"Content-Disposition" "Content-Disposition"
] = f'attachment; filename="umap_backup_{self.object.slug}.umap"' ] = 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): if not self.object.can_delete(self.request.user, self.request):
return HttpResponseForbidden(_("Only its owner can delete the map.")) return HttpResponseForbidden(_("Only its owner can delete the map."))
self.object.delete() 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): class MapClone(PermissionsMixin, View):
@ -857,7 +877,10 @@ class MapClone(PermissionsMixin, View):
return HttpResponseForbidden() return HttpResponseForbidden()
owner = self.request.user if self.request.user.is_authenticated else None owner = self.request.user if self.request.user.is_authenticated else None
self.object = kwargs["map_inst"].clone(owner=owner) self.object = kwargs["map_inst"].clone(owner=owner)
if is_ajax(self.request):
response = simple_json_response(redirect=self.object.get_absolute_url()) 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: if not self.request.user.is_authenticated:
key, value = self.object.signed_cookie_elements key, value = self.object.signed_cookie_elements
response.set_signed_cookie( response.set_signed_cookie(