Projet

Général

Profil

0002-maps-add-map-cell-8454.patch

Voir les différences:

Subject: [PATCH 2/2] maps: add map cell (#8454)

 combo/apps/maps/migrations/0002_map.py          |  40 +++++++++
 combo/apps/maps/models.py                       |  91 ++++++++++++++++++++
 combo/apps/maps/static/css/combo.map.css        |  90 ++++++++++++++++++++
 combo/apps/maps/static/js/combo.map.js          |  68 +++++++++++++++
 combo/apps/maps/templates/maps/map_cell.html    |   3 +
 combo/apps/maps/templates/maps/map_widget.html  |   4 +
 combo/apps/maps/urls.py                         |   4 +
 combo/apps/maps/views.py                        |  36 ++++++++
 combo/manager/templates/combo/manager_base.html |   4 +
 combo/settings.py                               |  12 +++
 debian/control                                  |   1 +
 requirements.txt                                |   1 +
 setup.py                                        |   1 +
 tests/test_maps_cells.py                        | 107 ++++++++++++++++++++++++
 14 files changed, 462 insertions(+)
 create mode 100644 combo/apps/maps/migrations/0002_map.py
 create mode 100644 combo/apps/maps/static/css/combo.map.css
 create mode 100644 combo/apps/maps/static/js/combo.map.js
 create mode 100644 combo/apps/maps/templates/maps/map_cell.html
 create mode 100644 combo/apps/maps/templates/maps/map_widget.html
 create mode 100644 combo/apps/maps/views.py
 create mode 100644 tests/test_maps_cells.py
combo/apps/maps/migrations/0002_map.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
        ('data', '0027_page_picture'),
11
        ('auth', '0006_require_contenttypes_0002'),
12
        ('maps', '0001_initial'),
13
    ]
14

  
15
    operations = [
16
        migrations.CreateModel(
17
            name='Map',
18
            fields=[
19
                ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)),
20
                ('placeholder', models.CharField(max_length=20)),
21
                ('order', models.PositiveIntegerField()),
22
                ('slug', models.SlugField(verbose_name='Slug', blank=True)),
23
                ('extra_css_class', models.CharField(max_length=100, verbose_name='Extra classes for CSS styling', blank=True)),
24
                ('public', models.BooleanField(default=True, verbose_name='Public')),
25
                ('restricted_to_unlogged', models.BooleanField(default=False, verbose_name='Restrict to unlogged users')),
26
                ('last_update_timestamp', models.DateTimeField(auto_now=True)),
27
                ('title', models.CharField(max_length=150, verbose_name='Title', blank=True)),
28
                ('default_position', models.CharField(max_length=128, null=True, verbose_name='Default position', blank=True)),
29
                ('initial_zoom', models.CharField(default=b'13', max_length=2, verbose_name='Initial zoom level', choices=[(b'0', 'Whole world'), (b'9', 'Wide area'), (b'11', 'Area'), (b'13', 'Town'), (b'16', 'Small road'), (b'19', 'Ant')])),
30
                ('min_zoom', models.CharField(default=b'0', max_length=2, verbose_name='Minimal zoom level', choices=[(b'0', 'Whole world'), (b'9', 'Wide area'), (b'11', 'Area'), (b'13', 'Town'), (b'16', 'Small road'), (b'19', 'Ant')])),
31
                ('max_zoom', models.CharField(default=19, max_length=2, verbose_name='Maximal zoom level', choices=[(b'0', 'Whole world'), (b'9', 'Wide area'), (b'11', 'Area'), (b'13', 'Town'), (b'16', 'Small road'), (b'19', 'Ant')])),
32
                ('groups', models.ManyToManyField(to='auth.Group', verbose_name='Groups', blank=True)),
33
                ('layers', models.ManyToManyField(to='maps.MapLayer', verbose_name='Layers', blank=True)),
34
                ('page', models.ForeignKey(to='data.Page')),
35
            ],
36
            options={
37
                'verbose_name': 'Map',
38
            },
39
        ),
40
    ]
