combo/combo/apps/maps/models.py

326 lines
12 KiB
Python

# 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 json
from django.core import serializers
from django.db import models
from django.utils import six
from django.utils.encoding import python_2_unicode_compatible
from django.utils.text import slugify
from django.utils.translation import ugettext_lazy as _
from django.core.urlresolvers import reverse_lazy
from django import forms
from django.conf import settings
import pyproj
from combo.data.models import CellBase
from combo.data.library import register_cell_class
from combo.utils import requests, get_templated_url
ICONS = [
('ambulance', _('Ambulance')),
('asterisk', _('Asterisk')),
('bell', _('Bell')),
('bicycle', _('Bicycle')),
('book', _('Book')),
('broken_chain', _('Broken chain')),
('building', _('Building')),
('bus', _('Bus')),
('car', _('Car')),
('checkmark', _('Checkmark')),
('cube', _('Cube')),
('drop', _('Drop')),
('eye', _('Eye')),
('flag', _('Flag')),
('gavel', _('Gavel')),
('hospital', _('Hospital')),
('house', _('House')),
('lightbulb', _('Lightbulb')),
('map_signs', _('Map signs')),
('motorcycle', _('Motorcycle')),
('paint_brush', _('Paint brush')),
('paw', _('Paw')),
('recycle', _('Recycle')),
('road', _('Road')),
('shower', _('Shower')),
('star', _('Star')),
('subway', _('Subway')),
('taxi', _('Taxi')),
('train', _('Train')),
('trash', _('Trash')),
('tree', _('Tree')),
('truck', _('Truck')),
('university', _('University')),
('warning', _('Warning')),
('wheelchair', _('Wheelchair')),
]
MARKER_BEHAVIOUR_ONCLICK = [
('none', _('Nothing')),
('display_data', _('Display data in popup')),
]
ZOOM_LEVELS = [ ('0', _('Whole world')),
('9', _('Wide area')),
('11', _('Area')),
('13', _('Town')),
('16', _('Small road')),
('18', _('Neighbourhood')),
('19', _('Ant')),]
class MapLayerManager(models.Manager):
def get_by_natural_key(self, slug):
return self.get(slug=slug)
@python_2_unicode_compatible
class MapLayer(models.Model):
objects = MapLayerManager()
label = models.CharField(_('Label'), max_length=128)
slug = models.SlugField(_('Identifier'))
geojson_url = models.CharField(_('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='#000000')
cache_duration = models.PositiveIntegerField(_('Cache duration'), default=60)
include_user_identifier = models.BooleanField(
_('Include user identifier in request'),
default=True)
properties = models.CharField(_('Properties'), max_length=500, blank=True,
help_text=_('List of properties to include, separated by commas'))
class Meta:
ordering = ('label',)
def save(self, *args, **kwargs):
if not self.slug:
base_slug = slugify(self.label)[:45]
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 __str__(self):
return self.label
def natural_key(self):
return (self.slug, )
@classmethod
def export_all_for_json(cls):
return [x.get_as_serialized_object() for x in MapLayer.objects.all()]
def get_as_serialized_object(self):
serialized_layer = json.loads(serializers.serialize('json', [self],
use_natural_foreign_keys=True, use_natural_primary_keys=True))[0]
del serialized_layer['model']
return serialized_layer
@classmethod
def load_serialized_objects(cls, json_site):
for json_layer in json_site:
cls.load_serialized_object(json_layer)
@classmethod
def load_serialized_object(cls, json_layer):
json_layer['model'] = 'maps.maplayer'
layer, created = MapLayer.objects.get_or_create(slug=json_layer['fields']['slug'])
json_layer['pk'] = layer.id
layer = [x for x in serializers.deserialize('json', json.dumps([json_layer]))][0]
layer.save()
def get_geojson(self, request=None, multiple_layers=False):
geojson_url = get_templated_url(self.geojson_url)
response = requests.get(geojson_url,
remote_service='auto',
cache_duration=self.cache_duration,
user=request.user if (request and self.include_user_identifier) else None,
without_user=not(self.include_user_identifier),
headers={'accept': 'application/json'})
if not response.ok:
return []
data = response.json()
if 'features' in data:
features = data['features']
else:
features = data
properties = []
if self.properties:
properties = [x.strip() for x in self.properties.split(',')]
for feature in features:
if 'display_fields' in feature['properties']:
# w.c.s. content, filter fields on varnames
feature['properties']['display_fields'] = [
x for x in feature['properties']['display_fields']
if x.get('varname') in properties]
else:
# classic geojson, filter properties
feature['properties'] = dict(
[x for x in feature['properties'].items() if x[0] in properties])
if request and request.GET.get('distance'):
distance = float(request.GET['distance'])
center_lat = float(request.GET['lat'])
center_lng = float(request.GET['lng'])
geod = pyproj.Geod(ellps='WGS84')
south_lat = geod.fwd(center_lng, center_lat, 180, distance)[1]
north_lat = geod.fwd(center_lng, center_lat, 0, distance)[1]
east_lng = geod.fwd(center_lng, center_lat, 90, distance)[0]
west_lng = geod.fwd(center_lng, center_lat, -90, distance)[0]
def match(feature):
if feature['geometry']['type'] != 'Point':
return True
lng, lat = feature['geometry']['coordinates']
return bool(west_lng < lng < east_lng and south_lat < lat < north_lat)
features = [x for x in features if match(x)]
if request and request.GET.get('q'):
# all words must match
query_words = [slugify(x) for x in request.GET['q'].split()]
additional_strings = []
if multiple_layers: # also match on layer name
additional_strings = [self.label]
def match(feature):
matching_query_words = set()
feature_words = additional_strings[:]
def get_feature_words(properties):
for property in properties.values():
if isinstance(property, six.string_types):
feature_words.append(property)
elif isinstance(property, dict):
get_feature_words(property)
get_feature_words(feature['properties'])
for feature_word in feature_words:
for word in query_words:
if word in slugify(feature_word):
matching_query_words.add(word)
if len(matching_query_words) == len(query_words):
return True
return False
features = [x for x in features if match(x)]
for feature in features:
feature['properties']['layer'] = {
'colour': self.marker_colour,
'icon_colour': self.icon_colour,
'label': self.label,
'icon': self.icon,
'identifier': self.slug,
'properties': properties,
}
return features
@register_cell_class
class Map(CellBase):
title = models.CharField(_('Title'), max_length=150, blank=True)
initial_state = models.CharField(
_('Initial state'),
max_length=20,
choices=[
('default-position', _('Centered on default position')),
('device-location', _('Centered on device location')),
('fit-markers', _('Centered to fit all markers')),
],
default='default-position')
initial_zoom = models.CharField(_('Initial zoom level'), max_length=2,
choices=ZOOM_LEVELS, default='13')
min_zoom = models.CharField(_('Minimal zoom level'), max_length=2,
choices=ZOOM_LEVELS, default='0')
max_zoom = models.CharField(_('Maximal zoom level'), max_length=2,
choices=ZOOM_LEVELS, default=19)
group_markers = models.BooleanField(_('Group markers in clusters'), default=False)
marker_behaviour_onclick = models.CharField(_('Marker behaviour on click'), max_length=32,
default='none', choices=MARKER_BEHAVIOUR_ONCLICK)
layers = models.ManyToManyField(MapLayer, verbose_name=_('Layers'), blank=True)
template_name = 'maps/map_cell.html'
class Meta:
verbose_name = _('Map')
class Media:
js = ('/jsi18n',
'xstatic/leaflet.js', 'js/leaflet-gps.js', 'js/combo.map.js',
'xstatic/leaflet.markercluster.js')
css = {'all': ('xstatic/leaflet.css', 'css/combo.map.css')}
def get_default_position(self):
return settings.COMBO_MAP_DEFAULT_POSITION
def get_default_form_class(self):
fields = ('title', 'initial_state', 'initial_zoom', 'min_zoom',
'max_zoom', 'group_markers', 'marker_behaviour_onclick', 'layers')
widgets = {'layers': forms.widgets.CheckboxSelectMultiple}
return forms.models.modelform_factory(self.__class__, fields=fields,
widgets=widgets)
def get_geojson(self, request):
geojson = {'type': 'FeatureCollection', 'features': []}
layers = self.layers.all()
for layer in layers:
geojson['features'] += layer.get_geojson(request,
multiple_layers=bool(len(layers) > 1))
return geojson
@classmethod
def is_enabled(cls):
return MapLayer.objects.count() > 0
def get_cell_extra_context(self, context):
ctx = super(Map, self).get_cell_extra_context(context)
ctx['title'] = self.title
default_position = self.get_default_position()
ctx['init_lat'] = default_position['lat']
ctx['init_lng'] = default_position['lng']
ctx['initial_state'] = self.initial_state
ctx['initial_zoom'] = self.initial_zoom
ctx['min_zoom'] = self.min_zoom
ctx['max_zoom'] = self.max_zoom
ctx['geojson_url'] = reverse_lazy('mapcell-geojson', kwargs={'cell_id': self.pk})
ctx['tile_urltemplate'] = settings.COMBO_MAP_TILE_URLTEMPLATE
ctx['map_attribution'] = settings.COMBO_MAP_ATTRIBUTION
ctx['max_bounds'] = settings.COMBO_MAP_MAX_BOUNDS
ctx['group_markers'] = self.group_markers
ctx['marker_behaviour_onclick'] = self.marker_behaviour_onclick
return ctx
def duplicate_m2m(self, new_cell):
# set layers
new_cell.layers.set(self.layers.all())