From 28653ae8e80c37e8735d43e5e5d7763d58574d83 Mon Sep 17 00:00:00 2001 From: Serghei Mihai Date: Fri, 12 May 2017 11:53:07 +0200 Subject: [PATCH] maps: add layers (#8454) --- combo/apps/maps/__init__.py | 34 ++++++ combo/apps/maps/forms.py | 26 +++++ combo/apps/maps/manager_views.py | 45 ++++++++ combo/apps/maps/migrations/0001_initial.py | 25 +++++ combo/apps/maps/migrations/__init__.py | 0 combo/apps/maps/models.py | 92 ++++++++++++++++ combo/apps/maps/templates/maps/manager_base.html | 11 ++ combo/apps/maps/templates/maps/manager_home.html | 26 +++++ .../templates/maps/map_layer_confirm_delete.html | 17 +++ combo/apps/maps/templates/maps/map_layer_form.html | 30 ++++++ combo/apps/maps/urls.py | 36 +++++++ combo/settings.py | 1 + tests/test_maps_manager.py | 117 +++++++++++++++++++++ 13 files changed, 460 insertions(+) create mode 100644 combo/apps/maps/__init__.py create mode 100644 combo/apps/maps/forms.py create mode 100644 combo/apps/maps/manager_views.py create mode 100644 combo/apps/maps/migrations/0001_initial.py create mode 100644 combo/apps/maps/migrations/__init__.py create mode 100644 combo/apps/maps/models.py create mode 100644 combo/apps/maps/templates/maps/manager_base.html create mode 100644 combo/apps/maps/templates/maps/manager_home.html create mode 100644 combo/apps/maps/templates/maps/map_layer_confirm_delete.html create mode 100644 combo/apps/maps/templates/maps/map_layer_form.html create mode 100644 combo/apps/maps/urls.py create mode 100644 tests/test_maps_manager.py diff --git a/combo/apps/maps/__init__.py b/combo/apps/maps/__init__.py new file mode 100644 index 0000000..9d4d1e0 --- /dev/null +++ b/combo/apps/maps/__init__.py @@ -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 . + +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' diff --git a/combo/apps/maps/forms.py b/combo/apps/maps/forms.py new file mode 100644 index 0000000..b8772f1 --- /dev/null +++ b/combo/apps/maps/forms.py @@ -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 . + +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'}) + } diff --git a/combo/apps/maps/manager_views.py b/combo/apps/maps/manager_views.py new file mode 100644 index 0000000..5273408 --- /dev/null +++ b/combo/apps/maps/manager_views.py @@ -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 . + +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' diff --git a/combo/apps/maps/migrations/0001_initial.py b/combo/apps/maps/migrations/0001_initial.py new file mode 100644 index 0000000..c302aa9 --- /dev/null +++ b/combo/apps/maps/migrations/0001_initial.py @@ -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'fa-home', 'home'), (b'fa-building', 'building'), (b'fa-hospital-o', 'hospital'), (b'fa-ambulance', 'ambulance'), (b'fa-taxi', 'taxi'), (b'fa-subway', 'subway'), (b'fa-wheelchair', 'wheelchair'), (b'fa-bicycle', 'bicycle'), (b'fa-car', 'car'), (b'fa-train', 'train'), (b'fa-bus', 'bus'), (b'fa-motorcycle', 'motorcycle'), (b'fa-truck', 'truck')])), + ('icon_colour', models.CharField(default=b'#FFFFFF', max_length=7, verbose_name='Icon colour')), + ], + ), + ] diff --git a/combo/apps/maps/migrations/__init__.py b/combo/apps/maps/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/combo/apps/maps/models.py b/combo/apps/maps/models.py new file mode 100644 index 0000000..ce57d04 --- /dev/null +++ b/combo/apps/maps/models.py @@ -0,0 +1,92 @@ +# 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 . + + +from django.db import models +from django.utils.text import slugify +from django.utils.translation import ugettext_lazy as _ + +icons = [ + ('home', _('home')), + ('building', _('building')), + ('hospital-o', _('hospital')), + ('ambulance', _('ambulance')), + ('taxi', _('taxi')), + ('subway', _('subway')), + ('wheelchair', _('wheelchair')), + ('bicycle', _('bicycle')), + ('car', _('car')), + ('train', _('train')), + ('bus', _('bus')), + ('motorcycle', _('motorcycle')), + ('truck', _('truck')), +] + +from combo.utils import requests + + +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: + if 'display_fields' not in feature['properties']: + display_fields = [] + for label, value in feature['properties'].iteritems(): + if value is not None: + display_fields.append((label, value)) + feature['properties']['display_fields'] = display_fields + 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 diff --git a/combo/apps/maps/templates/maps/manager_base.html b/combo/apps/maps/templates/maps/manager_base.html new file mode 100644 index 0000000..fa979f0 --- /dev/null +++ b/combo/apps/maps/templates/maps/manager_base.html @@ -0,0 +1,11 @@ +{% extends "combo/manager_base.html" %} +{% load i18n %} + +{% block appbar %} +

{% trans 'Maps' %}

+{% endblock %} + +{% block breadcrumb %} +{{ block.super }} +{% trans 'Maps' %} +{% endblock %} diff --git a/combo/apps/maps/templates/maps/manager_home.html b/combo/apps/maps/templates/maps/manager_home.html new file mode 100644 index 0000000..80a6882 --- /dev/null +++ b/combo/apps/maps/templates/maps/manager_home.html @@ -0,0 +1,26 @@ +{% extends "maps/manager_base.html" %} +{% load i18n %} + +{% block appbar %} +

{% trans 'Maps' %}

+{% trans 'New' %} +{% endblock %} + +{% block content %} +{% if object_list %} +
+ {% for layer in object_list %} + + {% endfor %} +
+{% else %} +
+ {% 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 %} +
+{% endif %} +{% endblock %} diff --git a/combo/apps/maps/templates/maps/map_layer_confirm_delete.html b/combo/apps/maps/templates/maps/map_layer_confirm_delete.html new file mode 100644 index 0000000..5c0802d --- /dev/null +++ b/combo/apps/maps/templates/maps/map_layer_confirm_delete.html @@ -0,0 +1,17 @@ +{% extends "combo/manager_base.html" %} +{% load i18n %} + +{% block appbar %} +

{{ view.model.get_verbose_name }}

+{% endblock %} + +{% block content %} +
+ {% csrf_token %} + {% blocktrans %}Are you sure you want to delete this?{% endblocktrans %} +
+ + {% trans 'Cancel' %} +
+
+{% endblock %} diff --git a/combo/apps/maps/templates/maps/map_layer_form.html b/combo/apps/maps/templates/maps/map_layer_form.html new file mode 100644 index 0000000..9a97f1d --- /dev/null +++ b/combo/apps/maps/templates/maps/map_layer_form.html @@ -0,0 +1,30 @@ +{% extends "maps/manager_home.html" %} +{% load i18n %} + +{% block appbar %} +{% if object.id %} +

{% trans "Edit Map Layer" %}

+{% else %} +

{% trans "New Map Layer" %}

+{% endif %} +{% endblock %} + +{% block breadcrumb %} +{{ block.super }} +{% trans 'Maps' %} +{% endblock %} + +{% block content %} + +
+ {% csrf_token %} + {{ form.as_p }} +
+ + {% trans 'Cancel' %} + {% if object.id %} + {% trans 'Delete' %} + {% endif %} +
+
+{% endblock %} diff --git a/combo/apps/maps/urls.py b/combo/apps/maps/urls.py new file mode 100644 index 0000000..a57574c --- /dev/null +++ b/combo/apps/maps/urls.py @@ -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 . + +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[\w-]+)/edit/$', LayerEditView.as_view(), + name='maps-manager-layer-edit'), + url('^layers/(?P[\w-]+)/delete/$', LayerDeleteView.as_view(), + name='maps-manager-layer-delete'), +] + +urlpatterns = [ + url(r'^manage/maps/', decorated_includes(manager_required, + include(maps_manager_urls))), +] diff --git a/combo/settings.py b/combo/settings.py index ab6a58d..3a678fe 100644 --- a/combo/settings.py +++ b/combo/settings.py @@ -77,6 +77,7 @@ INSTALLED_APPS = ( 'combo.apps.notifications', 'combo.apps.search', 'combo.apps.usersearch', + 'combo.apps.maps', 'haystack', 'xstatic.pkg.chartnew_js', ) diff --git a/tests/test_maps_manager.py b/tests/test_maps_manager.py new file mode 100644 index 0000000..3140a3e --- /dev/null +++ b/tests/test_maps_manager.py @@ -0,0 +1,117 @@ +# -*- 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']['display_fields'] == [('property', 'property value')] + 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']['display_fields'] == [('foo', 'bar')] + assert item['properties']['icon'] == 'bicycle' + assert item['properties']['label'] == 'Test' + assert item['properties']['colour'] == '#FFFFFF' + assert item['properties']['icon_colour'] == '#000000' -- 2.11.0