combo/apps/maps/models.py
18 18
from django.db import models
19 19
from django.utils.text import slugify
20 20
from django.utils.translation import ugettext_lazy as _
21
from django.core.urlresolvers import reverse_lazy
22
from django import forms
23
from django import template
24
from django.conf import settings
25
from django.core.exceptions import PermissionDenied
21 26

  
27
from combo.data.models import CellBase
28
from combo.data.library import register_cell_class
22 29
from combo.utils import requests
23 30

  
24 31

  
......
38 45
    ('truck', _('Truck')),
39 46
]
40 47

  
48
ZOOM_LEVELS = [ ('0', _('Whole world')),
49
                ('9', _('Wide area')),
50
                ('11', _('Area')),
51
                ('13', _('Town')),
52
                ('16', _('Small road')),
53
                ('19', _('Ant')),]
54

  
55

  
56
class MapWidget(forms.TextInput):
57
    template_name = 'maps/map_widget.html'
58

  
59
    def render(self, name, value, attrs):
60
        final_attrs = self.build_attrs(attrs, name=name, value=value,
61
                                       type='hidden')
62
        cell_form_template = template.loader.get_template(self.template_name)
63
        return cell_form_template.render(final_attrs)
64

  
41 65

  
42 66
class MapLayer(models.Model):
43 67
    label = models.CharField(_('Label'), max_length=128)
......
84 108
            feature['properties']['label'] = self.label
85 109
            feature['properties']['icon'] = self.icon
86 110
        return features
111

  
112

  
113
@register_cell_class
114
class Map(CellBase):
115
    title = models.CharField(_('Title'), max_length=150, blank=True)
116
    default_position = models.CharField(_('Default position'), null=True, blank=True,
117
                            max_length=128)
118
    initial_zoom = models.CharField(_('Initial zoom level'), max_length=2,
119
                                    choices=ZOOM_LEVELS, default='13')
120
    min_zoom = models.CharField(_('Minimal zoom level'), max_length=2,
121
                                   choices=ZOOM_LEVELS, default='0')
122
    max_zoom = models.CharField(_('Maximal zoom level'), max_length=2,
123
                                choices=ZOOM_LEVELS, default=19)
124
    layers = models.ManyToManyField(MapLayer, verbose_name=_('Layers'), blank=True)
125

  
126
    template_name = 'maps/map_cell.html'
127

  
128
    class Meta:
129
        verbose_name = _('Map')
130

  
131
    class Media:
132
        js = ('xstatic/leaflet.js', 'js/combo.map.js')
133
        css = {'all': ('xstatic/leaflet.css', 'css/combo.map.css')}
134

  
135
    def get_default_position(self):
136
        if self.default_position:
137
            return {'lat': self.default_position.split(';')[0],
138
                    'lng': self.default_position.split(';')[1]}
139
        return settings.COMBO_MAP_DEFAULT_POSITION
140

  
141
    def get_default_form_class(self):
142
        fields = ('title', 'default_position', 'initial_zoom', 'min_zoom',
143
                  'max_zoom', 'layers')
144

  
145
        default_position = self.get_default_position()
146
        map_attrs = {'init_lat': default_position['lat'], 'init_lng': default_position['lng'],
147
                     'tile_urltemplate': settings.COMBO_MAP_TILE_URLTEMPLATE,
148
                     'map_attribution': settings.COMBO_MAP_ATTRIBUTION}
149
        widgets = {'layers': forms.widgets.CheckboxSelectMultiple,
150
                   'default_position': MapWidget(attrs=map_attrs)}
151
        return forms.models.modelform_factory(self.__class__, fields=fields,
152
                                             widgets=widgets)
153

  
154
    def get_geojson(self, request):
155
        geojson = {'type': 'FeatureCollection', 'features': []}
156
        for layer in self.layers.all():
157
            geojson['features'] += layer.get_geojson(request)
158
        return geojson
159

  
160

  
161
    @classmethod
162
    def is_enabled(cls):
163
        return MapLayer.objects.count() > 0
164

  
165
    def get_cell_extra_context(self, context):
166
        ctx = super(Map, self).get_cell_extra_context(context)
167
        ctx['title'] = self.title
168
        default_position = self.get_default_position()
169
        ctx['init_lat'] = default_position['lat']
170
        ctx['init_lng'] = default_position['lng']
