maps: add UI to manage map layers (#8454)
This commit is contained in:
parent
5cd732fafa
commit
11da88d42c
|
@ -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'
|
|
@ -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'})
|
||||
}
|
|
@ -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'
|
|
@ -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')),
|
||||
],
|
||||
),
|
||||
]
|
|
@ -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
|
|
@ -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 %}
|
|
@ -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 %}
|
|
@ -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 %}
|
|
@ -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 %}
|
|
@ -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))),
|
||||
]
|
|
@ -77,6 +77,7 @@ INSTALLED_APPS = (
|
|||
'combo.apps.notifications',
|
||||
'combo.apps.search',
|
||||
'combo.apps.usersearch',
|
||||
'combo.apps.maps',
|
||||
'haystack',
|
||||
'xstatic.pkg.chartnew_js',
|
||||
)
|
||||
|
|
|
@ -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'
|
Loading…
Reference in New Issue