diff options
Diffstat (limited to 'chimere/models.py')
| -rw-r--r-- | chimere/models.py | 2118 |
1 files changed, 2118 insertions, 0 deletions
diff --git a/chimere/models.py b/chimere/models.py new file mode 100644 index 0000000..a1e00f9 --- /dev/null +++ b/chimere/models.py @@ -0,0 +1,2118 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +# Copyright (C) 2008-2016 Étienne Loks <etienne.loks_AT_peacefrogsDOTnet> + +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU 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 General Public License for more details. + +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + +# See the file COPYING for details. + +""" +Models description +""" +import os +import datetime +import pyexiv2 +import re +import copy +import simplejson as json +from lxml import etree +from PIL import Image +from subprocess import Popen, PIPE +from BeautifulSoup import BeautifulSoup + +from django import forms +from django.conf import settings +from django.contrib.auth.models import User, Permission, ContentType, Group +from django.contrib.gis.db import models +from django.core.files import File +from django.core.exceptions import ObjectDoesNotExist, MultipleObjectsReturned +from django.core.urlresolvers import reverse +from django.db.models import Q, Count +from django.db.models.signals import post_save, pre_save, m2m_changed +from django.template import defaultfilters +from django.utils.translation import ugettext_lazy as _ + +from chimere.widgets import HiddenPointChooserWidget, PointField, RouteField, \ + SelectMultipleField, TextareaWidget, DatePickerWidget, PolygonField, \ + JQueryAutoComplete +from chimere.utils import KMLManager, OSMManager, ShapefileManager, \ + GeoRSSManager, CSVManager, HtmlXsltManager, XMLXsltManager, JsonManager, \ + IcalManager + + +class Page(models.Model): + """Simple extra pages + """ + title = models.CharField(_(u"Name"), max_length=150) + mnemonic = models.CharField(_(u"Mnemonic"), max_length=10, blank=True, + null=True) + available = models.BooleanField(_(u"Available"), default=True) + order = models.IntegerField(_(u"Order"), default=10, blank=True, null=True) + template_path = models.CharField(_(u"Template path"), max_length=150, + blank=True, null=True) + content = models.TextField(blank=True, null=True) + + class Meta: + ordering = ["order"] + verbose_name = _(u"Page") + verbose_name_plural = _(u"Page") + + def __unicode__(self): + return self.title + + +def page_post_save(sender, **kwargs): + if not kwargs['instance']: + return + page = kwargs['instance'] + if not page.mnemonic: + page.mnemonic = defaultfilters.slugify(page.title) + page.save() +post_save.connect(page_post_save, sender=Page) + + +def shortify(text): + if not text: + return '' + if len(text) <= settings.CHIMERE_SHORT_DESC_LENGTH: + return text + desc = text[:settings.CHIMERE_SHORT_DESC_LENGTH] + short_desc = "" + # find a correct opportunity to cut + for idx, c in enumerate(reversed(desc)): + if c == '>': + break + if c == '<': + short_desc = desc[:-(idx + 1)] + break + if not short_desc: + for idx, c in enumerate(reversed(desc)): + if c == ' ' or c == '\n': + short_desc = desc[:-(idx + 1)] + break + return BeautifulSoup(short_desc).prettify() + + +class News(models.Model): + """News of the site + """ + title = models.CharField(_(u"Name"), max_length=150) + available = models.BooleanField(_(u"Available")) + is_front_page = models.NullBooleanField(_(u"Is front page"), blank=True, + null=True) + date = models.DateField(_(u"Date")) + content = models.TextField() + url = models.URLField(_(u"Url"), max_length=200, blank=True, null=True) + areas = SelectMultipleField('Area', verbose_name=_(u"Associated areas"), + blank=True, null=True) + + class Meta: + ordering = ["-date"] + verbose_name = _(u"News") + verbose_name_plural = _(u"News") + + def __unicode__(self): + return self.title + + @property + def short_desc(self): + return shortify(self.content) + + +class TinyUrl(models.Model): + """Tinyfied version of permalink parameters + """ + parameters = models.CharField(_(u"Parameters"), max_length=500, + unique=True) + digits = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ" + base = len(digits) + + class Meta: + verbose_name = _(u"TinyUrl") + + def __unicode__(self): + return self.parameters + + @classmethod + def getParametersByUrn(cls, urn): + c_id = 0 + for idx, char in enumerate(reversed(urn)): + c_id += cls.digits.index(char) * pow(cls.base, idx) + try: + params = cls.objects.get(id=c_id).parameters + except cls.DoesNotExist: + return '' + return params + + @classmethod + def getUrnByParameters(cls, parameters): + try: + obj = cls.objects.get(parameters=parameters) + except cls.DoesNotExist: + obj = cls(parameters=parameters) + obj.save() + n = obj.id + urn = '' + while 1: + idx = n % cls.base + urn = cls.digits[idx] + urn + n = n / cls.base + if n == 0: + break + return urn + + +class ColorTheme(models.Model): + """Color theme + """ + name = models.CharField(_(u"Name"), max_length=150) + + class Meta: + verbose_name = _(u"Color theme") + + def __unicode__(self): + return self.name + + +class Color(models.Model): + """Color + """ + code = models.CharField(_(u"Code/name"), max_length=200, + help_text=_(u"HTML code/name")) + inner_code = models.CharField(_(u"Code/name (inner)"), max_length=200, + help_text=_(u"HTML code/name"), + blank=True, null=True) + order = models.IntegerField(_(u"Order")) + color_theme = models.ForeignKey(ColorTheme, verbose_name=_(u"Color theme")) + + class Meta: + ordering = ["order"] + verbose_name = _(u"Color") + + def __unicode__(self): + return self.code + + @property + def color(self): + return self.code + + @property + def inner_color(self): + return self.inner_code + + +class Category(models.Model): + """Category of Point Of Interest (POI) + """ + name = models.CharField(_(u"Name"), max_length=150) + available = models.BooleanField(_(u"Available")) + order = models.IntegerField(_(u"Order")) + description = models.TextField(blank=True, null=True) + + class Meta: + ordering = ["order"] + verbose_name = _(u"Category") + + def __unicode__(self): + return self.name + + +class Icon(models.Model): + '''Icon + ''' + name = models.CharField(_(u"Name"), max_length=150) + image = models.ImageField(_(u"Image"), upload_to='icons', + height_field='height', width_field='width') + height = models.IntegerField(_(u"Height")) + width = models.IntegerField(_(u"Width")) + offset_x = models.IntegerField( + _(u"Offset x"), default=10, + help_text=_(u"Common value is half the icon width")) + offset_y = models.IntegerField( + _(u"Offset y"), default=20, + help_text=_(u"Common value is icon height")) + popup_offset_x = models.IntegerField(_(u"Popup offset x"), default=0, + help_text=_(u"Common value is 0")) + popup_offset_y = models.IntegerField( + _(u"Popup offset y"), default=20, + help_text=_(u"Common value is icon height")) + + def __unicode__(self): + return self.name + + class Meta: + verbose_name = _(u"Icon") + + +class SubCategory(models.Model): + '''Sub-category + ''' + category = models.ForeignKey(Category, verbose_name=_(u"Category"), + related_name='subcategories') + name = models.CharField(_(u"Name"), max_length=150) + available = models.BooleanField(_(u"Available"), default=True) + submission = models.BooleanField(_(u"Available for submission"), + default=True) + TYPE = (('M', _(u'Marker')), + ('R', _(u'Route')), + ('P', _(u'Polygon')), + ('B', _(u'Both')),) + item_type = models.CharField(_(u"Item type"), max_length=1, choices=TYPE) + dated = models.BooleanField(_(u"Is dated"), default=False) + description = models.TextField(blank=True, null=True) + icon = models.ForeignKey(Icon, verbose_name=_(u"Icon")) + hover_icon = models.ForeignKey( + Icon, verbose_name=_(u"Hover icon"), blank=True, null=True, + related_name='subcat_hovered') + color_theme = models.ForeignKey(ColorTheme, verbose_name=_(u"Color theme"), + blank=True, null=True, + related_name='subcategories') + as_layer = models.BooleanField(_(u"Displayed in the layer menu"), + default=False) + weight_formula = models.TextField(_(u"Weight formula"), default="", + blank=True, null=True) + routing_warn = models.BooleanField(_(u"Routing warn"), default=False) + order = models.IntegerField(_(u"Order"), default=1000) + keywords = models.TextField(_(u"Keywords"), max_length=200, + blank=True, null=True) + min_zoom = models.IntegerField( + _(u"Minimum zoom for loading details"), blank=True, null=True, + help_text=_(u"Optimization when too many data have to be displayed. " + u"Currently available only for route and polygon.")) + simplify_tolerance = models.FloatField( + _(u"Simplify tolerance for lower zoom"), blank=True, null=True, + help_text=_(u"Only relevant when Minimum zoom is set. Use the " + u"Douglas-Peucker algorythm to simplify the geometry when " + u"details is not alvailable. Adjust to your data volume " + u"and your performance need. 0.0003 is a good starting " + u"point. Note: typology is not preserved.")) + + class Meta: + ordering = ["category", "order"] + verbose_name = _(u"Sub-category") + verbose_name_plural = _(u"Sub-categories") + + def __unicode__(self): + return u"%s / %s" % (self.category.name, self.name) + + @classmethod + def getAvailable(cls, item_types=None, area_name=None, public=False, + instance=False): + '''Get list of tuples with first the category and second the associated + subcategories + ''' + sub_categories = {} + subcategories = cls.objects.filter(category__available=True) + if not item_types: + subcategories = subcategories.filter(available=True) + else: + subcategories = subcategories.filter(item_type__in=item_types) + if public: + subcategories = subcategories.filter(submission=True) + selected_cats = [] + if area_name: + area = Area.objects.get(urn=area_name) + # if there some restrictions with categories limit them + if area.subcategories.count(): + sub_ids = [sub.id for sub in area.subcategories.all()] + subcategories = subcategories.filter(id__in=sub_ids) + selected_cats = [subcat.pk + for subcat in area.default_subcategories.all()] + + if instance: + return subcategories.order_by('order') + + for sub_category in subcategories.order_by('order'): + if sub_category.category not in sub_categories: + sub_categories[sub_category.category] = [] + if sub_category.id in selected_cats: + sub_category.selected = True + sub_category.category.selected = True + sub_categories[sub_category.category].append(sub_category) + subcategories = [(cat, subcats) + for cat, subcats in sub_categories.items()] + get_cat_order = lambda cat_tuple: cat_tuple[0].order + subcategories = sorted(subcategories, key=get_cat_order) + return subcategories + + @classmethod + def getAvailableTuples(cls, item_types=None, area_name=None): + cats = [] + for cat, subcats in cls.getAvailable(item_types=item_types, + area_name=area_name): + cats.append((unicode(cat), + [(subcat.pk, subcat.name) for subcat in subcats])) + return cats + + def getJSONDict(self): + items = {'id': self.pk, 'name': self.name, + 'description': self.description if self.description + else '', + 'icon': {'url': self.icon.image.url, + 'width': self.icon.image.width, + 'height': self.icon.image.height, + 'offset_x': self.icon.offset_x, + 'offset_y': self.icon.offset_y, + 'popup_offset_x': self.icon.popup_offset_x, + 'popup_offset_y': self.icon.popup_offset_y} + } + + if self.hover_icon: + items['icon_hover'] = {'url': self.hover_icon.image.url} + return items + + def getJSON(self, categories_id=[]): + '''Return a JSON string - mainly used to get description + ''' + json_string = json.dumps(self.getJSONDict()) + return json_string + + @property + def slug(self): + return defaultfilters.slugify(self.name) + + @property + def item_nb(self): + return Marker.objects.filter(categories=self).count() + + +class SubCategoryUserLimit(models.Model): + """ + Moderation limit for user by category + """ + subcategory = models.ForeignKey( + SubCategory, related_name='limited_for_user') + user = models.ForeignKey(User, related_name='subcategory_limit_to') + + def __unicode__(self): + return u"{} / {}".format(self.user, self.subcategory) + + class Meta: + verbose_name = _(u"Sub-category limit for user") + verbose_name_plural = _(u"Sub-category limits for users") + + +STATUS = (('S', _(u'Submited')), + ('A', _(u'Available')), + ('M', _(u'Modified')), + ('D', _(u'Disabled')), + ('I', _(u'Imported'))) +STATUS_DCT = dict(STATUS) + +IMPORTERS = {'KML': KMLManager, + 'OSM': OSMManager, + 'SHP': ShapefileManager, + 'RSS': GeoRSSManager, + 'CSV': CSVManager, + 'JSON': JsonManager, + 'ICAL': IcalManager, + 'XSLT': HtmlXsltManager, + 'XXLT': XMLXsltManager + } + +IMPORTER_CHOICES = (('KML', 'KML'), + ('OSM', 'OSM'), + ('SHP', 'Shapefile'), + ('RSS', 'GeoRSS'), + ('CSV', 'CSV'), + ('JSON', 'JSON'), + ('ICAL', 'ICAL'), + ('XSLT', 'HTML-XSLT'), + ('XXLT', 'XML-XSLT'), + ) + +IMPORTER_CHOICES_DICT = dict(IMPORTER_CHOICES) + + +class Importer(models.Model): + ''' + Data importer for a specific subcategory + ''' + importer_type = models.CharField(_(u"Importer type"), max_length=4, + choices=IMPORTER_CHOICES) + filtr = models.TextField(_(u"Filter"), blank=True, null=True) + source = models.CharField(_(u"Web address"), max_length=200, + blank=True, null=True, + help_text=_(u"Don't forget the trailing slash")) + source_file = models.FileField( + _(u"Source file"), upload_to='import_files', blank=True, null=True) + source_file_alt = models.FileField( + _(u"Alt source file"), upload_to='import_files', blank=True, null=True) + default_name = models.CharField(_(u"Name by default"), max_length=200, + blank=True, null=True) + srid = models.IntegerField(_(u"SRID"), blank=True, null=True) + zipped = models.BooleanField(_(u"Zipped file"), default=False) + overwrite = models.BooleanField(_(u"Overwrite existing data"), + default=False) + get_description = models.BooleanField(_(u"Get description from source"), + default=False) + default_description = models.TextField(_(u"Default description"), + blank=True, null=True) + origin = models.CharField(_(u"Origin"), max_length=1000, + blank=True, null=True) + license = models.CharField(_(u"License"), max_length=1000, + blank=True, null=True) + categories = SelectMultipleField( + SubCategory, blank=True, null=True, + verbose_name=_(u"Associated subcategories")) + state = models.TextField(_(u"State"), blank=True, null=True) + associate_marker_to_way = models.BooleanField( + _(u"Automatically associate a marker to a way"), default=False) + automatic_update = models.BooleanField(_(u"Automatically updated"), + default=False) + default_status = models.CharField(_(u"Default status"), max_length=1, + choices=STATUS, default='I') + default_localisation = PointField( + _(u"Default localisation"), + srid=settings.CHIMERE_EPSG_DISPLAY_PROJECTION, blank=True, null=True, + widget=HiddenPointChooserWidget) + + class Meta: + verbose_name = _(u"Importer") + + def __unicode__(self): + vals = [IMPORTER_CHOICES_DICT[self.importer_type], + self.source, self.source_file.name, + u", ".join([unicode(cat) for cat in self.categories.all()]), + self.default_name] + return u' %d: %s' % (self.pk, u" - ".join([unicode(v) + for v in vals if v])) + + @property + def manager(self): + return IMPORTERS[self.importer_type](self) + + def display_categories(self): + return u"\n".join([cat.name for cat in self.categories.all()]) + + def get_key_category_dict(self): + dct = {} + # if no category provided: all category are considered + q = SubCategory.objects.all() + if self.categories.count(): + q = self.categories.all() + for cat in q.all(): + dct[defaultfilters.slugify(cat.name)] = cat + + for key_cat in self.key_categories.all(): + dct[key_cat.key] = key_cat.category + return dct + + +class ImporterKeyCategories(models.Model): + """ + Association between key and categories + """ + importer = models.ForeignKey(Importer, verbose_name=_(u"Importer"), + related_name='key_categories') + category = models.ForeignKey(SubCategory, verbose_name=_(u"Category")) + key = models.CharField(_(u"Import key"), max_length=200) + + class Meta: + verbose_name = _(u"Importer - Key categories") + + +class GeographicItem(models.Model): + categories = SelectMultipleField(SubCategory) + name = models.TextField(_(u"Name")) + submiter_session_key = models.CharField( + _(u"Submitter session key"), blank=True, null=True, max_length=40) + submiter_name = models.CharField(_(u"Submitter name or nickname"), + blank=True, null=True, max_length=40) + submiter_email = models.EmailField(_(u"Submitter email"), blank=True, + null=True) + submiter_comment = models.TextField(_(u"Submitter comment"), + max_length=200, blank=True, null=True) + status = models.CharField(_(u"Status"), max_length=1, choices=STATUS) + keywords = models.TextField(_(u"Keywords"), max_length=200, blank=True, + null=True) + import_key = models.CharField(_(u"Import key"), max_length=200, + blank=True, null=True) + import_version = models.IntegerField(_(u"Import version"), + blank=True, null=True) + import_source = models.CharField(_(u"Source"), max_length=200, + blank=True, null=True) + modified_since_import = models.BooleanField( + _(u"Modified since last import"), default=True) + not_for_osm = models.BooleanField(_(u"Not to be exported to OSM"), + default=False) + origin = models.CharField(_(u"Origin"), max_length=1000, + blank=True, null=True) + license = models.CharField(_(u"License"), max_length=1000, + blank=True, null=True) + start_date = models.DateField( + _(u"Start date"), blank=True, null=True, + help_text=_(u"Not mandatory. Set it for dated item such as event. " + u"Format YYYY-MM-DD")) + end_date = models.DateField( + _(u"End date"), blank=True, null=True, + help_text=_(u"Not mandatory. Set it only if you have a multi-day " + u"event. Format YYYY-MM-DD")) + weight = models.FloatField( + _(u"Weight"), blank=True, null=True, + help_text=_( + u"Weight are used for heatmap and clustering. A formula must " + u"defined in the associated category.")) + normalised_weight = models.FloatField( + _(u"Normalised weight"), blank=True, null=True, + help_text=_(u"The weight normalised to be between 0 and 1. " + u"Automatically recalculated.")) + + class Meta: + abstract = True + + def __unicode__(self): + return self.name + + def __init__(self, *args, **kwargs): + super(GeographicItem, self).__init__(*args, **kwargs) + # add read attributes for properties + for pm in self.all_properties(): + attr_name = pm.getAttrName() + if not hasattr(self, attr_name): + val = '' + property = self.getProperty(pm) + if property: + val = property.python_value + setattr(self, attr_name, val) + if not hasattr(self, attr_name + '_set'): + setattr(self, attr_name + '_set', + property_setter(self.__class__, pm)) + + def get_geometry(self): + return getattr(self, self.geom_attr) + + @property + def geometry(self): + return getattr(self, self.geom_attr).wkt + + def _get_geom_item_fk_name(self): + geom_attr = self.geom_attr + if self.geom_attr == 'route': + # # TODO v3 - backport routes + geom_attr = 'point' + return GEOM_TO_GEOM_ITEM[geom_attr].lower() + + def getProperty(self, propertymodel, safe=None): + """Get the property of an associated property model. + If safe set to True, verify if the property is available + """ + if safe and not propertymodel.available: + return + geom_item = self._get_geom_item_fk_name() + try: + d = {'propertymodel': propertymodel, + geom_item: self} + property = Property.objects.get(**d) + except Property.DoesNotExist: + return + return property + + def getProperties(self, area_name=None): + """Get all the property availables + """ + properties = [] + querys = PropertyModel.getAvailable(area_name=area_name) + for query in querys: + for pm in query.all(): + property = self.getProperty(pm) + if property: + properties.append(property) + return properties + + def setProperty(self, pm, value): + u""" + Set a property + """ + if not hasattr(pm, 'pk'): + pm = PropertyModel.objects.get(slug=pm) + geom_item = self._get_geom_item_fk_name() + d = {'propertymodel': pm, geom_item: self} + q = Property.objects.filter(**d) + properties = q.all() + # in case of multiple edition as the same time delete arbitrary + # the others + if len(properties) > 1: + for property in properties[1:]: + property.delete() + if pm.type == 'C' and value: + try: + value = str(int(value)) + except ValueError: + choice = PropertyModelChoice.objects.filter(propertymodel=pm, + value=value) + if choice.count(): + value = choice.all()[0].pk + else: + choice = PropertyModelChoice.objects.create( + value=value, propertymodel=pm) + value = choice.pk + # new property + if not properties: + d = {'propertymodel': pm, geom_item: self, 'value': value} + new_property = Property.objects.create(**d) + new_property.save() + else: + property = properties[0] + property.value = value + property.save() + + def saveProperties(self, values): + """ + Save properties + """ + for propertymodel in PropertyModel.objects.filter(available=True): + val = u"" + if unicode(propertymodel.id) in values: + val = values[unicode(propertymodel.id)] + self.setProperty(propertymodel, val) + + def get_key(self, key): + key_vals = self.import_key.split(';') + for k_v in key_vals: + if k_v.startswith(key + ':'): + return k_v.split(':')[1] + + def set_key(self, key, value): + new_keys, _set = '', None + key_vals = self.import_key.split(';') if self.import_key else [] + for k_v in key_vals: + if ':' not in k_v: + continue + k, v = k_v.split(':') + if k == key: + _set = True + new_keys += '%s:%s;' % (k, value) + else: + new_keys += '%s:%s;' % (k, v) + if not _set: + new_keys += '%s:%s;' % (key, value) + self.import_key = new_keys + modified_since_import = self.modified_since_import + self.save() + # preserve modified_since_import + if modified_since_import != self.modified_since_import: + self.modified_since_import = modified_since_import + self.save() + + def has_modified(self): + if (self.ref_item and self.ref_item != self) \ + or self.__class__.objects.filter( + ref_item=self).exclude(pk=self.pk).count(): + return True + return False + + @classmethod + def properties(cls): + return [pm for pm in PropertyModel.objects.filter(available=True)] + + @classmethod + def all_properties(cls): + return [pm for pm in PropertyModel.objects.all()] + + def get_init_multi(self): + multis = [forms.model_to_dict(multi) + for multi in self.multimedia_files.all()] + return multis + + def get_init_picture(self): + picts = [forms.model_to_dict(pict) + for pict in self.pictures.all()] + return picts + + def get_full_dict(self): + dct = {} + # get all property even the one not displayed + for pm in PropertyModel.objects.all(): + dct[pm.slug] = unicode(self.getProperty(pm)) + return dct + + def calculate_weight(self, formula): + try: + # try to eval the formula + # safe because open to admin only + return round(eval(formula.format(**self.get_full_dict())), 10) + except: + return 0 + + def get_weight_formula(self, get_associated_cat=False): + for sub in self.categories.order_by('order').all(): + if sub.weight_formula: + if get_associated_cat: + return sub.weight_formula, sub + return sub.weight_formula + if get_associated_cat: + return None, None + return None + + def normalise_weight(self): + formula, cat = self.get_weight_formula(get_associated_cat=True) + if not formula: + return + q = self.__class__.objects.filter( + categories__pk=cat.pk, weight__isnull=False) + if not q.count(): + return 0 + min_weight = q.order_by('weight')[0].weight or 0 + max_weight = q.order_by('-weight')[0].weight or 0 + return 1 - round( + (max_weight - self.weight or 0) / + (float((max_weight - min_weight)) or 1), 5) + + +def weighted_post_save(sender, **kwargs): + if not kwargs['instance']: + return + obj = kwargs['instance'] + formula = obj.get_weight_formula() + weight, normalised_weight = None, None + if formula: + weight = obj.calculate_weight(formula) + if weight != obj.weight: + obj.weight = weight + obj.save() + return + normalised_weight = obj.normalise_weight() + if weight != obj.weight or normalised_weight != obj.normalised_weight: + obj.weight = weight + obj.normalised_weight = normalised_weight + obj.save() + + +def property_setter(cls, propertymodel): + def setter(self, value): + item = self + if cls == Route: + # TODO v3 + if not self.associated_marker.objects.count(): + return + item = self.associated_marker.objects.all()[0] + item.setProperty(propertymodel, value) + return setter + +GEOM_TO_GEOM_ITEM = { + "point": "Marker", + "route": "Route", + "polygon": "Polygon" +} + + +class Marker(GeographicItem): + '''Marker for a POI + ''' + ref_item = models.ForeignKey( + "Marker", blank=True, null=True, verbose_name=_(u"Reference marker"), + related_name='submited_marker') + point = PointField(_(u"Localisation"), + srid=settings.CHIMERE_EPSG_DISPLAY_PROJECTION) + available_date = models.DateTimeField(_(u"Available Date"), blank=True, + null=True) # used by feeds + route = models.ForeignKey(u"Route", blank=True, null=True, + related_name='associated_marker') + description = models.TextField(_(u"Description"), blank=True, null=True) + is_front_page = models.NullBooleanField(_(u"Is front page"), blank=True, + null=True) + objects = models.GeoManager() + + class Meta: + ordering = ('status', 'name') + verbose_name = _(u"Point of interest") + + @property + def multimedia_items(self): + pict = list(self.pictures.filter(miniature=False).all()) + mm = list(self.multimedia_files.filter(miniature=False).all()) + items = [(item.order, item) for item in pict + mm] + return [item for order, item in sorted(items)] + + @property + def default_pictures(self): + return list(self.pictures.filter(miniature=True).order_by('order')) + + @property + def default_multimedia_items(self): + return list(self.multimedia_files.filter(miniature=True + ).order_by('order')) + + @property + def date(self): + if settings.CHIMERE_DAYS_BEFORE_EVENT and self.start_date: + today = datetime.date.today() + if self.end_date and self.start_date < today: + return self.end_date + return self.start_date + + @property + def short_desc(self): + return shortify(self.description) + + @property + def geom_attr(self): + return 'point' + + def getLatitude(self): + '''Return the latitude + ''' + return self.point.y + + def getLongitude(self): + '''Return the longitude + ''' + return self.point.x + + def getGeoJSON(self, categories_id=[]): + '''Return a GeoJSON string + ''' + jsons = [] + json_tpl = {"type": "Feature", "properties": {}} + for cat in self.categories.all(): + if categories_id and cat.id not in categories_id: + continue + items = copy.deepcopy(json_tpl) + try: + items['geometry'] = json.loads(self.point.geojson) + except json.JSONDecodeError: + continue + items['properties'].update({ + 'pk': self.id, + 'key': "marker-{}".format(self.id), + 'name': self.name, + 'icon_path': unicode(cat.icon.image), + 'icon_hover_path': unicode(cat.hover_icon.image) + if cat.hover_icon else '', + 'icon_offset_x': cat.icon.offset_x, + 'icon_offset_y': cat.icon.offset_y, + 'icon_popup_offset_x': cat.icon.popup_offset_x, + 'icon_popup_offset_y': cat.icon.popup_offset_y, + 'category_name': cat.name}) + try: + items['properties'].update( + {'icon_width': cat.icon.image.width, + 'icon_height': cat.icon.image.height, + }) + except IOError: + pass + + jsons.append(items) + + return json.dumps(jsons) + + @property + def default_category(self): + # Should we select only available ones ? + # Should we catch if not exists ? + cats = self.categories.filter(available=True, category__available=True) + if cats.count(): + return cats.all()[0] + + def get_absolute_url(self, area_name=''): + parameters = 'current_feature=%d' % self.id + if self.default_category: + parameters += '&checked_categories=%s' % self.default_category.pk + urn = TinyUrl.getUrnByParameters(parameters) + area_name = area_name + '/' if area_name else '' + url = reverse('chimere:tiny', args=[area_name, urn]) + return url + +PRE_ATTRS = { + 'Marker': ('name', 'description', 'start_date', 'geometry', + 'import_version', 'modified_since_import'), + 'Route': ('name', 'geometry', 'import_version', 'modified_since_import'), + 'Area': ('urn', 'name'), +} + + +def geometry_pre_save(cls, pre_save_geom_values): + def geom_pre_save(sender, **kwargs): + if not kwargs['instance'] or not kwargs['instance'].pk: + return + instance = kwargs['instance'] + try: + instance = cls.objects.get(pk=instance.pk) + pre_save_geom_values[instance.pk] = dict( + [(attr, getattr(instance, attr)) + for attr in PRE_ATTRS[cls.__name__]]) + except ObjectDoesNotExist: + pass + return geom_pre_save + +pre_save_marker_values = {} + + +def marker_pre_save(sender, **kwargs): + if not kwargs['instance']: + return + geometry_pre_save(Marker, pre_save_marker_values)(sender, **kwargs) +pre_save.connect(marker_pre_save, sender=Marker) + + +def geometry_post_save(pre_save_geom_values): + def geom_post_save(sender, **kwargs): + if not kwargs['instance'] \ + or kwargs['instance'].pk not in pre_save_geom_values: + return + instance = kwargs['instance'] + pre = pre_save_geom_values[instance.pk] + # force the reinit of modified_since_import + if pre['modified_since_import'] != instance.modified_since_import: + return + if (instance.import_version != pre['import_version'] + and instance.modified_since_import): + instance.modified_since_import = False + instance.save() + return + if instance.modified_since_import: + return + if [key for key in pre if pre not in ( + 'import_version', 'modified_since_import') and + getattr(instance, key) != pre[key]]: + instance.modified_since_import = True + instance.save() + return geom_post_save + + +def marker_post_save(sender, **kwargs): + weighted_post_save(sender, **kwargs) + if not kwargs['instance'] or kwargs['created']: + return + geometry_post_save(pre_save_marker_values)(sender, **kwargs) +post_save.connect(marker_post_save, sender=Marker) + + +class Polygon(GeographicItem): + '''Polygon on the map + ''' + ref_item = models.ForeignKey( + "Polygon", blank=True, null=True, verbose_name=_(u"Reference polygon"), + related_name='submited_polygon') + polygon = PolygonField( + _(u"Polygon"), srid=settings.CHIMERE_EPSG_DISPLAY_PROJECTION) + picture = models.ImageField( + _(u"Image"), upload_to='upload', blank=True, null=True, + height_field='height', width_field='width') + height = models.IntegerField(_(u"Height"), blank=True, null=True) + width = models.IntegerField(_(u"Width"), blank=True, null=True) + color = models.CharField( + _(u"Color"), max_length=200, help_text=_(u"HTML code/name"), + blank=True, null=True) + inner_color = models.CharField( + _(u"Inner color"), max_length=200, + help_text=_(u"HTML code/name"), blank=True, null=True) + objects = models.GeoManager() + + class Meta: + ordering = ('status', 'name') + verbose_name = _(u"Polygon") + + @property + def geom_attr(self): + return 'polygon' + + def getGeoJSON(self, color="#000", inner_color='#0F0'): + '''Return a GeoJSON string + ''' + try: + geom = json.loads(self.polygon.geojson) + except json.JSONDecodeError: + return json.dumps('{}') + attributes = {"type": "Feature", + "geometry": geom, + "properties": {"pk": self.id, "name": self.name, + 'key': "polygon-{}".format(self.pk), + "color": self.color or color, + "inner_color": self.inner_color + or inner_color}} + return json.dumps(attributes) + + @classmethod + def getGeoJSONs(self, queryset, color="#000", + inner_color='rgba(180, 180, 180, 0.3)', + limit_to_categories=[]): + vals, default_color, default_inner_color = [], color, inner_color + q = queryset.select_related('categories').extra( + select={'json': 'ST_AsGeoJSON(polygon)'}).values( + 'json', 'name', 'pk', 'inner_color', 'color', 'categories__pk') + added = [] + current_categories = {} + for polygon in q.all(): + if polygon['pk'] in added: + continue + if limit_to_categories and \ + polygon["categories__pk"] not in limit_to_categories: + continue + color = default_color + if polygon["color"]: + color = polygon['color'] + elif polygon["categories__pk"]: + if polygon["categories__pk"] not in current_categories: + cat = SubCategory.objects.get(pk=polygon["categories__pk"]) + # [index, color list] + current_categories[polygon["categories__pk"]] = \ + [0, list(Color.objects.filter( + color_theme=cat.color_theme))] + idx, colors = current_categories[polygon["categories__pk"]] + # category have a color theme + if colors: + c = colors[idx % len(colors)] + color = c.code + if c.inner_code: + inner_color = c.inner_code + # index += 1 + current_categories[polygon["categories__pk"]][0] += 1 + if polygon["inner_color"]: + inner_color = polygon["inner_color"] + elif not inner_color: + inner_color = default_inner_color + vals.append({ + "type": "Feature", + "geometry": json.loads(polygon['json']), + "properties": {"pk": polygon['pk'], "name": polygon['name'], + 'key': "polygon-{}".format(polygon['pk']), + 'color': color, + 'inner_color': inner_color}}) + added.append(polygon['pk']) + return vals + + def get_full_dict(self): + dct = super(Polygon, self).get_full_dict() + # to be done - use local unity + dct['area'] = self.polygon.area + dct['length'] = self.polygon.length + return dct + +post_save.connect(weighted_post_save, sender=Polygon) + + +class AggregatedPolygon(models.Model): + ''' + Database view for aggregated polygons + ''' + polygon = models.MultiPolygonField() + subcategory = models.ForeignKey(SubCategory) + status = models.CharField(_(u"Status"), max_length=1, choices=STATUS) + + class Meta: + managed = False + db_table = 'chimere_aggregated_polygons' + + def getGeoJSON(self, color="", inner_color=''): + '''Return a GeoJSON string + ''' + # get colors + if not color or not inner_color: + q = Color.objects.filter( + color_theme__subcategories=self.subcategory) + if not q.count(): + if not color: + color = "rgba(0, 0, 255, 1)" + if not inner_color: + inner_color = 'rgba(255, 125, 0, 0.6)' + else: + # get the first + c = q.order_by('order').all()[0] + if not color: + color = c.color + if not inner_color: + inner_color = c.inner_color + + geom = self.polygon + if self.subcategory.simplify_tolerance: + geom = self.polygon.simplify( + self.subcategory.simplify_tolerance).json + else: + geom = geom.json + attributes = { + 'color': color, 'geometry': json.loads(geom), + 'type': "Feature", "properties": { + 'color': color, + 'inner_color': inner_color, + 'key': "aggpoly-{}".format(self.pk), + "pk": self.id, "name": u'Aggregated polygon'}} + return json.dumps(attributes) + + +class MultimediaType(models.Model): + MEDIA_TYPES = (('A', _(u"Audio")), + ('V', _(u"Video")), + ('I', _(u"Image")), + ('O', _(u"Other")),) + media_type = models.CharField(_(u"Media type"), max_length=1, + choices=MEDIA_TYPES) + name = models.CharField(_(u"Name"), max_length=150) + mime_type = models.CharField(_(u"Mime type"), max_length=50, blank=True, + null=True) + iframe = models.BooleanField(_(u"Inside an iframe"), default=False) + available = models.BooleanField(_(u"Available"), default=True) + + class Meta: + verbose_name = _(u"Multimedia type") + verbose_name_plural = _(u"Multimedia types") + + def __unicode__(self): + return self.name + + @classmethod + def get_tuples(cls): + vals = cls.objects.filter(available=True).order_by('media_type', + 'name') + tuples, c_tpe = [('', _("Automatic recognition"))], None + media_type_dct = dict(cls.MEDIA_TYPES) + for tpe, pk, name in [(media_type_dct[v.media_type], v.pk, v.name) + for v in vals]: + if not c_tpe or c_tpe != tpe: + c_tpe = tpe + tuples.append([tpe, []]) + tuples[-1][1].append((pk, name)) + return tuples + +IFRAME_LINKS = { + 'youtube': ((re.compile(r'youtube.com\/watch\?[A-Za-z0-9_\-\=\&]*v=' + r'([A-Za-z0-9_-]*)[A-Za-z0-9_\-\=\&]*'), + re.compile(r'youtu.be\/([A-Za-z0-9_-]*)'), + re.compile(r'youtube.com\/embed\/([A-Za-z0-9_-]*)')), + "http://www.youtube.com/embed/%s"), + 'dailymotion': ( + (re.compile(r'dailymotion.com/video/([A-Za-z0-9]*)_[A-Za-z0-9_-]*'), + re.compile(r'dailymotion.com/embed/video/([A-Za-z0-9]*)'), + re.compile("http://www.dailymotion.com/embed/video/%s")), + 'http://www.dailymotion.com/embed/video/%s'), + 'vimeo': ((re.compile(r'vimeo.com/video/([A-Za-z0-9]*)'), + re.compile(r'vimeo.com/([A-Za-z0-9]*)'),), + "http://player.vimeo.com/video/%s") +} + + +class MultimediaExtension(models.Model): + name = models.CharField(_(u"Extension name"), max_length=6) + multimedia_type = models.ForeignKey( + MultimediaType, verbose_name=_(u"Associated multimedia type"), + related_name='extensions') + + class Meta: + verbose_name = _(u"Multimedia extension") + verbose_name_plural = _(u"Multimedia extensions") + + def __unicode__(self): + return self.name + + +class MultimediaFile(models.Model): + name = models.CharField(_(u"Name"), max_length=150) + url = models.URLField(_(u"Url"), max_length=200) + order = models.IntegerField(_(u"Order"), default=1) + multimedia_type = models.ForeignKey(MultimediaType, blank=True, null=True) + miniature = models.BooleanField( + _(u"Display inside the description?"), + default=settings.CHIMERE_MINIATURE_BY_DEFAULT) + marker = models.ForeignKey(Marker, related_name='multimedia_files', + blank=True, null=True) + polygon = models.ForeignKey(Polygon, related_name='multimedia_files', + blank=True, null=True) + + class Meta: + verbose_name = _(u"Multimedia file") + verbose_name_plural = _(u"Multimedia files") + + def __unicode__(self): + return self.name or u"" + + +def multimediafile_post_save(sender, **kwargs): + if not kwargs['instance'] or not kwargs['created']: + return + multimediafile = kwargs['instance'] + # auto recognition of file types + if not multimediafile.multimedia_type: + url = multimediafile.url + for mm_type in IFRAME_LINKS: + res, embeded_url = IFRAME_LINKS[mm_type] + if [r for r in res if r.search(url)]: + multimedia_type = MultimediaType.objects.get( + name__iexact=mm_type) + multimediafile.multimedia_type = multimedia_type + if not multimediafile.multimedia_type: + ext = url.split(".")[-1] + q = MultimediaExtension.objects.filter(name__iendswith=ext) + if q.count(): + multimediafile.multimedia_type = q.all()[0].multimedia_type + else: + # default to an iframe + multimediafile.multimedia_type = \ + MultimediaType.objects.filter(name__iexact='iframe')\ + .all()[0] + # manage iframe of video providers + if multimediafile.multimedia_type.name.lower() in IFRAME_LINKS: + regexps, lnk = IFRAME_LINKS[ + multimediafile.multimedia_type.name.lower()] + key = None + for regexp in regexps: + key = regexp.findall(multimediafile.url) + if key: + key = key[0] + break + if key: + multimediafile.url = lnk % key + + mfs = MultimediaFile.objects.filter(marker=multimediafile.marker)\ + .exclude(pk=multimediafile.pk)\ + .order_by('order') + for idx, mf in enumerate(mfs.all()): + mf.order = idx + 1 + mf.save() + multimediafile.order = mfs.count() + 1 + multimediafile.save() +post_save.connect(multimediafile_post_save, sender=MultimediaFile) + + +class PictureFile(models.Model): + name = models.CharField(_(u"Name"), max_length=150) + picture = models.ImageField(_(u"Image"), upload_to='pictures', + height_field='height', width_field='width') + height = models.IntegerField(_(u"Height"), blank=True, null=True) + width = models.IntegerField(_(u"Width"), blank=True, null=True) + miniature = models.BooleanField( + _(u"Display inside the description?"), + default=settings.CHIMERE_MINIATURE_BY_DEFAULT) + thumbnailfile = models.ImageField( + _(u"Thumbnail"), upload_to='pictures', blank=True, null=True, + height_field='thumbnailfile_height', width_field='thumbnailfile_width') + thumbnailfile_height = models.IntegerField(_(u"Thumbnail height"), + blank=True, null=True) + thumbnailfile_width = models.IntegerField(_(u"Thumbnail width"), + blank=True, null=True) + order = models.IntegerField(_(u"Order"), default=1) + marker = models.ForeignKey(Marker, related_name='pictures', blank=True, + null=True) + polygon = models.ForeignKey(Polygon, related_name='pictures', blank=True, + null=True) + + def __unicode__(self): + return self.name or u"" + + class Meta: + verbose_name = _(u"Picture file") + verbose_name_plural = _(u"Picture files") + + +def scale_image(max_x, pair): + x, y = pair + new_y = (float(max_x) / x) * y + return (int(max_x), int(new_y)) + +IMAGE_EXIF_ORIENTATION_MAP = { + 1: 0, + 8: 2, + 3: 3, + 6: 4, +} + +PYEXIV2_OLD_API = not hasattr(pyexiv2, 'ImageMetadata') + + +def picturefile_post_save(sender, **kwargs): + if not kwargs['instance']: + return + picturefile = kwargs['instance'] + + if kwargs['created']: + filename = picturefile.picture.path + metadata, orientation = None, None + if PYEXIV2_OLD_API: + metadata = pyexiv2.Image(filename) + metadata.readMetadata() + orientation = metadata['Exif.Image.Orientation'] \ + if 'Exif.Image.Orientation' in metadata.exifKeys() else None + else: + metadata = pyexiv2.ImageMetadata(filename) + metadata.read() + orientation = metadata['Exif.Image.Orientation'].value \ + if 'Exif.Image.Orientation' in metadata else None + if orientation and orientation in IMAGE_EXIF_ORIENTATION_MAP \ + and orientation > 1: + metadata['Exif.Image.Orientation'] = 1 + if PYEXIV2_OLD_API: + metadata.writeMetadata() + else: + metadata.write() + im = Image.open(filename) + im = im.transpose(IMAGE_EXIF_ORIENTATION_MAP[orientation]) + im.save(filename) + + if not picturefile.thumbnailfile: + file = picturefile.picture + # defining the filename and the thumbnail filename + filehead, filetail = os.path.split(os.path.abspath(file.path)) + basename, format = os.path.splitext(filetail) + basename = defaultfilters.slugify(basename) + basename = re.sub(r'-', '_', basename) + miniature = basename + '_thumb.jpg' + filename = file.path + miniature_filename = os.path.join(filehead, miniature) + try: + image = Image.open(filename) + except: + image = None + if image: + image_x, image_y = image.size + if settings.CHIMERE_THUMBS_SCALE_HEIGHT: + image_y, image_x = scale_image( + settings.CHIMERE_THUMBS_SCALE_HEIGHT, (image_y, image_x)) + elif settings.CHIMERE_THUMBS_SCALE_WIDTH: + image_x, image_y = scale_image( + settings.CHIMERE_THUMBS_SCALE_WIDTH, (image_x, image_y)) + image.thumbnail([image_x, image_y], Image.ANTIALIAS) + + temp_image = open(miniature_filename, 'w') + if image.mode != "RGB": + image = image.convert('RGB') + try: + image.save(temp_image, 'JPEG', quality=90, optimize=1) + except: + image.save(temp_image, 'JPEG', quality=90) + + short_name = miniature_filename[len(settings.MEDIA_ROOT):] + picturefile.thumbnailfile = short_name + picturefile.save() + + if not kwargs['created']: + return + pfs = PictureFile.objects.filter(marker=picturefile.marker)\ + .exclude(pk=picturefile.pk).order_by('order') + for idx, pf in enumerate(pfs.all()): + pf.order = idx + 1 + pf.save() + picturefile.order = pfs.count() + 1 + picturefile.save() +post_save.connect(picturefile_post_save, sender=PictureFile) + + +class RouteFile(models.Model): + name = models.CharField(_(u"Name"), max_length=150) + raw_file = models.FileField(_(u"Raw file (gpx or kml)"), + upload_to='route_files') + simplified_file = models.FileField( + _(u"Simplified file"), upload_to='route_files', blank=True, null=True) + TYPE = (('K', _(u'KML')), ('G', _(u'GPX'))) + file_type = models.CharField(max_length=1, choices=TYPE) + + class Meta: + ordering = ('name',) + verbose_name = _(u"Route file") + verbose_name_plural = _(u"Route files") + + def __unicode__(self): + return self.name + + def process(self): + if self.simplified_file: + return + input_name = settings.MEDIA_ROOT + self.raw_file.name + output_name = settings.MEDIA_ROOT + self.raw_file.name[:-4] + \ + "_simplified" + ".gpx" + cli_args = [settings.GPSBABEL, '-i'] + if self.file_type == 'K': + cli_args.append('kml') + elif self.file_type == 'G': + cli_args.append('gpx') + cli_args += ['-f', input_name, '-x', settings.GPSBABEL_OPTIONS, + '-o', 'gpx', '-F', output_name] + p = Popen(cli_args, stderr=PIPE) + p.wait() + if p.returncode: + print p.stderr.read() + # logger.error(p.stderr.read()) + else: + self.simplified_file = File(open(output_name)) + self.save() + os.remove(output_name) + + @property + def route(self): + if not self.simplified_file: + return + + file_name = settings.MEDIA_ROOT + self.simplified_file.name + tree = etree.parse(file_name) + pts = [] + for pt in tree.getiterator(): + if not pt.tag.endswith('trkpt'): + continue + pts.append((pt.get("lon"), pt.get("lat"))) + wkt_tpl = u'LINESTRING(%s)' + return wkt_tpl % u','.join([u'%s %s' % (pt[0], pt[1]) + for pt in pts]) + + +class Route(GeographicItem): + '''Route on the map + ''' + ref_item = models.ForeignKey( + "Route", blank=True, null=True, verbose_name=_(u"Reference route"), + related_name='submited_route') + route = RouteField(_(u"Route"), + srid=settings.CHIMERE_EPSG_DISPLAY_PROJECTION) + associated_file = models.ForeignKey(RouteFile, blank=True, null=True, + verbose_name=_(u"Associated file")) + picture = models.ImageField( + _(u"Image"), upload_to='upload', blank=True, null=True, + height_field='height', width_field='width') + height = models.IntegerField(_(u"Height"), blank=True, null=True) + width = models.IntegerField(_(u"Width"), blank=True, null=True) + has_associated_marker = models.BooleanField(_(u"Has an associated marker"), + default=True) + color = models.CharField( + _(u"Color"), max_length=200, help_text=_(u"HTML code/name"), + blank=True, null=True) + objects = models.GeoManager() + + class Meta: + ordering = ('status', 'name') + verbose_name = _(u"Route") + + def __init__(self, *args, **kwargs): + super(Route, self).__init__(*args, **kwargs) + self.description = '' + try: + associated_marker = Marker.objects.get(route=self) + self.description = associated_marker.description + except: + associated_marker = None + # add read attributes for properties + for pm in self.properties(): + attr_name = pm.getAttrName() + if not hasattr(self, attr_name): + val = '' + if associated_marker: + property = associated_marker.getProperty(pm) + if property: + val = property.python_value + setattr(self, attr_name, val) + if not hasattr(self, attr_name + '_set'): + setattr(self, attr_name + '_set', + property_setter(self.__class__, pm)) + + @property + def geom_attr(self): + return 'route' + + def get_init_multi(self): + if not self.associated_marker.count(): + return [] + multis = [ + forms.model_to_dict(multi) + for multi in self.associated_marker.all()[0].multimedia_files.all() + ] + return multis + + def get_init_picture(self): + if not self.associated_marker.count(): + return [] + picts = [forms.model_to_dict(pict) + for pict in self.associated_marker.all()[0].pictures.all()] + return picts + + def getGeoJSON(self, color="#000"): + '''Return a GeoJSON string + ''' + try: + geom = json.loads(self.route.geojson) + except json.JSONDecodeError: + return json.dumps('{}') + attributes = {"type": "Feature", + "geometry": geom, + "properties": {"pk": self.id, "name": self.name, + 'key': "route-{}".format(self.pk), + "color": color}} + return json.dumps(attributes) + + def get_full_dict(self): + dct = super(Route, self).get_full_dict() + # to be done - use local unity + dct['length'] = self.route.length + return dct + +pre_save_route_values = {} + + +def route_pre_save(sender, **kwargs): + if not kwargs['instance']: + return + geometry_pre_save(Route, pre_save_route_values)(sender, **kwargs) +pre_save.connect(route_pre_save, sender=Route) + + +def route_post_save(sender, **kwargs): + if not kwargs['instance']: + return + geometry_post_save(pre_save_route_values)(sender, **kwargs) + weighted_post_save(sender, **kwargs) + instance = kwargs['instance'] + + # manage associated marker + if instance.has_associated_marker: + marker_fields = [f.attname for f in Marker._meta.fields] + route_fields = [f.attname for f in Route._meta.fields] + marker_dct = dict( + [(k, getattr(instance, k)) for k in marker_fields + if k in route_fields and k not in ('id', 'ref_item_id')]) + marker_dct['point'] = "SRID=%d;POINT(%f %f)" % ( + instance.route.srid, instance.route[0][0], instance.route[0][1]) + try: + marker, created = Marker.objects.get_or_create(route=instance, + defaults=marker_dct) + except MultipleObjectsReturned: + # db error - trying to continue... + marker = Marker.objects.filter(route=instance).all()[0] + created = False + if not created: + marker.status = instance.status + marker.point = marker_dct['point'] + marker.save() + properties = {} + for pm in instance.properties(): + prop = instance.getProperty(pm) + if prop: + properties[pm.pk] = prop.python_value + # fix mis-initialized markers + if created: + for cat in instance.categories.all(): + marker.categories.add(cat) + marker.saveProperties(properties) + +post_save.connect(route_post_save, sender=Route) + + +def sync_m2m_route(sender, **kwargs): + if kwargs['action'] not in ('post_add', 'post_clear', 'post_remove'): + return + route = kwargs['instance'] + marker = route.associated_marker + if not marker.count(): + return + marker = marker.all()[0] + marker.categories.clear() + if kwargs['action'] == 'post_clear': + return + for cat in route.categories.all(): + marker.categories.add(cat) +m2m_changed.connect(sync_m2m_route, sender=Route.categories.through) + + +def getDateCondition(): + ''' + Return an SQL condition for apparition of dates + ''' + if not settings.CHIMERE_DAYS_BEFORE_EVENT: + return "" + now = datetime.datetime.now().strftime('%Y-%m-%d') + after = (datetime.datetime.now() + + datetime.timedelta(settings.CHIMERE_DAYS_BEFORE_EVENT)).strftime( + '%Y-%m-%d') + date_condition = " and (%(alias)s.start_date is null or " + date_condition += "(%(alias)s.start_date >= '" + now + "' and " + date_condition += "%(alias)s.start_date <='" + after + "')" + date_condition += " or (%(alias)s.start_date <='" + now + "' and " + date_condition += "%(alias)s.end_date >='" + now + "')) " + return date_condition + + +class AggregatedRoute(models.Model): + ''' + Database view for aggregated routes + ''' + route = models.MultiLineStringField() + subcategory = models.ForeignKey(SubCategory) + status = models.CharField(_(u"Status"), max_length=1, choices=STATUS) + + class Meta: + managed = False + db_table = 'chimere_aggregated_routes' + + def getGeoJSON(self, color="#000"): + '''Return a GeoJSON string + ''' + if '#' not in color: + color = '#' + color + try: + geom = json.loads(self.route.geojson) + except json.JSONDecodeError: + return json.dumps('{}') + attributes = { + 'color': color, 'geometry': geom, + 'type': "Feature", "properties": { + 'key': "aggroute-{}".format(self.pk), + "pk": self.id, "name": u'Aggregated route'}} + return json.dumps(attributes) + + +class SimplePoint: + """ + Point in the map (not in the database) + """ + def __init__(self, x, y): + self.x, self.y = x, y + + +class SimpleArea: + """ + Rectangular area of a map (not in the database) + """ + def __init__(self, area): + """ + Defining upper left corner ans lower right corner from a tuple + """ + assert len(area) == 4 + x1, y1, x2, y2 = area + self.upper_left_corner = SimplePoint(x1, y1) + self.lower_right_corner = SimplePoint(x2, y2) + + def isIn(self, area): + """ + Verify if the current area is in the designated area + """ + if self.upper_left_corner.x >= area.upper_left_corner.x and \ + self.upper_left_corner.y <= area.upper_left_corner.x and \ + self.lower_right_corner.x <= area.lower_right_corner.x and \ + self.lower_right_corner.y >= area.lower_right_corner.y: + return True + return False + + def getCategories(self, status='A', filter_available=True, area_name=None): + """ + Get categories for this area + """ + wheres = [] + if area_name: + subcategory_pks = [] + for cat, subcats in SubCategory.getAvailable(area_name=area_name): + for subcat in subcats: + subcategory_pks.append(unicode(subcat.pk)) + if filter_available: + wheres += ['subcat.available = TRUE', 'cat.available = TRUE'] + wheres += ['subcat.id in (%s)' % ",".join(subcategory_pks)] + where = " where " + " and ".join(wheres) if wheres else "" + + equal_status = '' + if len(status) == 1: + equal_status = "='%s'" % status[0] + elif status: + equal_status = " in ('%s')" % "','".join(status) + area = u"ST_GeometryFromText('POLYGON((%f %f,%f %f,%f %f,%f %f, %f %f"\ + u"))', %d)" % ( + self.upper_left_corner.x, self.upper_left_corner.y, + self.lower_right_corner.x, self.upper_left_corner.y, + self.lower_right_corner.x, self.lower_right_corner.y, + self.upper_left_corner.x, self.lower_right_corner.y, + self.upper_left_corner.x, self.upper_left_corner.y, + settings.CHIMERE_EPSG_DISPLAY_PROJECTION) + date_condition = getDateCondition() + sql_main = '''select subcat.id as id, subcat.category_id as category_id, + subcat.name as name, subcat.available as available, + subcat.icon_id as icon_id, subcat.color_theme_id as color_theme_id, + subcat.order as order, subcat.item_type as item_type + from chimere_subcategory subcat + inner join chimere_category cat on cat.id=subcat.category_id''' + + sql = sql_main + ''' + inner join chimere_marker mark on ST_Contains(%s, mark.point)''' % area + if equal_status: + sql += ' and mark.status' + equal_status + sql += date_condition % {'alias': 'mark'} + sql += ''' + inner join chimere_marker_categories mc on mc.subcategory_id=subcat.id + and mc.marker_id=mark.id''' + sql += where + subcats = set(SubCategory.objects.raw(sql)) + + sql = sql_main + ''' + inner join chimere_route rt on (ST_Intersects(%s, rt.route) or + ST_Contains(%s, rt.route))''' % (area, area) + if equal_status: + sql += ' and rt.status' + equal_status + sql += date_condition % {'alias': 'rt'} + sql += ''' + inner join chimere_route_categories rc on rc.subcategory_id=subcat.id + and rc.route_id=rt.id''' + sql += where + # subcats.union(set(SubCategory.objects.raw(sql))) + # set union behave strangely. Doing it manualy... + for c in set(SubCategory.objects.raw(sql)): + subcats.add(c) + + sql = sql_main + ''' + inner join chimere_polygon pol on (ST_Intersects(%s, pol.polygon) or + ST_Contains(%s, pol.polygon))''' % (area, area) + if equal_status: + sql += ' and pol.status' + equal_status + sql += date_condition % {'alias': 'pol'} + sql += ''' + inner join chimere_polygon_categories pc on pc.subcategory_id=subcat.id + and pc.polygon_id=pol.id''' + sql += where + # subcats.union(set(SubCategory.objects.raw(sql))) + # set union behave strangely. Doing it manualy... + for c in set(SubCategory.objects.raw(sql)): + subcats.add(c) + + return subcats + + def getExtent(self): + return (unicode(self.upper_left_corner.x), + unicode(self.upper_left_corner.y), + unicode(self.lower_right_corner.x), + unicode(self.lower_right_corner.y)) + + +class Layer(models.Model): + name = models.CharField(_(u"Name"), max_length=150) + layer_code = models.TextField(_(u"Layer code")) + extra_js_code = models.TextField( + _(u"Extra JS code"), blank=True, null=True, default='', + help_text=_(u"This code is loaded before the layer code.")) + + def __unicode__(self): + return self.name + + class Meta: + verbose_name = _("Layer") + + +class Area(models.Model, SimpleArea): + """Rectangular area of the map + """ + name = models.CharField(_(u"Name"), max_length=150) + urn = models.SlugField(_(u"Area urn"), max_length=50, blank=True, + unique=True) + welcome_message = models.TextField(_(u"Welcome message"), blank=True, + null=True) + order = models.IntegerField(_(u"Order"), unique=True) + available = models.BooleanField(_(u"Available")) + upper_left_corner = models.PointField( + _(u"Upper left corner"), default='POINT(0 0)', + srid=settings.CHIMERE_EPSG_DISPLAY_PROJECTION) + lower_right_corner = models.PointField( + _(u"Lower right corner"), default='POINT(0 0)', + srid=settings.CHIMERE_EPSG_DISPLAY_PROJECTION) + default = models.NullBooleanField( + _(u"Default area"), help_text=_(u"Only one area is set by default")) + layers = SelectMultipleField(Layer, related_name='areas', + through='AreaLayers', blank=True) + default_subcategories = SelectMultipleField( + SubCategory, blank=True, + verbose_name=_(u"Sub-categories checked by default")) + dynamic_categories = models.NullBooleanField( + _(u"Sub-categories dynamicaly displayed"), + help_text=_(u"If checked, categories are only displayed in the menu " + u"if they are available on the current extent.")) + subcategories = SelectMultipleField( + SubCategory, related_name='areas', + blank=True, db_table='chimere_subcategory_areas', + verbose_name=_(u"Restricted to theses sub-categories"), + help_text=_(u"If no sub-category is set all sub-categories are " + u"available")) + display_category_menu = models.BooleanField( + _(u"Display category menu"), default=True, + help_text=_(u"If set to False, category menu will be hide and all " + u"categories will be always displayed.")) + external_css = models.URLField(_(u"Link to an external CSS"), blank=True, + null=True) + restrict_to_extent = models.BooleanField(_(u"Restrict to the area extent"), + default=False) + allow_point_edition = models.BooleanField(_(u"Allow point edition"), + default=True) + allow_route_edition = models.BooleanField(_(u"Allow route edition"), + default=True) + allow_polygon_edition = models.BooleanField(_(u"Allow polygon edition"), + default=True) + extra_map_def = models.TextField( + _(u"Extra map definition"), blank=True, null=True, + help_text=_(u"Extra javascript script loaded for this area. " + u"Carreful! To prevent breaking the map must be valid.")) + objects = models.GeoManager() + + def __unicode__(self): + return self.name + + class Meta: + ordering = ('order', 'name') + verbose_name = _("Area") + + @classmethod + def getAvailable(cls): + '''Get available areas + ''' + return cls.objects.filter(available=True) + + def getWkt(self): + return "SRID=%d;POLYGON((%f %f,%f %f,%f %f,%f %f, %f %f))" % ( + settings.CHIMERE_EPSG_DISPLAY_PROJECTION, + self.upper_left_corner.x, self.upper_left_corner.y, + self.lower_right_corner.x, self.upper_left_corner.y, + self.lower_right_corner.x, self.lower_right_corner.y, + self.upper_left_corner.x, self.lower_right_corner.y, + self.upper_left_corner.x, self.upper_left_corner.y, + ) + + def getIncludeMarker(self): + """ + Get the sql statement for the test if the point is included in the area + """ + return Q(point__contained=self.getWkt()) + + def getIncludeRoute(self): + """ + Get the sql statement for the test if the route is included in the area + """ + return Q(route__contained=self.getWkt()) + + def getIncludePolygon(self): + """ + Get the sql statement for the test if the route is included in the area + """ + return Q(polygon__contained=self.getWkt()) + +pre_save_area_values = {} + + +def area_pre_save(sender, **kwargs): + if not kwargs['instance']: + return + geometry_pre_save(Area, pre_save_area_values)(sender, **kwargs) +pre_save.connect(area_pre_save, sender=Area) + + +def area_post_save(sender, **kwargs): + if not kwargs['instance']: + return + area = kwargs['instance'] + if area.default: + defaults = Area.objects.filter(default=True).exclude(pk=area.pk) + for default in defaults: + default.default = False + default.save() + # manage permissions + old_urn, old_name = area.urn, area.name + if area.pk in pre_save_area_values: + old_urn, old_name = pre_save_area_values[area.pk] + perm = None + if area.urn != old_urn: + oldmnemo = 'change_area_' + old_urn + old_perm = Permission.objects.filter(codename=oldmnemo) + if old_perm.count(): + perm = old_perm.all()[0] + perm.codename = 'change_area_' + area.urn + perm.save() + if not area.urn: + area.urn = defaultfilters.slugify(area.name) + area.save() + mnemo = 'change_area_' + area.urn + perm = Permission.objects.filter(codename=mnemo) + lbl = "Can change " + area.name + if not perm.count(): + content_type, created = ContentType.objects.get_or_create( + app_label="chimere", model="area") + perm = Permission(name=lbl, content_type_id=content_type.id, + codename=mnemo) + perm.save() + else: + perm = perm.all()[0] + if old_name != area.name: + perm.name = lbl + perm.save() + # manage moderation group + groupname = area.name + " moderation" + if old_name != area.name: + old_groupname = old_name + " moderation" + old_gp = Group.objects.filter(name=old_groupname) + if old_gp.count(): + old_gp = old_gp.all()[0] + old_gp.name = groupname + old_gp.save() + group = Group.objects.filter(name=groupname) + if not group.count(): + group = Group.objects.create(name=groupname) + group.permissions.add(perm) + for app_label, model in (('chimere', 'marker'), + ('chimere', 'route'), + ('chimere', 'polygon'), + ('chimere', 'multimediafile'), + ('chimere', 'picturefile'), + ('chimere', 'routefile')): + ct, created = ContentType.objects.get_or_create( + app_label=app_label, model=model) + for p in Permission.objects.filter(content_type=ct).all(): + group.permissions.add(p) + +post_save.connect(area_post_save, sender=Area) + + +def get_areas_for_user(user): + """ + Getting subcats for a specific user + """ + perms = user.get_all_permissions() + areas = set() + prefix = 'chimere.change_area_' + for perm in perms: + if perm.startswith(prefix): + try: + area = Area.objects.get(urn=perm[len(prefix):]) + areas.add(area) + except ObjectDoesNotExist: + pass + return areas + + +def get_users_by_area(area): + if not area: + return [] + perm = 'change_area_' + area.urn + return User.objects.filter(Q(groups__permissions__codename=perm) | + Q(user_permissions__codename=perm)).all() + + +class AreaLayers(models.Model): + area = models.ForeignKey(Area) + layer = models.ForeignKey(Layer) + order = models.IntegerField(_(u"Order")) + default = models.NullBooleanField(_(u"Default layer")) + + class Meta: + ordering = ('order',) + verbose_name = _("Layers") + verbose_name_plural = _("Layers") + + +class PropertyModel(models.Model): + '''Model for a property + ''' + name = models.CharField(_(u"Name"), max_length=150) + slug = models.SlugField(_(u"Slug"), blank=True, null=True) + order = models.IntegerField(_(u"Order")) + available = models.BooleanField(_(u"Available")) + mandatory = models.BooleanField(_(u"Mandatory")) + subcategories = SelectMultipleField( + SubCategory, related_name='properties', + blank=True, verbose_name=_(u"Restricted to theses sub-categories"), + help_text=_(u"If no sub-category is set all the property applies to " + u"all sub-categories")) + areas = SelectMultipleField( + 'Area', verbose_name=_(u"Restrict to theses areas"), blank=True, + null=True, + help_text=_(u"If no area is set the property apply to " + u"all areas")) + TYPE = (('T', _('Text')), + ('L', _('Long text')), + ('P', _('Password')), + ('D', _("Date")), + ('C', _("Choices")), + ('A', _("Choices (autocomplete)")), + ('B', _("Boolean")), + ) + TYPE_WIDGET = {'T': forms.TextInput, + 'L': TextareaWidget, + 'P': forms.PasswordInput, + 'D': DatePickerWidget, + 'C': forms.Select, + 'A': JQueryAutoComplete, + 'B': forms.CheckboxInput, + } + type = models.CharField(_(u"Type"), max_length=1, choices=TYPE) + + class Meta: + ordering = ('order',) + verbose_name = _("Property model") + + def __unicode__(self): + return self.name + + def getAttrName(self): + return self.slug + + def getNamedId(self): + '''Get the name used as named id (easily sortable) + ''' + return 'property_%d_%d' % (self.order, self.id) + + @classmethod + def getAvailable(cls, area_name=None): + if area_name and area_name.endswith('/'): + area_name = area_name[:-1] + base_q = cls.objects.filter(available=True).annotate(Count('areas')) + q1 = base_q.filter(areas__count=0) + if not area_name: + return [q1] + # areas__count__gt=0 necessary to prevent Django bug + q2 = base_q.filter(Q(areas__urn=area_name) & Q(areas__count__gt=0)) + # if made it a single queryset the condition on 'count' is + # wrong - hope this will be fixed on higher Django version (>=1.4) + # to make a single query + return [q1, q2] + + +class PropertyModelChoice(models.Model): + '''Choices for property model + ''' + propertymodel = models.ForeignKey(PropertyModel, related_name='choices', + verbose_name=_(u"Property model")) + value = models.CharField(_(u"Value"), max_length=150) + available = models.BooleanField(_(u"Available"), default=True) + + class Meta: + verbose_name = _(u"Model property choice") + + def __unicode__(self): + return unicode(self.value) + + +class Property(models.Model): + '''Property for a POI + ''' + marker = models.ForeignKey( + Marker, verbose_name=_(u"Point of interest"), blank=True, null=True) + polygon = models.ForeignKey( + Polygon, verbose_name=_(u"Polygon"), blank=True, null=True) + propertymodel = models.ForeignKey(PropertyModel, + verbose_name=_(u"Property model")) + value = models.TextField(_(u"Value")) + + def __unicode__(self): + if self.propertymodel.type == 'C': + if not self.value: + return '' + try: + return unicode(PropertyModelChoice.objects.get( + pk=self.value).value) + except (self.DoesNotExist, ValueError): + return "" + return unicode(self.value) + + class Meta: + verbose_name = _(u"Property") + + @property + def python_value(self): + if self.propertymodel.type == 'D': + try: + return datetime.date(*[int(val) + for val in self.value.split('-')]) + except: + return "" + if self.propertymodel.type == 'C' and self.value: + try: + return PropertyModelChoice.objects.get(pk=self.value) + except (self.DoesNotExist, ValueError): + return None + else: + return self.value |