171
        ctx['initial_zoom'] = self.initial_zoom
172
        ctx['min_zoom'] = self.min_zoom
173
        ctx['max_zoom'] = self.max_zoom
174
        ctx['geojson_url'] = reverse_lazy('mapcell-geojson', kwargs={'cell_id': self.pk})
175
        ctx['tile_urltemplate'] = settings.COMBO_MAP_TILE_URLTEMPLATE
176
        ctx['map_attribution'] = settings.COMBO_MAP_ATTRIBUTION
177
        return ctx
combo/apps/maps/static/css/combo.map.css
1
div.combo-cell-map {
2
    height: 60vh;
3
}
4

  
5
/* leaflet styles */
6

  
7
div.leaflet-div-icon span {
8
    width: 2.3rem;
9
    height: 2.3rem;
10
    display: block;
11
    left: -1rem;
12
    top: -1rem;
13
    position: relative;
14
    border-radius: 11rem 6rem 0.8rem;
15
    transform: scale(1, 1.3) rotate(45deg);
16
    border: 1px solid #aaa;
17
}
18

  
19
div.leaflet-popup-content span {
20
    display: block;
21
}
22

  
23
div.leaflet-popup-content span.field-value {
24
    font-weight: bold;
25
}
26

  
27
div.leaflet-div-icon span i:before {
28
    display: inline-block;
29
    margin: 9px;
30
    transform: scale(1.1) rotate(-45deg);
31
}
32

  
33
/* leaflet markers icons */
34

  
35
i.leaflet-marker-icon {
36
    font: normal normal normal 1rem/1 FontAwesome;
37
}
38

  
39
i.leaflet-marker-icon.home::before {
40
    content: "\f015"; /* home */
41
}
42

  
43
i.leaflet-marker-icon.building::before {
44
    content: "\f0f7"; /* building */
45
}
46

  
47
i.leaflet-marker-icon.hospital::before {
48
    content: "\f0f8"; /* hospital */
49
}
50

  
51
i.leaflet-marker-icon.ambulance::before {
52
    content: "\f0f9"; /* ambulance */
53
}
54

  
55
i.leaflet-marker-icon.taxi::before {
56
    content: "\f1ba"; /* taxi */
57
}
58

  
59
i.leaflet-marker-icon.subway::before {
60
    content: "\f239"; /* subway */
61
}
62

  
63
i.leaflet-marker-icon.wheelchair::before {
64
    content: "\f193"; /* wheelchair */
65
}
66

  
67
i.leaflet-marker-icon.bicycle::before {
68
    content: "\f206"; /* bicycle */
69
}
70

  
71
i.leaflet-marker-icon.car::before {
72
    content: "\f1b9"; /* car */
73
}
74

  
75
i.leaflet-marker-icon.train::before {
76
    content: "\f238"; /* train */
77
}
78

  
79
i.leaflet-marker-icon.bus::before {
80
    content: "\f207"; /* bus */
81
}
82

  
83
i.leaflet-marker-icon.motorcycle::before {
84
    content: "\f21c"; /* motorcycle */
85
}
86

  
87
i.leaflet-marker-icon.truck::before {
88
    content: "\f0d1"; /* truck */
89
}
90

  
combo/apps/maps/static/js/combo.map.js
1
$(function() {
2
    function render_map(container) {
3
        $container = $(container);
4
        $container.find('div div.combo-cell-map').each(function() {
5
            var $map_widget = $(this);
6
            var map_options = Object();
7
            var initial_zoom = parseInt($map_widget.data('init-zoom'));
8
            if (! isNaN(initial_zoom)) {
9
                map_options.zoom = initial_zoom;
10
            } else {
11
                map_options.zoom = 13;
12
            }
13
            var max_zoom = parseInt($map_widget.data('max_zoom'));
14
            if (!isNaN(max_zoom)) map_options.maxZoom = max_zoom;
15
            var min_zoom = parseInt($map_widget.data('min-zoom'));
16
            if (!isNaN(min_zoom)) map_options.minZoom = min_zoom;
17
            var latlng = [$map_widget.data('init-lat'), $map_widget.data('init-lng')];
18
            var geojson_url = $map_widget.data('geojson-url');
19
            var map_tile_url = $map_widget.data('tile-urltemplate');
20
            var map_attribution = $map_widget.data('map-attribution');
21
            var map = L.map(this, map_options);
22
            var store_position_selector = $map_widget.data('store-position');
23
            map.setView(latlng, map_options.zoom);
24

  
25
            L.tileLayer(map_tile_url,
26
                {
27
                    attribution: map_attribution
28
                }).addTo(map);
29
            if (store_position_selector) {
30
                map.marker = L.marker(latlng);
31
                map.marker.addTo(map);
32
                var hidden = $('input#' + store_position_selector);
33
                map.on('click', function(e) {
34
                    map.marker.setLatLng(e.latlng);
35
                    hidden.val(e.latlng.lat + ';' + e.latlng.lng);
36
                });
37
            }
38
            if (geojson_url) {
39
                $.getJSON(geojson_url, function(data) {
40
                    var geo_json = L.geoJson(data, {
41
                        onEachFeature: function(feature, layer) {
42
                            $map_widget.trigger('combo:map-feature-click', feature, layer);
43
                        },
44
                        pointToLayer: function (feature, latlng) {
45
                            var markerStyles = "background-color: "+feature.properties.colour+";";
46
                            marker = L.divIcon({iconAnchor: [0, 30],
47
                                                popupAnchor: [5, -45],
48
                                                html: '<span style="' + markerStyles + '"><i class="leaflet-marker-icon '+feature.properties.icon+'" style="color:'+feature.properties.icon_colour+'"></i></span>'
49
                                               });
50
                            return L.marker(latlng, {icon: marker});
51
                        }
52
                    });
53
                    var bounds = geo_json.getBounds();
54
                    if (bounds.isValid()) {
55
                        map.fitBounds(bounds);
56
                        geo_json.addTo(map);
57
                    }
58
                });
59
            }
60
        });
61
    };
62
    $('div.combo-cell-map').parents('div.cell').on('combo:cellform-reloaded', function() {
63
        render_map(this);
64
    });
65
    $('div.combo-cell-map').parents('div.cell').each(function() {
66
        $(this).trigger('combo:cellform-reloaded');
67
    });
68
});
combo/apps/maps/templates/maps/map_cell.html
1
{% if title %}<h2>{{ title }}</h2>{% endif %}
2
<div class="combo-cell-map" data-init-zoom="{{ initial_zoom }}" data-min-zoom="{{ min_zoom }}" data-max-zoom="{{ max_zoom }}" data-init-lat="{{ init_lat }}" data-init-lng="{{ init_lng }}" data-geojson-url="{{ geojson_url }}" data-tile-urltemplate="{{ tile_urltemplate}}" data-map-attribution="{{ map_attribution}}">
3
</div>
combo/apps/maps/templates/maps/map_widget.html
1
<div class="combo-cell-map" data-init-lat="{{ init_lat }}" data-init-lng="{{ init_lng }}" data-store-position="combo-map-{{ id }}" data-tile-urltemplate="{{ tile_urltemplate}}" data-map-attribution="{{ map_attribution}}">
2
</div>
3

  
4
<input type="{{ type }}" name="{{ name }}" value="{{ init_lat }};{{ init_lng }}" id="combo-map-{{ id }}" />
combo/apps/maps/urls.py
21 21
from .manager_views import (ManagerHomeView, LayerAddView,
22 22
                LayerEditView, LayerDeleteView)
