maps: add UI to manage map layers (#8454)

This commit is contained in:
Serghei Mihai 2017-05-12 11:53:07 +02:00 committed by Serghei Mihai
parent 5cd732fafa
commit 11da88d42c
13 changed files with 452 additions and 0 deletions

View File

@ -0,0 +1,34 @@
# combo - content management system
# Copyright (C) 2017 Entr'ouvert
#
# This program is free software: you can redistribute it and/or modify it
# under the terms of the GNU Affero General Public License as published
# by the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
import django.apps
from django.core.urlresolvers import reverse
from django.utils.translation import ugettext_lazy as _
class AppConfig(django.apps.AppConfig):
name = 'combo.apps.maps'
verbose_name = _('Maps')
def get_before_urls(self):
from . import urls
return urls.urlpatterns
def get_extra_manager_actions(self):
return [{'href': reverse('maps-manager-homepage'),
'text': _('Maps')}]
default_app_config = 'combo.apps.maps.AppConfig'

26
combo/apps/maps/forms.py Normal file
View File

@ -0,0 +1,26 @@
# combo - content management system
# Copyright (C) 2017 Entr'ouvert
#
# This program is free software: you can redistribute it and/or modify it
# under the terms of the GNU Affero General Public License as published
# by the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
from django import forms
from .models import MapLayer
class MapLayerForm(forms.ModelForm):
class Meta:
model = MapLayer
exclude = ('slug', )
widgets = {'marker_colour': forms.TextInput(attrs={'type': 'color'}),
'icon_colour': forms.TextInput(attrs={'type': 'color'})
}

View File

@ -0,0 +1,45 @@
# combo - content management system
# Copyright (C) 2017 Entr'ouvert
#
# This program is free software: you can redistribute it and/or modify it
# under the terms of the GNU Affero General Public License as published
# by the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
from django.core.urlresolvers import reverse_lazy
from django.views.generic import (TemplateView, ListView, CreateView,
UpdateView, DeleteView)
from .models import MapLayer
from .forms import MapLayerForm
class MapLayerMixin(object):
model = MapLayer
success_url = reverse_lazy('maps-manager-homepage')
class ManagerHomeView(MapLayerMixin, ListView):
template_name = 'maps/manager_home.html'
class LayerAddView(MapLayerMixin, CreateView):
form_class = MapLayerForm
template_name = 'maps/map_layer_form.html'
class LayerEditView(MapLayerMixin, UpdateView):
form_class = MapLayerForm
template_name = 'maps/map_layer_form.html'
class LayerDeleteView(MapLayerMixin, DeleteView):
template_name = 'maps/map_layer_confirm_delete.html'

View File

@ -0,0 +1,25 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
]
operations = [
migrations.CreateModel(
name='MapLayer',
fields=[
('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)),
('label', models.CharField(max_length=128, verbose_name='Label')),
('slug', models.SlugField(verbose_name='Slug')),
('geojson_url', models.URLField(max_length=1024, verbose_name='Geojson URL')),
('marker_colour', models.CharField(default=b'#0000FF', max_length=7, verbose_name='Marker colour')),
('icon', models.CharField(blank=True, max_length=32, null=True, verbose_name='Marker icon', choices=[(b'home', 'Home'), (b'building', 'Building'), (b'hospital', 'Hospital'), (b'ambulance', 'Ambulance'), (b'taxi', 'Taxi'), (b'subway', 'Subway'), (b'wheelchair', 'Wheelchair'), (b'bicycle', 'Bicycle'), (b'car', 'Car'), (b'train', 'Train'), (b'bus', 'Bus'), (b'motorcycle', 'Motorcycle'), (b'truck', 'Truck')])),
('icon_colour', models.CharField(default=b'#FFFFFF', max_length=7, verbose_name='Icon colour')),
],
),
]

View File

86
combo/apps/maps/models.py Normal file
View File

@ -0,0 +1,86 @@
# combo - content management system
# Copyright (C) 2017 Entr'ouvert
#
# This program is free software: you can redistribute it and/or modify it
# under the terms of the GNU Affero General Public License as published
# by the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
from django.db import models
from django.utils.text import slugify
from django.utils.translation import ugettext_lazy as _
from combo.utils import requests
ICONS = [
('home', _('Home')),
('building', _('Building')),
('hospital', _('Hospital')),
('ambulance', _('Ambulance')),
('taxi', _('Taxi')),
('subway', _('Subway')),
('wheelchair', _('Wheelchair')),
('bicycle', _('Bicycle')),
('car', _('Car')),
('train', _('Train')),
('bus', _('Bus')),
('motorcycle', _('Motorcycle')),
('truck', _('Truck')),
]
class MapLayer(models.Model):
label = models.CharField(_('Label'), max_length=128)
slug = models.SlugField(_('Slug'))
geojson_url = models.URLField(_('Geojson URL'), max_length=1024)
marker_colour = models.CharField(_('Marker colour'), max_length=7, default='#0000FF')
icon = models.CharField(_('Marker icon'), max_length=32, blank=True, null=True,
choices=ICONS)
icon_colour = models.CharField(_('Icon colour'), max_length=7, default='#FFFFFF')
def save(self, *args, **kwargs):
if not self.slug:
base_slug = slugify(self.label)
slug = base_slug
i = 1
while True:
try:
MapLayer.objects.get(slug=slug)
except self.DoesNotExist:
break
slug = '%s-%s' % (base_slug, i)
i += 1
self.slug = slug
super(MapLayer, self).save(*args, **kwargs)
def __unicode__(self):
return self.label
def get_geojson(self, request):
response = requests.get(self.geojson_url,
remote_service='auto',
user=request.user,
headers={'accept': 'application/json'})
if not response.ok:
return []
data = response.json()
if 'features' in data:
features = data['features']
else:
features = data
for feature in features:
feature['properties']['colour'] = self.marker_colour
feature['properties']['icon_colour'] = self.icon_colour
feature['properties']['label'] = self.label
feature['properties']['icon'] = self.icon
return features

View File

@ -0,0 +1,11 @@
{% extends "combo/manager_base.html" %}
{% load i18n %}
{% block appbar %}
<h2>{% trans 'Maps' %}</h2>
{% endblock %}
{% block breadcrumb %}
{{ block.super }}
<a href="{% url 'maps-manager-homepage' %}">{% trans 'Maps' %}</a>
{% endblock %}

View File

@ -0,0 +1,26 @@
{% extends "maps/manager_base.html" %}
{% load i18n %}
{% block appbar %}
<h2>{% trans 'Maps' %}</h2>
<a rel="popup" href="{% url 'maps-manager-layer-add' %}">{% trans 'New' %}</a>
{% endblock %}
{% block content %}
{% if object_list %}
<div class="objects-list">
{% for layer in object_list %}
<div>
<a href="{% url 'maps-manager-layer-edit' slug=layer.slug %}">{{ layer.label }}</a>
</div>
{% endfor %}
</div>
{% else %}
<div class="big-msg-info">
{% blocktrans %}
This site doesn't have any layer yet. Click on the "New" button in the top
right of the page to add a first one.
{% endblocktrans %}
</div>
{% endif %}
{% endblock %}

View File

@ -0,0 +1,17 @@
{% extends "combo/manager_base.html" %}
{% load i18n %}
{% block appbar %}
<h2>{{ view.model.get_verbose_name }}</h2>
{% endblock %}
{% block content %}
<form method="post">
{% csrf_token %}
{% blocktrans %}Are you sure you want to delete this?{% endblocktrans %}
<div class="buttons">
<button class="delete-button">{% trans 'Delete' %}</button>
<a class="cancel" href="{% url 'maps-manager-homepage' %}">{% trans 'Cancel' %}</a>
</div>
</form>
{% endblock %}

View File

@ -0,0 +1,30 @@
{% extends "maps/manager_home.html" %}
{% load i18n %}
{% block appbar %}
{% if object.id %}
<h2>{% trans "Edit Map Layer" %}</h2>
{% else %}
<h2>{% trans "New Map Layer" %}</h2>
{% endif %}
{% endblock %}
{% block breadcrumb %}
{{ block.super }}
<a href="{% url 'maps-manager-homepage' %}">{% trans 'Maps' %}</a>
{% endblock %}
{% block content %}
<form method="post" enctype="multipart/form-data">
{% csrf_token %}
{{ form.as_p }}
<div class="buttons">
<button class="submit-button">{% trans "Save" %}</button>
<a class="cancel" href="{% url 'maps-manager-homepage' %}">{% trans 'Cancel' %}</a>
{% if object.id %}
<a class="delete" rel="popup" href="{% url 'maps-manager-layer-delete' slug=object.slug %}">{% trans 'Delete' %}</a>
{% endif %}
</div>
</form>
{% endblock %}

36
combo/apps/maps/urls.py Normal file
View File

@ -0,0 +1,36 @@
# combo - content management system
# Copyright (C) 2017 Entr'ouvert
#
# This program is free software: you can redistribute it and/or modify it
# under the terms of the GNU Affero General Public License as published
# by the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
from django.conf.urls import url, include
from combo.urls_utils import decorated_includes, manager_required
from .manager_views import (ManagerHomeView, LayerAddView,
LayerEditView, LayerDeleteView)
maps_manager_urls = [
url('^$', ManagerHomeView.as_view(), name='maps-manager-homepage'),
url('^layers/add/$', LayerAddView.as_view(), name='maps-manager-layer-add'),
url('^layers/(?P<slug>[\w-]+)/edit/$', LayerEditView.as_view(),
name='maps-manager-layer-edit'),
url('^layers/(?P<slug>[\w-]+)/delete/$', LayerDeleteView.as_view(),
name='maps-manager-layer-delete'),
]
urlpatterns = [
url(r'^manage/maps/', decorated_includes(manager_required,
include(maps_manager_urls))),
]

View File

@ -77,6 +77,7 @@ INSTALLED_APPS = (
'combo.apps.notifications',
'combo.apps.search',
'combo.apps.usersearch',
'combo.apps.maps',
'haystack',
'xstatic.pkg.chartnew_js',
)

115
tests/test_maps_manager.py Normal file
View File

@ -0,0 +1,115 @@
# -*- coding: utf-8 -*-
import pytest
import mock
from django.contrib.auth.models import User
from combo.apps.maps.models import MapLayer
pytestmark = pytest.mark.django_db
@pytest.fixture
def admin_user():
try:
user = User.objects.get(username='admin')
except User.DoesNotExist:
user = User.objects.create_superuser('admin', email=None, password='admin')
return user
def login(app, username='admin', password='admin'):
login_page = app.get('/login/')
login_form = login_page.forms[0]
login_form['username'] = username
login_form['password'] = password
resp = login_form.submit()
assert resp.status_int == 302
return app
def test_access(app, admin_user):
app = login(app)
resp = app.get('/manage/', status=200)
assert '/manage/maps/' in resp.body
def test_add_layer(app, admin_user):
MapLayer.objects.all().delete()
app = login(app)
resp = app.get('/manage/maps/', status=200)
resp = resp.click('New')
resp.forms[0]['label'] = 'Test'
resp.forms[0]['geojson_url'] = 'http://example.net/geojson'
assert resp.form['marker_colour'].value == '#0000FF'
resp.forms[0]['marker_colour'] = '#FFFFFF'
resp.forms[0]['icon'] = 'bicycle'
assert resp.form['icon_colour'].value == '#FFFFFF'
resp.form['icon_colour'] = '#000000'
resp = resp.forms[0].submit()
assert resp.location == 'http://testserver/manage/maps/'
assert MapLayer.objects.count() == 1
layer = MapLayer.objects.get()
assert layer.label == 'Test'
assert layer.slug == 'test'
def test_edit_layer(app, admin_user):
test_add_layer(app, admin_user)
app = login(app)
resp = app.get('/manage/maps/', status=200)
resp = resp.click('Test')
resp.forms[0]['geojson_url'] = 'http://example.net/new_geojson'
resp = resp.forms[0].submit()
assert resp.location == 'http://testserver/manage/maps/'
assert MapLayer.objects.count() == 1
layer = MapLayer.objects.get()
assert layer.geojson_url == 'http://example.net/new_geojson'
def test_delete_layer(app, admin_user):
test_add_layer(app, admin_user)
app = login(app)
resp = app.get('/manage/maps/', status=200)
resp = resp.click('Test')
resp = resp.click('Delete')
assert 'Are you sure you want to delete this?' in resp.body
resp = resp.forms[0].submit()
assert resp.location == 'http://testserver/manage/maps/'
assert MapLayer.objects.count() == 0
@mock.patch('combo.apps.maps.models.requests.get')
def test_download_geojson(mock_request, app, admin_user):
test_add_layer(app, admin_user)
layer = MapLayer.objects.get()
mocked_response = mock.Mock()
mocked_response.json.return_value = [{'type': 'Feature',
'geometry': {'type': 'Point',
'coordinates': [2.3233688436448574, 48.83369263315934]},
'properties': {'property': 'property value'}}]
mocked_response.ok.return_value = True
mock_request.return_value = mocked_response
geojson = layer.get_geojson(mock_request)
assert len(geojson) > 0
for item in geojson:
assert item['type'] == 'Feature'
assert item['geometry']['type'] == 'Point'
assert item['geometry']['coordinates'] == [2.3233688436448574, 48.83369263315934]
assert item['properties']['icon'] == 'bicycle'
assert item['properties']['label'] == 'Test'
assert item['properties']['colour'] == '#FFFFFF'
assert item['properties']['icon_colour'] == '#000000'
mocked_response.json.return_value = {'type': 'FeatureCollection',
'features': [{'geometry': {'type': 'Point',
'coordinates': [2.3233688436448574, 48.83369263315934]},
'properties': {'property': 'a random value',
'display_fields': [('foo', 'bar')]}}
]
}
mocked_response.ok.return_value = True
mock_request.return_value = mocked_response
geojson = layer.get_geojson(mock_request)
assert len(geojson) > 0
for item in geojson:
assert item['geometry']['type'] == 'Point'
assert item['geometry']['coordinates'] == [2.3233688436448574, 48.83369263315934]
assert item['properties']['icon'] == 'bicycle'
assert item['properties']['label'] == 'Test'
assert item['properties']['colour'] == '#FFFFFF'
assert item['properties']['icon_colour'] == '#000000'