combo/combo/apps/maps/models.py

450 lines
17 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.core import validators
from django.db import models
from django.utils import six
from django.utils.encoding import python_2_unicode_compatible
from django.utils.html import escape
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
KIND = [
('tiles', _('Tiles')),
('geojson', _('GeoJSON')),
]
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'))
kind = models.CharField(max_length=10, choices=KIND, default='geojson')
tiles_template_url = models.CharField(
_('Tiles URL'),
max_length=1024,
help_text=_('For example: %s') % settings.COMBO_MAP_TILE_URLTEMPLATE,
blank=True, null=True)
tiles_attribution = models.CharField(
_('Attribution'),
max_length=1024,
help_text=_('For example: %s') % escape(settings.COMBO_MAP_ATTRIBUTION),
blank=True, null=True)
tiles_default = models.BooleanField(_('Default tiles layer'), default=False)
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 get_default_tiles_layer(cls):
return cls.objects.filter(kind='tiles', tiles_default=True).first()
@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 = next(serializers.deserialize('json', json.dumps([json_layer]), ignorenonexistent=True))
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,
through='MapLayerOptions',
verbose_name=_('Layers'),
blank=True
)
template_name = 'maps/map_cell.html'
manager_form_template = 'maps/map_cell_form.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',
'xstatic/leaflet-gesture-handling.min.js',
)
css = {'all': ('xstatic/leaflet.css', 'css/combo.map.css', 'xstatic/leaflet-gesture-handling.min.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')
return forms.models.modelform_factory(self.__class__, fields=fields)
def get_geojson(self, request):
geojson = {'type': 'FeatureCollection', 'features': []}
layers = self.layers.filter(kind='geojson')
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.exists()
def get_tiles_layers(self):
tiles_layers = []
options_qs = (
self.maplayeroptions_set
.filter(map_layer__kind='tiles')
.select_related('map_layer')
.order_by('-opacity'))
for options in options_qs:
tiles_layers.append({
'tile_urltemplate': options.map_layer.tiles_template_url,
'map_attribution': options.map_layer.tiles_attribution,
'opacity': options.opacity or 0,
})
# check if at least one layer with opacity set to 1 exists
if any([l['opacity'] == 1 for l in tiles_layers]):
return tiles_layers
# add the default tiles layer
default_tiles_layer = MapLayer.get_default_tiles_layer()
if default_tiles_layer is not None:
tiles_layers.insert(0, {
'tile_urltemplate': default_tiles_layer.tiles_template_url,
'map_attribution': default_tiles_layer.tiles_attribution,
'opacity': 1,
})
else:
tiles_layers.insert(0, {
'tile_urltemplate': settings.COMBO_MAP_TILE_URLTEMPLATE,
'map_attribution': settings.COMBO_MAP_ATTRIBUTION,
'opacity': 1,
})
return tiles_layers
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['tiles_layers'] = self.get_tiles_layers()
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 get_maplayer_options(self):
return self.maplayeroptions_set.order_by('map_layer__kind', 'map_layer__label')
def get_free_geojson_layers(self):
used_layers = self.maplayeroptions_set.values('map_layer')
return MapLayer.objects.filter(kind='geojson').exclude(pk__in=used_layers)
def get_free_tiles_layers(self):
used_layers = self.maplayeroptions_set.values('map_layer')
return MapLayer.objects.filter(kind='tiles').exclude(pk__in=used_layers)
def export_subobjects(self):
return {'layers': [x.get_as_serialized_object() for x in MapLayerOptions.objects.filter(map_cell=self)]}
@classmethod
def prepare_serialized_data(cls, cell_data):
# ensure compatibility with old exports
if 'layers' in cell_data['fields']:
layers = cell_data['fields'].pop('layers')
cell_data['layers'] = [
{
'fields': {'map_layer': layer},
'model': 'maps.maplayeroptions'
} for layer in layers
]
return cell_data
def import_subobjects(self, cell_json):
if 'layers' not in cell_json:
return
for layer in cell_json['layers']:
layer['fields']['map_cell'] = self.pk
for layer in serializers.deserialize('json', json.dumps(cell_json['layers'])):
layer.save()
def duplicate_m2m(self, new_cell):
# set layers
for layer in self.layers.all():
MapLayerOptions.objects.create(map_cell=new_cell, map_layer=layer)
class MapLayerOptions(models.Model):
map_cell = models.ForeignKey(Map, on_delete=models.CASCADE, db_column='map_id')
map_layer = models.ForeignKey(MapLayer, verbose_name=_('Layer'), on_delete=models.CASCADE, db_column='maplayer_id')
opacity = models.FloatField(
verbose_name=_('Opacity'),
validators=[
validators.MinValueValidator(0),
validators.MaxValueValidator(1)],
null=True,
help_text=_('Float value between 0 and 1'),
)
class Meta:
db_table = 'maps_map_layers'
unique_together = ('map_cell', 'map_layer')
def get_as_serialized_object(self):
serialized_options = json.loads(
serializers.serialize('json', [self], use_natural_foreign_keys=True, use_natural_primary_keys=True)
)[0]
del serialized_options['fields']['map_cell']
del serialized_options['pk']
return serialized_options