23 23

  
24
from .views import GeojsonView
25

  
24 26
maps_manager_urls = [
25 27
    url('^$', ManagerHomeView.as_view(), name='maps-manager-homepage'),
26 28
    url('^layers/add/$', LayerAddView.as_view(), name='maps-manager-layer-add'),
......
33 35
urlpatterns = [
34 36
    url(r'^manage/maps/', decorated_includes(manager_required,
35 37
        include(maps_manager_urls))),
38
    url(r'^ajax/mapcell/geojson/(?P<cell_id>\w+)/$', GeojsonView.as_view(),
39
        name='mapcell-geojson'),
36 40
]
combo/apps/maps/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
import json
18

  
19
from django.views.generic.base import View
20
from django.http import HttpResponse, Http404, HttpResponseForbidden
21

  
22
from .models import Map
23

  
24

  
25
class GeojsonView(View):
26

  
27
    def get(self, request, *args, **kwargs):
28
        try:
29
            cell = Map.objects.get(pk=kwargs['cell_id'])
30
        except Map.DoesNotExist:
31
            raise Http404()
32
        if cell.page.is_visible(request.user) and cell.is_visible(request.user):
33
            geojson = cell.get_geojson(request)
34
            content_type = 'application/json'
35
            return HttpResponse(json.dumps(geojson), content_type=content_type)
36
        return HttpResponseForbidden()
combo/manager/templates/combo/manager_base.html
3 3

  
4 4
{% block css %}
5 5
<link rel="stylesheet" type="text/css" media="all" href="{{ STATIC_URL }}css/combo.manager.css"/>
6
<link rel="stylesheet" type="text/css" media="all" href="{% static "xstatic/leaflet.css" %}"></script>
7
<link rel="stylesheet" type="text/css" media="all" href="{% static "css/combo.map.css" %}"></script>
6 8
{% endblock %}
7 9
{% block page-title %}{% firstof site_title "Combo" %}{% endblock %}
8 10
{% block site-title %}{% firstof site_title "Combo" %}{% endblock %}
......
30 32
<script src="{% static "ckeditor/ckeditor/ckeditor.js" %}"></script>
31 33
<script type="text/javascript" src="{% static "ckeditor/ckeditor-init.js" %}"></script>
32 34
<script src="{% static "js/combo.manager.js" %}"></script>
35
<script src="{% static "xstatic/leaflet.js" %}"></script>
36
<script src="{% static "js/combo.map.js" %}"></script>
33 37
{% endblock %}
combo/settings.py
80 80
    'combo.apps.maps',
81 81
    'haystack',
82 82
    'xstatic.pkg.chartnew_js',
83
    'xstatic.pkg.leaflet',
83 84
)
84 85

  
85 86
INSTALLED_APPS = plugins.register_plugins_apps(INSTALLED_APPS)
......
283 284
# dashboard support
284 285
COMBO_DASHBOARD_ENABLED = False
285 286

  
287
# default position on maps
288
COMBO_MAP_DEFAULT_POSITION = {'lat': '48.83369263315934',
289
                              'lng': '2.3233688436448574'
290
                              }
