450 lines
17 KiB
Python
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
|