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:
commit
afdc732204
17 changed files with 500 additions and 103 deletions
|
@ -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})
|
||||
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
||||
/* ************************************************* */
|
||||
|
|
4
umap/static/umap/img/icon-delete.svg
Normal file
4
umap/static/umap/img/icon-delete.svg
Normal 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 |
13
umap/static/umap/img/icon-download.svg
Normal file
13
umap/static/umap/img/icon-download.svg
Normal 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 |
5
umap/static/umap/img/icon-duplicate.svg
Normal file
5
umap/static/umap/img/icon-duplicate.svg
Normal 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 |
12
umap/static/umap/img/icon-edit.svg
Normal file
12
umap/static/umap/img/icon-edit.svg
Normal 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 |
11
umap/static/umap/img/icon-share.svg
Normal file
11
umap/static/umap/img/icon-share.svg
Normal 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 |
12
umap/static/umap/img/icon-view.svg
Normal file
12
umap/static/umap/img/icon-view.svg
Normal 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 |
|
@ -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">
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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 %}Map’s 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)
|
||||
|
|
18
umap/tests/integration/conftest.py
Normal file
18
umap/tests/integration/conftest.py
Normal 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
|
17
umap/tests/integration/test_dashboard.py
Normal file
17
umap/tests/integration/test_dashboard.py
Normal 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
|
|
@ -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()}")
|
||||
|
|
|
@ -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
|
||||
|
|
13
umap/urls.py
13
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(
|
||||
|
|
|
@ -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(
|
||||
|
|
Loading…
Reference in a new issue