291

  
292
# default map tiles url
293
COMBO_MAP_TILE_URLTEMPLATE = 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png'
294

  
295
# default combo map attribution
296
COMBO_MAP_ATTRIBUTION = 'Map data &copy; <a href="https://openstreetmap.org">OpenStreetMap</a> contributors, <a href="http://creativecommons.org/licenses/by-sa/2.0/">CC-BY-SA</a>'
297

  
286 298
local_settings_file = os.environ.get('COMBO_SETTINGS_FILE',
287 299
        os.path.join(os.path.dirname(__file__), 'local_settings.py'))
288 300
if os.path.exists(local_settings_file):
debian/control
16 16
    python-feedparser,
17 17
    python-django-cmsplugin-blurp,
18 18
    python-xstatic-chartnew-js,
19
    python-xstatic-leaflet,
19 20
    python-eopayment (>= 1.9),
20 21
    python-django-haystack (>= 2.4.0),
21 22
    python-sorl-thumbnail,
requirements.txt
6 6
django-jsonfield
7 7
requests
8 8
XStatic-ChartNew.js
9
XStatic-Leaflet
9 10
eopayment>=1.13
10 11
python-dateutil
11 12
djangorestframework>=3.3, <3.4
setup.py
111 111
        'django-jsonfield',
112 112
        'requests',
113 113
        'XStatic-ChartNew.js',
114
        'XStatic-Leaflet',
114 115
        'eopayment>=1.13',
115 116
        'python-dateutil',
116 117
        'djangorestframework>=3.3, <3.4',
