0001-maps-add-layers-8454.patch
combo/apps/maps/__init__.py | ||
---|---|---|
1 |
# combo - content management system |
|
2 |
# Copyright (C) 2017 Entr'ouvert |
|
3 |
# |
|
4 |
# This program is free software: you can redistribute it and/or modify it |
|
5 |
# under the terms of the GNU Affero General Public License as published |
|
6 |
# by the Free Software Foundation, either version 3 of the License, or |
|
7 |
# (at your option) any later version. |
|
8 |
# |
|
9 |
# This program is distributed in the hope that it will be useful, |
|
10 |
# but WITHOUT ANY WARRANTY; without even the implied warranty of |
|
11 |
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
|
12 |
# GNU Affero General Public License for more details. |
|
13 |
# |
|
14 |
# You should have received a copy of the GNU Affero General Public License |
|
15 |
# along with this program. If not, see <http://www.gnu.org/licenses/>. |
|
16 | ||
17 |
import django.apps |
|
18 |
from django.core.urlresolvers import reverse |
|
19 |
from django.utils.translation import ugettext_lazy as _ |
|
20 | ||
21 | ||
22 |
class AppConfig(django.apps.AppConfig): |
|
23 |
name = 'combo.apps.maps' |
|
24 |
verbose_name = _('Maps') |
|
25 | ||
26 |
def get_before_urls(self): |
|
27 |
from . import urls |
|
28 |
return urls.urlpatterns |
|
29 | ||
30 |
def get_extra_manager_actions(self): |
|
31 |
return [{'href': reverse('maps-manager-homepage'), |
|
32 |
'text': _('Maps')}] |
|
33 | ||
34 |
default_app_config = 'combo.apps.maps.AppConfig' |
combo/apps/maps/forms.py | ||
---|---|---|
1 |
# combo - content management system |
|
2 |
# Copyright (C) 2017 Entr'ouvert |
|
3 |
# |
|
4 |
# This program is free software: you can redistribute it and/or modify it |
|
5 |
# under the terms of the GNU Affero General Public License as published |
|
6 |
# by the Free Software Foundation, either version 3 of the License, or |
|
7 |
# (at your option) any later version. |
|
8 |
# |
|
9 |
# This program is distributed in the hope that it will be useful, |
|
10 |
# but WITHOUT ANY WARRANTY; without even the implied warranty of |
|
11 |
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
|
12 |
# GNU Affero General Public License for more details. |
|
13 |
# |
|
14 |
# You should have received a copy of the GNU Affero General Public License |
|
15 |
# along with this program. If not, see <http://www.gnu.org/licenses/>. |
|
16 | ||
17 |
from django import forms |
|
18 |
from .models import MapLayer |
|
19 | ||
20 |
class MapLayerForm(forms.ModelForm): |
|
21 |
class Meta: |
|
22 |
model = MapLayer |
|
23 |
exclude = ('slug', ) |
|
24 |
widgets = {'marker_colour': forms.TextInput(attrs={'type': 'color'}), |
|
25 |
'icon_colour': forms.TextInput(attrs={'type': 'color'}) |
|
26 |
} |
combo/apps/maps/manager_views.py | ||
---|---|---|
1 |
# combo - content management system |
|
2 |
# Copyright (C) 2017 Entr'ouvert |
|
3 |
# |
|
4 |
# This program is free software: you can redistribute it and/or modify it |
|
5 |
# under the terms of the GNU Affero General Public License as published |
|
6 |
# by the Free Software Foundation, either version 3 of the License, or |
|
7 |
# (at your option) any later version. |
|
8 |
# |
|
9 |
# This program is distributed in the hope that it will be useful, |
|
10 |
# but WITHOUT ANY WARRANTY; without even the implied warranty of |
|
11 |
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
|
12 |
# GNU Affero General Public License for more details. |
|
13 |
# |
|
14 |
# You should have received a copy of the GNU Affero General Public License |
|
15 |
# along with this program. If not, see <http://www.gnu.org/licenses/>. |
|
16 | ||
17 |
from django.core.urlresolvers import reverse_lazy |
|
18 |
from django.views.generic import (TemplateView, ListView, CreateView, |
|
19 |
UpdateView, DeleteView) |
|
20 | ||
21 |
from .models import MapLayer |
|
22 |
from .forms import MapLayerForm |
|
23 | ||
24 | ||
25 |
class MapLayerMixin(object): |
|
26 |
model = MapLayer |
|
27 |
success_url = reverse_lazy('maps-manager-homepage') |
|
28 | ||
29 | ||
30 |
class ManagerHomeView(MapLayerMixin, ListView): |
|
31 |
template_name = 'maps/manager_home.html' |
|
32 | ||
33 | ||
34 |
class LayerAddView(MapLayerMixin, CreateView): |
|
35 |
form_class = MapLayerForm |
|
36 |
template_name = 'maps/map_layer_form.html' |
|
37 | ||
38 | ||
39 |
class LayerEditView(MapLayerMixin, UpdateView): |
|
40 |
form_class = MapLayerForm |
|
41 |
template_name = 'maps/map_layer_form.html' |
|
42 | ||
43 | ||
44 |
class LayerDeleteView(MapLayerMixin, DeleteView): |
|
45 |
template_name = 'maps/map_layer_confirm_delete.html' |
combo/apps/maps/migrations/0001_initial.py | ||
---|---|---|
1 |
# -*- coding: utf-8 -*- |
|
2 |
from __future__ import unicode_literals |
|
3 | ||
4 |
from django.db import migrations, models |
|
5 | ||
6 | ||
7 |
class Migration(migrations.Migration): |
|
8 | ||
9 |
dependencies = [ |
|
10 |
] |
|
11 | ||
12 |
operations = [ |
|
13 |
migrations.CreateModel( |
|
14 |
name='MapLayer', |
|
15 |
fields=[ |
|
16 |
('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), |
|
17 |
('label', models.CharField(max_length=128, verbose_name='Label')), |
|
18 |
('slug', models.SlugField(verbose_name='Slug')), |
|
19 |
('geojson_url', models.URLField(max_length=1024, verbose_name='Geojson URL')), |
|
20 |
('marker_colour', models.CharField(default=b'#0000FF', max_length=7, verbose_name='Marker colour')), |
|
21 |
('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')])), |
|
22 |
('icon_colour', models.CharField(default=b'#FFFFFF', max_length=7, verbose_name='Icon colour')), |
|
23 |
], |
|
24 |
), |
|
25 |
] |
combo/apps/maps/models.py | ||
---|---|---|
1 |
# combo - content management system |
|
2 |
# Copyright (C) 2017 Entr'ouvert |
|
3 |
# |
|
4 |
# This program is free software: you can redistribute it and/or modify it |
|
5 |
# under the terms of the GNU Affero General Public License as published |
|
6 |
# by the Free Software Foundation, either version 3 of the License, or |
|
7 |
# (at your option) any later version. |
|
8 |
# |
|
9 |
# This program is distributed in the hope that it will be useful, |
|
10 |
# but WITHOUT ANY WARRANTY; without even the implied warranty of |
|
11 |
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
|
12 |
# GNU Affero General Public License for more details. |
|
13 |
# |
|
14 |
# You should have received a copy of the GNU Affero General Public License |
|
15 |
# along with this program. If not, see <http://www.gnu.org/licenses/>. |
|
16 | ||
17 | ||
18 |
from django.db import models |
|
19 |
from django.utils.text import slugify |
|
20 |
from django.utils.translation import ugettext_lazy as _ |
|
21 | ||
22 |
from combo.utils import requests |
|
23 | ||
24 | ||
25 |
ICONS = [ |
|
26 |
('home', _('Home')), |
|
27 |
('building', _('Building')), |
|
28 |
('hospital', _('Hospital')), |
|
29 |
('ambulance', _('Ambulance')), |
|
30 |
('taxi', _('Taxi')), |
|
31 |
('subway', _('Subway')), |
|
32 |
('wheelchair', _('Wheelchair')), |
|
33 |
('bicycle', _('Bicycle')), |
|
34 |
('car', _('Car')), |
|
35 |
('train', _('Train')), |
|
36 |
('bus', _('Bus')), |
|
37 |
('motorcycle', _('Motorcycle')), |
|
38 |
('truck', _('Truck')), |
|
39 |
] |
|
40 | ||
41 | ||
42 |
class MapLayer(models.Model): |
|
43 |
label = models.CharField(_('Label'), max_length=128) |
|
44 |
slug = models.SlugField(_('Slug')) |
|
45 |
geojson_url = models.URLField(_('Geojson URL'), max_length=1024) |
|
46 |
marker_colour = models.CharField(_('Marker colour'), max_length=7, default='#0000FF') |
|
47 |
icon = models.CharField(_('Marker icon'), max_length=32, blank=True, null=True, |
|
48 |
choices=ICONS) |
|
49 |
icon_colour = models.CharField(_('Icon colour'), max_length=7, default='#FFFFFF') |
|
50 | ||
51 |
def save(self, *args, **kwargs): |
|
52 |
if not self.slug: |
|
53 |
base_slug = slugify(self.label) |
|
54 |
slug = base_slug |
|
55 |
i = 1 |
|
56 |
while True: |
|
57 |
try: |
|
58 |
MapLayer.objects.get(slug=slug) |
|
59 |
except self.DoesNotExist: |
|
60 |
break |
|
61 |
slug = '%s-%s' % (base_slug, i) |
|
62 |
i += 1 |
|
63 |
self.slug = slug |
|
64 |
super(MapLayer, self).save(*args, **kwargs) |
|
65 | ||
66 |
def __unicode__(self): |
|
67 |
return self.label |
|
68 | ||
69 |
def get_geojson(self, request): |
|
70 |
response = requests.get(self.geojson_url, |
|
71 |
remote_service='auto', |
|
72 |
user=request.user, |
|
73 |
headers={'accept': 'application/json'}) |
|
74 |
if not response.ok: |
|
75 |
return [] |
|
76 |
data = response.json() |
|
77 |
if 'features' in data: |
|
78 |
features = data['features'] |
|
79 |
else: |
|
80 |
features = data |
|
81 | ||
82 |
for feature in features: |
|
83 |
if 'display_fields' not in feature['properties']: |
|
84 |
display_fields = [] |
|
85 |
for label, value in feature['properties'].iteritems(): |
|
86 |
if value is not None: |
|
87 |
display_fields.append((label, value)) |
|
88 |
feature['properties']['display_fields'] = display_fields |
|
89 |
feature['properties']['colour'] = self.marker_colour |
|
90 |
feature['properties']['icon_colour'] = self.icon_colour |
|
91 |
feature['properties']['label'] = self.label |
|
92 |
feature['properties']['icon'] = self.icon |
|
93 |
return features |
combo/apps/maps/templates/maps/manager_base.html | ||
---|---|---|
1 |
{% extends "combo/manager_base.html" %} |
|
2 |
{% load i18n %} |
|
3 | ||
4 |
{% block appbar %} |
|
5 |
<h2>{% trans 'Maps' %}</h2> |
|
6 |
{% endblock %} |
|
7 | ||
8 |
{% block breadcrumb %} |
|
9 |
{{ block.super }} |
|
10 |
<a href="{% url 'maps-manager-homepage' %}">{% trans 'Maps' %}</a> |
|
11 |
{% endblock %} |
combo/apps/maps/templates/maps/manager_home.html | ||
---|---|---|
1 |
{% extends "maps/manager_base.html" %} |
|
2 |
{% load i18n %} |
|
3 | ||
4 |
{% block appbar %} |
|
5 |
<h2>{% trans 'Maps' %}</h2> |
|
6 |
<a rel="popup" href="{% url 'maps-manager-layer-add' %}">{% trans 'New' %}</a> |
|
7 |
{% endblock %} |
|
8 | ||
9 |
{% block content %} |
|
10 |
{% if object_list %} |
|
11 |
<div class="objects-list"> |
|
12 |
{% for layer in object_list %} |
|
13 |
<div> |
|
14 |
<a href="{% url 'maps-manager-layer-edit' slug=layer.slug %}">{{ layer.label }}</a> |
|
15 |
</div> |
|
16 |
{% endfor %} |
|
17 |
</div> |
|
18 |
{% else %} |
|
19 |
<div class="big-msg-info"> |
|
20 |
{% blocktrans %} |
|
21 |
This site doesn't have any layer yet. Click on the "New" button in the top |
|
22 |
right of the page to add a first one. |
|
23 |
{% endblocktrans %} |
|
24 |
</div> |
|
25 |
{% endif %} |
|
26 |
{% endblock %} |
combo/apps/maps/templates/maps/map_layer_confirm_delete.html | ||
---|---|---|
1 |
{% extends "combo/manager_base.html" %} |
|
2 |
{% load i18n %} |
|
3 | ||
4 |
{% block appbar %} |
|
5 |
<h2>{{ view.model.get_verbose_name }}</h2> |
|
6 |
{% endblock %} |
|
7 | ||
8 |
{% block content %} |
|
9 |
<form method="post"> |
|
10 |
{% csrf_token %} |
|
11 |
{% blocktrans %}Are you sure you want to delete this?{% endblocktrans %} |
|
12 |
<div class="buttons"> |
|
13 |
<button class="delete-button">{% trans 'Delete' %}</button> |
|
14 |
<a class="cancel" href="{% url 'maps-manager-homepage' %}">{% trans 'Cancel' %}</a> |
|
15 |
</div> |
|
16 |
</form> |
|
17 |
{% endblock %} |
combo/apps/maps/templates/maps/map_layer_form.html | ||
---|---|---|
1 |
{% extends "maps/manager_home.html" %} |
|
2 |
{% load i18n %} |
|
3 | ||
4 |
{% block appbar %} |
|
5 |
{% if object.id %} |
|
6 |
<h2>{% trans "Edit Map Layer" %}</h2> |
|
7 |
{% else %} |
|
8 |
<h2>{% trans "New Map Layer" %}</h2> |
|
9 |
{% endif %} |
|
10 |
{% endblock %} |
|
11 | ||
12 |
{% block breadcrumb %} |
|
13 |
{{ block.super }} |
|
14 |
<a href="{% url 'maps-manager-homepage' %}">{% trans 'Maps' %}</a> |
|
15 |
{% endblock %} |
|
16 | ||
17 |
{% block content %} |
|
18 | ||
19 |
<form method="post" enctype="multipart/form-data"> |
|
20 |
{% csrf_token %} |
|
21 |
{{ form.as_p }} |
|
22 |
<div class="buttons"> |
|
23 |
<button class="submit-button">{% trans "Save" %}</button> |
|
24 |
<a class="cancel" href="{% url 'maps-manager-homepage' %}">{% trans 'Cancel' %}</a> |
|
25 |
{% if object.id %} |
|
26 |
<a class="delete" rel="popup" href="{% url 'maps-manager-layer-delete' slug=object.slug %}">{% trans 'Delete' %}</a> |
|
27 |
{% endif %} |
|
28 |
</div> |
|
29 |
</form> |
|
30 |
{% endblock %} |
combo/apps/maps/urls.py | ||
---|---|---|
1 |
# combo - content management system |
|
2 |
# Copyright (C) 2017 Entr'ouvert |
|
3 |
# |
|
4 |
# This program is free software: you can redistribute it and/or modify it |
|
5 |
# under the terms of the GNU Affero General Public License as published |
|
6 |
# by the Free Software Foundation, either version 3 of the License, or |
|
7 |
# (at your option) any later version. |
|
8 |
# |
|
9 |
# This program is distributed in the hope that it will be useful, |
|
10 |
# but WITHOUT ANY WARRANTY; without even the implied warranty of |
|
11 |
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
|
12 |
# GNU Affero General Public License for more details. |
|
13 |
# |
|
14 |
# You should have received a copy of the GNU Affero General Public License |
|
15 |
# along with this program. If not, see <http://www.gnu.org/licenses/>. |
|
16 | ||
17 |
from django.conf.urls import url, include |
|
18 | ||
19 |
from combo.urls_utils import decorated_includes, manager_required |
|
20 | ||
21 |
from .manager_views import (ManagerHomeView, LayerAddView, |
|
22 |
LayerEditView, LayerDeleteView) |
|
23 | ||
24 |
maps_manager_urls = [ |
|
25 |
url('^$', ManagerHomeView.as_view(), name='maps-manager-homepage'), |
|
26 |
url('^layers/add/$', LayerAddView.as_view(), name='maps-manager-layer-add'), |
|
27 |
url('^layers/(?P<slug>[\w-]+)/edit/$', LayerEditView.as_view(), |
|
28 |
name='maps-manager-layer-edit'), |
|
29 |
url('^layers/(?P<slug>[\w-]+)/delete/$', LayerDeleteView.as_view(), |
|
30 |
name='maps-manager-layer-delete'), |
|
31 |
] |
|
32 | ||
33 |
urlpatterns = [ |
|
34 |
url(r'^manage/maps/', decorated_includes(manager_required, |
|
35 |
include(maps_manager_urls))), |
|
36 |
] |
combo/settings.py | ||
---|---|---|
77 | 77 |
'combo.apps.notifications', |
78 | 78 |
'combo.apps.search', |
79 | 79 |
'combo.apps.usersearch', |
80 |
'combo.apps.maps', |
|
80 | 81 |
'haystack', |
81 | 82 |
'xstatic.pkg.chartnew_js', |
82 | 83 |
) |
tests/test_maps_manager.py | ||
---|---|---|
1 |
# -*- coding: utf-8 -*- |
|
2 | ||
3 |
import pytest |
|
4 |
import mock |
|
5 | ||
6 |
from django.contrib.auth.models import User |
|
7 | ||
8 |
from combo.apps.maps.models import MapLayer |
|
9 | ||
10 |
pytestmark = pytest.mark.django_db |
|
11 | ||
12 |
@pytest.fixture |
|
13 |
def admin_user(): |
|
14 |
try: |
|
15 |
user = User.objects.get(username='admin') |
|
16 |
except User.DoesNotExist: |
|
17 |
user = User.objects.create_superuser('admin', email=None, password='admin') |
|
18 |
return user |
|
19 | ||
20 |
def login(app, username='admin', password='admin'): |
|
21 |
login_page = app.get('/login/') |
|
22 |
login_form = login_page.forms[0] |
|
23 |
login_form['username'] = username |
|
24 |
login_form['password'] = password |
|
25 |
resp = login_form.submit() |
|
26 |
assert resp.status_int == 302 |
|
27 |
return app |
|
28 | ||
29 |
def test_access(app, admin_user): |
|
30 |
app = login(app) |
|
31 |
resp = app.get('/manage/', status=200) |
|
32 |
assert '/manage/maps/' in resp.body |
|
33 | ||
34 |
def test_add_layer(app, admin_user): |
|
35 |
MapLayer.objects.all().delete() |
|
36 |
app = login(app) |
|
37 |
resp = app.get('/manage/maps/', status=200) |
|
38 |
resp = resp.click('New') |
|
39 |
resp.forms[0]['label'] = 'Test' |
|
40 |
resp.forms[0]['geojson_url'] = 'http://example.net/geojson' |
|
41 |
assert resp.form['marker_colour'].value == '#0000FF' |
|
42 |
resp.forms[0]['marker_colour'] = '#FFFFFF' |
|
43 |
resp.forms[0]['icon'] = 'bicycle' |
|
44 |
assert resp.form['icon_colour'].value == '#FFFFFF' |
|
45 |
resp.form['icon_colour'] = '#000000' |
|
46 |
resp = resp.forms[0].submit() |
|
47 |
assert resp.location == 'http://testserver/manage/maps/' |
|
48 |
assert MapLayer.objects.count() == 1 |
|
49 |
layer = MapLayer.objects.get() |
|
50 |
assert layer.label == 'Test' |
|
51 |
assert layer.slug == 'test' |
|
52 | ||
53 |
def test_edit_layer(app, admin_user): |
|
54 |
test_add_layer(app, admin_user) |
|
55 |
app = login(app) |
|
56 |
resp = app.get('/manage/maps/', status=200) |
|
57 |
resp = resp.click('Test') |
|
58 |
resp.forms[0]['geojson_url'] = 'http://example.net/new_geojson' |
|
59 |
resp = resp.forms[0].submit() |
|
60 |
assert resp.location == 'http://testserver/manage/maps/' |
|
61 |
assert MapLayer.objects.count() == 1 |
|
62 |
layer = MapLayer.objects.get() |
|
63 |
assert layer.geojson_url == 'http://example.net/new_geojson' |
|
64 | ||
65 |
def test_delete_layer(app, admin_user): |
|
66 |
test_add_layer(app, admin_user) |
|
67 |
app = login(app) |
|
68 |
resp = app.get('/manage/maps/', status=200) |
|
69 |
resp = resp.click('Test') |
|
70 |
resp = resp.click('Delete') |
|
71 |
assert 'Are you sure you want to delete this?' in resp.body |
|
72 |
resp = resp.forms[0].submit() |
|
73 |
assert resp.location == 'http://testserver/manage/maps/' |
|
74 |
assert MapLayer.objects.count() == 0 |
|
75 | ||
76 |
@mock.patch('combo.apps.maps.models.requests.get') |
|
77 |
def test_download_geojson(mock_request, app, admin_user): |
|
78 |
test_add_layer(app, admin_user) |
|
79 |
layer = MapLayer.objects.get() |
|
80 |
mocked_response = mock.Mock() |
|
81 |
mocked_response.json.return_value = [{'type': 'Feature', |
|
82 |
'geometry': {'type': 'Point', |
|
83 |
'coordinates': [2.3233688436448574, 48.83369263315934]}, |
|
84 |
'properties': {'property': 'property value'}}] |
|
85 |
mocked_response.ok.return_value = True |
|
86 |
mock_request.return_value = mocked_response |
|
87 |
geojson = layer.get_geojson(mock_request) |
|
88 |
assert len(geojson) > 0 |
|
89 |
for item in geojson: |
|
90 |
assert item['type'] == 'Feature' |
|
91 |
assert item['geometry']['type'] == 'Point' |
|
92 |
assert item['geometry']['coordinates'] == [2.3233688436448574, 48.83369263315934] |
|
93 |
assert item['properties']['display_fields'] == [('property', 'property value')] |
|
94 |
assert item['properties']['icon'] == 'bicycle' |
|
95 |
assert item['properties']['label'] == 'Test' |
|
96 |
assert item['properties']['colour'] == '#FFFFFF' |
|
97 |
assert item['properties']['icon_colour'] == '#000000' |
|
98 | ||
99 |
mocked_response.json.return_value = {'type': 'FeatureCollection', |
|
100 |
'features': [{'geometry': {'type': 'Point', |
|
101 |
'coordinates': [2.3233688436448574, 48.83369263315934]}, |
|
102 |
'properties': {'property': 'a random value', |
|
103 |
'display_fields': [('foo', 'bar')]}} |
|
104 |
] |
|
105 |
} |
|
106 |
mocked_response.ok.return_value = True |
|
107 |
mock_request.return_value = mocked_response |
|
108 |
geojson = layer.get_geojson(mock_request) |
|
109 |
assert len(geojson) > 0 |
|
110 |
for item in geojson: |
|
111 |
assert item['geometry']['type'] == 'Point' |
|
112 |
assert item['geometry']['coordinates'] == [2.3233688436448574, 48.83369263315934] |
|
113 |
assert item['properties']['display_fields'] == [('foo', 'bar')] |
|
114 |
assert item['properties']['icon'] == 'bicycle' |
|
115 |
assert item['properties']['label'] == 'Test' |
|
116 |
assert item['properties']['colour'] == '#FFFFFF' |
|
117 |
assert item['properties']['icon_colour'] == '#000000' |
|
0 |
- |