tests/test_maps_cells.py
1
# -*- coding: utf-8 -*-
2
import pytest
3

  
4
from django.contrib.auth.models import User
5
from django.test.client import RequestFactory
6
from django.template import Context
7
from django.test import Client
8
from django.core.urlresolvers import reverse
9
from django.contrib.auth.models import Group
10

  
11
from combo.data.models import Page
12
from combo.apps.maps.models import MapLayer, Map
13

  
14
pytestmark = pytest.mark.django_db
15

  
16
client = Client()
17

  
18
@pytest.fixture
19
def user():
20
    try:
21
        user = User.objects.get(username='admin')
22
    except User.DoesNotExist:
23
        user = User.objects.create_user('admin', email=None, password='admin')
24
    return user
25

  
26
@pytest.fixture
27
def layer():
28
    try:
29
        layer = MapLayer.objects.get()
30
    except MapLayer.DoesNotExist:
31
        layer = MapLayer()
32
        layer.geojson_url = 'http://example.net/geojson'
33
        layer.marker_colour = 'FF0000'
34
        layer.icon = 'fa-bicycle'
35
        layer.icon_colour = '0000FF'
36
        layer.save()
37
    return layer
38

  
39
def login(username='admin', password='admin'):
40
    resp = client.post('/login/', {'username': username, 'password': password})
41
    assert resp.status_code == 302
42

  
43
def test_cell_disabled():
44
    MapLayer.objects.all().delete()
45
    assert Map.is_enabled() is False
46

  
47
def test_cell_enabled(layer):
48
    assert Map.is_enabled() is True
49

  
50
def test_cell_rendering(layer):
51
    page = Page(title='xxx', slug='test_map_cell', template_name='standard')
52
    page.save()
53
    cell = Map(page=page, placeholder='content', order=0, title = 'Map with points',
54
               default_position="48.83369263315934;2.3233688436448574")
55
    cell.save()
56
    cell.layers.add(layer)
57
    context = Context({'request': RequestFactory().get('/')})
58
    rendered = cell.render(context)
59
    assert 'data-init-zoom="13"' in rendered
60
    assert 'data-min-zoom="0"' in rendered
61
    assert 'data-max-zoom="19"' in rendered
62
    assert 'data-init-lat="48.83369263315934"' in rendered
63
    assert 'data-init-lng="2.3233688436448574"' in rendered
64
    assert 'data-geojson-url="/ajax/mapcell/geojson/1/"' in rendered
65
    resp = client.get('/test_map_cell/')
66
    assert 'xstatic/leaflet.js' in resp.content
67
    assert 'js/combo.map.js' in resp.content
68
    assert 'xstatic/leaflet.css' in resp.content
69
    assert 'css/combo.map.css' in resp.content
70

  
71

  
72
def test_get_geojson_on_non_public_page(layer):
73
    page = Page(title='xxx', slug='new', template_name='standard',
74
                public=False)
75
    page.save()
76
    cell = Map(page=page, placeholder='content', order=0,
77
                   title = 'Map with points')
78
    cell.save()
79
    cell.layers.add(layer)
80
    resp = client.get(reverse('mapcell-geojson', kwargs={'cell_id': cell.id}))
81
    assert resp.status_code == 403
82

  
83
def test_get_geojson_on_non_publik_cell(layer):
84
    page = Page(title='xxx', slug='new', template_name='standard')
85
    page.save()
86
    cell = Map(page=page, placeholder='content', order=0, public=False,
87
                   title = 'Map with points')
88
    cell.save()
89
    cell.layers.add(layer)
90
    resp = client.get(reverse('mapcell-geojson', kwargs={'cell_id': cell.id}))
91
    assert resp.status_code == 403
92

  
93
def test_geojson_on_restricted_cell(layer, user):
94
    page = Page(title='xxx', slug='new', template_name='standard')
95
    page.save()
96
    group = Group.objects.create(name='map tester')
97
    cell = Map(page=page, placeholder='content', order=0, public=False)
98
    cell.title = 'Map with points'
99
    cell.save()
100
    cell.layers.add(layer)
101
    cell.groups.add(group)
102
    login()
103
    resp = client.get(reverse('mapcell-geojson', kwargs={'cell_id': cell.id}))
104
    assert resp.status_code == 403
105
    user.groups.add(group)
106
    user.save()
107
    resp = client.get(reverse('mapcell-geojson', kwargs={'cell_id': cell.id}))
0
-