#!/usr/bin/env python # -*- coding: utf-8 -*- # Copyright (C) 2008-2016 Étienne Loks # 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 . # See the file COPYING for details. """ Forms """ from django import forms from django.conf import settings from django.core.exceptions import ImproperlyConfigured from django.core.urlresolvers import reverse from django.db.models import Q from django.forms.formsets import formset_factory from django.utils.translation import ugettext as _ from django.contrib.auth.models import User, Permission, ContentType from django.core.mail import EmailMessage, BadHeaderError if hasattr(settings, 'CHIMERE_SEARCH_ENGINE') and \ settings.CHIMERE_SEARCH_ENGINE: from haystack.forms import SearchForm as HaystackSearchForm from chimere.models import Marker, Route, PropertyModel, Area,\ News, Category, RouteFile, MultimediaFile, MultimediaType, \ PictureFile, Importer, PropertyModelChoice, Page, IMPORTER_CHOICES, \ get_areas_for_user, SubCategoryUserLimit, Polygon from chimere.widgets import AreaField, PointField, TextareaWidget, \ FullTextareaWidget, DatePickerWidget, ButtonSelectWidget, NominatimWidget,\ TextareaAdminWidget, ImportFiltrWidget, ImporterChoicesWidget, RE_XAPI from datetime import timedelta, datetime, tzinfo ZERO = timedelta(0) class UTC(tzinfo): """UTC time zone""" def utcoffset(self, dt): return ZERO def tzname(self, dt): return settings.TIME_ZONE def dst(self, dt): return ZERO def getStaffEmails(): return [u.email for u in User.objects.filter( is_superuser=True).exclude(email="").order_by('id')] def getRelevantModeratorEmails(geo_object): categories = list(geo_object.categories.all()) # q = Q(is_staff=True) & ( # Q(subcategory_limit_to__count=0) | # Q(subcategory_limit_to__subcategory__in=categories)) q1 = Q(is_staff=True) & ( Q(subcategory_limit_to__subcategory__in=categories)) qusers = list(User.objects.filter(q1).exclude(email="").all()) q2 = Q(is_staff=True) qusers += list(User.objects.filter(q2).exclude( email="").exclude(pk__in=[ sl.user.pk for sl in SubCategoryUserLimit.objects.all()]).all()) users = [] for user in qusers: areas = get_areas_for_user(user) if not areas: users.append(user) continue contained = Q() qs = geo_object.__class__.objects.filter(pk=geo_object.pk) for area in areas: if hasattr(geo_object, 'point'): contained = contained | area.getIncludeMarker() elif hasattr(geo_object, 'route'): contained = contained | area.getIncludeRoute() qs = qs.filter(contained) if qs.count(): users.append(user) lst = [u.email for u in users] return lst def notifyStaff(subject, body, sender=None): user_list = getStaffEmails() notifyByEmail(subject, body, user_list, sender) def notifyByEmail(subject, body, user_list, sender=None): if not settings.EMAIL_HOST or not user_list: return if settings.PROJECT_NAME: subject = '[%s] %s' % (settings.PROJECT_NAME, subject) headers = {} if sender: headers['Reply-To'] = sender email = EmailMessage(subject, body, user_list[0], user_list, headers=headers) try: email.send() except (BadHeaderError, ConnectionRefusedError): return False return True def notifySubmission(absolute_uri, geo_object): category = " - ".join([str(cat) for cat in geo_object.categories.all()]) subject = '%s %s' % (_("New submission for"), category) message = _('The new item "%s" has been submited in the category: ') % \ geo_object.name + category message += "\n\n" + _("To valid, precise or unvalid this item: ") named_url = 'admin:chimere_%s_change' % \ geo_object.__class__.__name__.lower() message += absolute_uri + reverse(named_url, args=(geo_object.pk,)) message += "\n\n--\nChimère" user_list = getStaffEmails() + getRelevantModeratorEmails(geo_object) user_list = list(set(user_list)) return notifyByEmail(subject, message, user_list) class ContactForm(forms.Form): """ Main form for categories """ email = forms.EmailField(label=_("Email (optional)"), required=False) content = forms.CharField(label=_("Object"), widget=forms.Textarea) class SubCategoryAdminForm(forms.ModelForm): ''' Add a tinyMCE widget to fill description ''' description = forms.CharField(widget=FullTextareaWidget, required=False) class Meta: exclude = [] class PageAdminForm(forms.ModelForm): """ Main form for extra pages """ content = forms.CharField(widget=FullTextareaWidget) class Meta: model = Page exclude = [] class OSMForm(forms.Form): """ OSM export form """ username = forms.CharField(label=_("OSM user")) password = forms.CharField(label=_("Password"), widget=forms.PasswordInput(render_value=False)) # API URL are hardcoded: the day the API change Chimère will need # adaptations not only on this portion... api = forms.ChoiceField( label=_("API"), choices=(('', '--'), ('api06.dev.openstreetmap.org', _("Test API - %s") % 'api06.dev.openstreetmap.org'), ('api.openstreetmap.org/api', _("Main API - %s") % 'api.openstreetmap.org/api'), )) class NewsAdminForm(forms.ModelForm): """ Main form for news """ content = forms.CharField(widget=TextareaAdminWidget) class Meta: model = News exclude = [] class ImporterAdminForm(forms.ModelForm): filtr = forms.CharField(widget=ImportFiltrWidget, required=False) importer_type = forms.ChoiceField( widget=ImporterChoicesWidget, choices=[('', '--')] + list(IMPORTER_CHOICES)) default_description = forms.CharField(widget=TextareaAdminWidget, required=False) class Meta: model = Importer widgets = { 'source': forms.TextInput(attrs={'size': 80}), 'filtr': forms.Textarea(attrs={'size': 80}), } exclude = [] def clean(self): ''' Verify that only one type of source is provided Verify that shapefiles are zipped ''' if self.cleaned_data.get('importer_type') == 'OSM' and \ not self.cleaned_data.get('filtr'): raise forms.ValidationError( _("For OSM import you must be provide a filter. Select an " "area and node/way filter.")) if self.cleaned_data.get('importer_type') == 'OSM' and \ not RE_XAPI.match(self.cleaned_data.get('filtr')): raise forms.ValidationError( _("For OSM import you must be provide a filter. Select an " "area and node/way filter.")) if self.cleaned_data.get('importer_type') == 'SHP' and \ not self.cleaned_data.get('zipped'): raise forms.ValidationError(_("Shapefiles must be provided in a " "zipped archive.")) if self.cleaned_data.get('importer_type') not in ('XSLT', 'XXLT') and \ self.cleaned_data.get('source') and \ self.cleaned_data.get('source_file'): raise forms.ValidationError(_("You have to set \"source\" or " "\"source file\" but not both.")) if not self.cleaned_data.get('source') and \ not self.cleaned_data.get('source_file') and \ self.cleaned_data.get('importer_type') != 'OSM': raise forms.ValidationError(_("You have to set \"source\" or " "\"source file\".")) return self.cleaned_data class CategoryAdminForm(forms.ModelForm): """ Main form for categories """ description = forms.CharField(widget=TextareaAdminWidget, required=False) class Media: js = [ '%sjquery/jquery.min.js' % settings.STATIC_URL, '%schimere/js/menu-sort.js' % settings.STATIC_URL, ] css = { 'all': ('chimere/css/admin.css',) } class Meta: model = Category exclude = [] def get_properties(queryset): # As we have dynamic fields, it's cleaner to make the class dynamic too fields = {} for prop in queryset: key = "property_%d_%d" % (prop.order, prop.id) if prop.type == 'C': choices = PropertyModelChoice.objects.filter(propertymodel=prop, available=True ).order_by('value') fields[key] = forms.ChoiceField( label=prop.name, choices=[('', '--')] + [(choice.pk, str(choice)) for choice in choices], required=False) elif prop.type == 'A': widget = PropertyModel.TYPE_WIDGET[prop.type] widget = widget(slug=prop.slug) fields[key] = forms.CharField(label=prop.name, widget=widget, required=False) else: widget = PropertyModel.TYPE_WIDGET[prop.type] fields[key] = forms.CharField( label=prop.name, widget=widget, required=False) return fields class MarkerAdminFormBase(forms.ModelForm): """ Main form for marker """ is_admin = True name = forms.CharField(label=_("Name"), required=True) description = forms.CharField(widget=FullTextareaWidget, required=False) _PROPERTY_FILTERS = {} class Meta: model = Marker exclude = [] @classmethod def _set_cls_fields(cls): fields = get_properties( PropertyModel.objects.filter(**cls._PROPERTY_FILTERS).all()) for key in fields: setattr(cls, key, fields[key]) def _set_fields(self): fields = get_properties( PropertyModel.objects.filter(**self._PROPERTY_FILTERS).all()) for key in fields: self.fields[key] = fields[key] def __init__(self, *args, **keys): """ Custom initialization method in order to manage properties """ area_name = None if 'area_name' in keys: area_name = keys.pop('area_name') querys = PropertyModel.getAvailable(area_name=area_name) self.pms = [] for query in querys: self.pms += [pm for pm in query.all()] if 'instance' in keys and keys['instance']: instance = keys['instance'] property_dct = {} for pm in self.pms: property = instance.getProperty(pm) if property: property_dct[pm.getNamedId()] = property.value if 'initial' in keys: keys['initial'].update(property_dct) else: keys['initial'] = property_dct subcategories = keys.pop('subcategories') \ if 'subcategories' in keys else [] super(MarkerAdminFormBase, self).__init__(*args, **keys) self._set_fields() if settings.CHIMERE_DAYS_BEFORE_EVENT: self.fields['start_date'].widget = DatePickerWidget() self.fields['end_date'].widget = DatePickerWidget() if self.is_admin: return if subcategories: self.fields['categories'].choices = subcategories # auto select if there is only one category choices = list(self.fields['categories'].choices) self.fields['categories'].choices = choices self.fields['categories'].label = "" if (len(choices) == 1): self.fields['categories'].widget = forms.MultipleHiddenInput() choices = list(self.fields['categories'].choices) if type(choices[0][1]) in (list, tuple): # hierarchical choices self.fields['categories'].label = "{} / {}".format( choices[0][0], choices[0][1][0][1]) self.fields['categories'].initial = \ [self.fields['categories'].choices[0][1][0][0]] else: self.fields['categories'].label = "{}".format( choices[0][1]) self.fields['categories'].initial = \ [self.fields['categories'].choices[0][0]] if not settings.CHIMERE_SEARCH_ENGINE and 'keywords' in self.fields: self.fields.pop('keywords') if not settings.CHIMERE_DAYS_BEFORE_EVENT: self.fields.pop('start_date') self.fields.pop('end_date') # not a clean way to filter properties... # to do: change creation process pms = [pm.getNamedId() for pm in self.pms] for k in self.fields.keys(): if not k.startswith('property_') or \ k in pms: continue self.fields.pop(k) def clean(self): ''' Verify that a start date is provided when an end date is set Verify the mandatory properties (to be check manualy because it depends on the checked categories) ''' start_date = self.cleaned_data.get('start_date') end_date = self.cleaned_data.get('end_date') if end_date and not start_date: msg = _("End date has been set with no start date") self._errors["end_date"] = self.error_class([msg]) del self.cleaned_data['end_date'] if end_date and start_date and start_date > end_date: msg = _("End date can't be before start date") self._errors["end_date"] = self.error_class([msg]) raise forms.ValidationError(msg) for pm in self.pms: if not pm.mandatory or self.cleaned_data[pm.getNamedId()]: continue pm_cats = pm.subcategories.all() if not pm_cats or \ [submited_cat for submited_cat in self.cleaned_data['categories'] if submited_cat in pm_cats]: msg = _("This field is mandatory for the selected categories") self._errors[pm.getNamedId()] = self.error_class([msg]) # raise forms.ValidationError() return self.cleaned_data def save(self, *args, **keys): """ Custom save method in order to manage associated properties """ new_marker = super(MarkerAdminFormBase, self).save(*args, **keys) if 'status' not in self.cleaned_data and not new_marker.status: new_marker.status = 'S' if new_marker.status == 'A': tz = UTC() new_marker.available_date = datetime.replace(datetime.utcnow(), tzinfo=tz) new_marker.save() # save properties properties = dict( [(k.split('_')[-1], self.cleaned_data[k]) for k in self.cleaned_data.keys() if k.startswith('property_')]) new_marker.saveProperties(properties) return new_marker class MarkerAdminForm(MarkerAdminFormBase): pass class MarkerBaseForm(MarkerAdminFormBase): # in public form only visible fields are displayed _PROPERTY_FILTERS = {'available': True} EXCLUDED_FIELDS = ['status'] if not settings.CHIMERE_SEARCH_ENGINE: EXCLUDED_FIELDS.append('keywords') class MarkerForm(MarkerBaseForm): """ Form for the edit page """ is_admin = False ref_pk = forms.IntegerField(label=" ", widget=forms.HiddenInput(), required=False) description = forms.CharField(widget=TextareaWidget, required=False) keywords = forms.CharField(widget=TextareaWidget, max_length=200, required=False) class Meta: model = Marker exclude = EXCLUDED_FIELDS widgets = { 'description': TextareaWidget(), } class RouteAdminForm(forms.ModelForm): """ Main form for route """ is_admin = True name = forms.CharField(label=_("Name"), required=True) read_from_file = forms.BooleanField( label=_("Lire la géométrie depuis le fichier"), required=False) class Meta: model = Route exclude = [] widgets = { 'categories': forms.SelectMultiple } def __init__(self, *args, **keys): """ Custom initialization method in order to manage properties """ area_name = None if 'area_name' in keys: area_name = keys.pop('area_name') querys = PropertyModel.getAvailable(area_name=area_name) self.pms = [] for query in querys: self.pms += [pm for pm in query.all()] if 'instance' in keys and keys['instance']: instance = keys['instance'] property_dct = {} for pm in PropertyModel.objects.filter(available=True): property = instance.getProperty(pm) if property: property_dct[pm.getNamedId()] = property.value if 'initial' in keys: keys['initial'].update(property_dct) else: keys['initial'] = property_dct subcategories = keys.pop('subcategories') \ if 'subcategories' in keys else [] super(RouteAdminForm, self).__init__(*args, **keys) if self.is_admin: self.fields['route'].required = False self.fields['categories'].widget = forms.SelectMultiple( choices=self.fields['categories'].choices) return self.fields.pop('read_from_file') if not settings.CHIMERE_SEARCH_ENGINE and 'keywords' in self.fields: self.fields.pop('keywords') if settings.CHIMERE_DAYS_BEFORE_EVENT: self.fields['start_date'].widget = DatePickerWidget() self.fields['end_date'].widget = DatePickerWidget() self.fields['categories'].choices = [] if subcategories: self.fields['categories'].choices = subcategories # not a clean way to filter properties... # to do: change creation process pms = [pm.getNamedId() for pm in self.pms] for k in self.fields.keys(): if not k.startswith('property_') or \ k in pms: continue self.fields.pop(k) def clean(self): if self.cleaned_data.get('read_from_file', None) and \ self.cleaned_data.get('associated_file', None): self.cleaned_data['associated_file'].process() self.cleaned_data['route'] = \ self.cleaned_data['associated_file'].route if not self.cleaned_data.get('route', None): raise forms.ValidationError("Vous devez rentrer un trajet ou lire " "celui-ci depuis un fichier.") return self.cleaned_data def save(self, *args, **keys): """ Custom save method in order to manage associated properties """ new_route = super(RouteAdminForm, self).save(*args, **keys) if 'status' not in self.cleaned_data and not new_route.status: new_route.status = 'S' new_route.save() return new_route class RouteForm(RouteAdminForm): """ Form for the edit page """ is_admin = False description = forms.CharField(widget=TextareaWidget, required=False) point = forms.CharField(label=" ", required=False, widget=forms.HiddenInput) associated_file_id = forms.CharField( label=" ", required=False, widget=forms.HiddenInput) keywords = forms.CharField(widget=TextareaWidget, max_length=200, required=False) class Meta: model = Route exclude = EXCLUDED_FIELDS def __init__(self, *args, **kwargs): if kwargs.get('instance'): try: marker = Marker.objects.get(route=kwargs['instance']) kwargs['initial'] = { 'point': marker.point, 'description': marker.description} property_dct = {} for pm in PropertyModel.objects.filter(available=True): property = marker.getProperty(pm) if property: property_dct[pm.getNamedId()] = property.value if 'initial' in kwargs: kwargs['initial'].update(property_dct) else: kwargs['initial'] = property_dct except: pass super(RouteForm, self).__init__(*args, **kwargs) def save(self, *args, **keys): """ Custom save method in order to manage associated file """ new_route = super(RouteForm, self).save(*args, **keys) # associate a route file if 'associated_file_id' in self.cleaned_data and \ self.cleaned_data['associated_file_id']: file_pk = int(self.cleaned_data['associated_file_id']) new_route.associated_file = RouteFile.objects.get(pk=file_pk) new_route.save() return new_route class PolygonAdminForm(MarkerAdminForm): """ Main form for polygon """ class Meta: model = Polygon exclude = [] class PolygonForm(PolygonAdminForm): """ Form for the edit page """ is_admin = False description = forms.CharField(widget=TextareaWidget, required=False) class Meta: model = Polygon exclude = EXCLUDED_FIELDS class BaseFileForm(forms.ModelForm): id = forms.IntegerField(label="", widget=forms.HiddenInput(), required=False) def __init__(self, *args, **kwargs): if not hasattr(self, '_related_name') or not self._related_name: raise ImproperlyConfigured super(BaseFileForm, self).__init__(*args, **kwargs) self.fields.pop('marker') self.fields.pop('route') self.fields.pop('polygon') def save(self, associated_item): if not hasattr(self, 'cleaned_data') or not self.cleaned_data: return instance = None if self.cleaned_data.get('id'): try: instance = self._meta.model.objects.get( pk=self.cleaned_data['id']) except: pass self.cleaned_data.pop('id') if self.cleaned_data.get('DELETE'): if instance: instance.delete() return self.cleaned_data.pop('DELETE') if type(associated_item) == Marker: self.cleaned_data['marker'] = associated_item if type(associated_item) == Polygon: self.cleaned_data['polygon'] = associated_item if instance: for k in self.cleaned_data: setattr(instance, k, self.cleaned_data[k]) instance.save() else: instance = self._meta.model.objects.create(**self.cleaned_data) class MultimediaFileAdminForm(forms.ModelForm): class Meta: model = MultimediaFile exclude = [] class Media: js = [ '%sjquery/jquery.min.js' % settings.STATIC_URL, '%schimere/js/menu-sort.js' % settings.STATIC_URL, ] def __init__(self, *args, **kwargs): super(MultimediaFileAdminForm, self).__init__(*args, **kwargs) self.fields['multimedia_type'].widget.choices = \ MultimediaType.get_tuples() class MultimediaFileForm(BaseFileForm): """ Form for a multimedia file """ _related_name = 'multimedia_files' class Meta: model = MultimediaFile exclude = ('order', 'miniature', 'multimedia_type') MultimediaFileFormSet = formset_factory(MultimediaFileForm, can_delete=True) class PictureFileAdminForm(forms.ModelForm): class Meta: model = PictureFile exclude = [] class Media: js = [ '%sjquery/jquery.min.js' % settings.STATIC_URL, '%schimere/js/menu-sort.js' % settings.STATIC_URL, ] class PictureFileForm(BaseFileForm): """ Form for a picture file """ _related_name = 'pictures' class Meta: model = PictureFile exclude = ('order', 'height', 'width', 'thumbnailfile', 'thumbnailfile_height', 'thumbnailfile_width', 'miniature') PictureFileFormSet = formset_factory(PictureFileForm, can_delete=True) class FileForm(forms.Form): raw_file = forms.FileField(label=_("File")) def clean_raw_file(self): data = self.cleaned_data['raw_file'] if '.' not in data.name or \ data.name.split('.')[-1].lower() not in ('kml', 'gpx'): raise forms.ValidationError(_("Bad file format: this must be a " "GPX or KML file")) return data class FullFileForm(FileForm): name = forms.CharField(label=_("Name"), max_length=150) def __init__(self, *args, **kwargs): super(FullFileForm, self).__init__(*args, **kwargs) self.fields.keyOrder = ['name', 'raw_file'] class AreaAdminForm(forms.ModelForm): """ Admin page to create an area """ area = AreaField(label=_("Area"), fields=(PointField(), PointField())) welcome_message = forms.CharField(widget=TextareaAdminWidget, required=False) class Meta: model = Area exclude = [] def __init__(self, *args, **keys): """ Custom initialization method in order to manage area """ if args: vals = args[0] for k in ('upper_left_lat', 'upper_left_lon', 'lower_right_lat', 'lower_right_lon'): v = vals.get(k) try: v = float(v) except ValueError: v = None if not v: args[0][k] = None if 'instance' in keys and keys['instance']: instance = keys['instance'] dct = {'area': (instance.upper_left_corner, instance.lower_right_corner)} if 'initial' in keys: keys['initial'].update(dct) else: keys['initial'] = dct super(AreaAdminForm, self).__init__(*args, **keys) def clean(self): ''' Verify that the area is not empty ''' if not self.cleaned_data.get('upper_left_lat') \ and not self.cleaned_data.get('upper_left_lon') \ and not self.cleaned_data.get('lower_right_lat') \ and not self.cleaned_data.get('lower_right_lon') \ and not self.cleaned_data.get('area'): msg = _("No area selected.") raise forms.ValidationError(msg) if self.cleaned_data.get('order'): q = Area.objects.filter(order=self.cleaned_data.get('order')) if self.instance: q = q.exclude(pk=self.instance.pk) if q.count(): msg = _("The area \"%s\" has the same order, you need to" " choose another one.") % str(q.all()[0]) raise forms.ValidationError(msg) return self.cleaned_data def save(self, *args, **keys): """ Custom save method in order to manage area """ new_area = super(AreaAdminForm, self).save(*args, **keys) area = self.cleaned_data['area'] new_area.upper_left_corner = 'POINT(%s %s)' % (area[0][0], area[0][1]) new_area.lower_right_corner = 'POINT(%s %s)' % (area[1][0], area[1][1]) content_type = ContentType.objects.get(app_label="chimere", model="area") if new_area.urn: mnemo = 'change_area_' + new_area.urn perm = Permission.objects.filter(codename=mnemo) if not perm: perm = Permission( name='Can change ' + new_area.name, content_type_id=content_type.id, codename=mnemo) perm.save() else: if 'urn' in self.initial: mnemo = 'change_area_' + self.initial['urn'] perm = Permission.objects.filter(codename=mnemo) if perm: perm[0].delete() return new_area class AreaForm(AreaAdminForm): """ Form for the edit page """ class Meta: model = Area exclude = [] CHIMERE_ROUTING_TRANSPORT = [] ROUTING_INIT = None if hasattr(settings, 'CHIMERE_ROUTING_TRANSPORT'): CHIMERE_ROUTING_TRANSPORT = [ (idx, _(lbl)) for idx, lbl in settings.CHIMERE_ROUTING_TRANSPORT] if CHIMERE_ROUTING_TRANSPORT: ROUTING_INIT = CHIMERE_ROUTING_TRANSPORT[0][0] class RoutingForm(forms.Form): transport = forms.ChoiceField(label='', widget=ButtonSelectWidget, choices=CHIMERE_ROUTING_TRANSPORT, initial=ROUTING_INIT) start = forms.CharField(label=_("Start"), widget=NominatimWidget) end = forms.CharField(label=_("Finish"), widget=NominatimWidget) speed = forms.ChoiceField(label=_("Speed"), choices=[], required=False, widget=forms.RadioSelect) def __init__(self, *args, **kwargs): super(RoutingForm, self).__init__(*args, **kwargs) if not settings.CHIMERE_ROUTING_SPEEDS: self.fields.pop('speed') self.fields['speed'].widget.choices = [] for transport in settings.CHIMERE_ROUTING_SPEEDS: for speed, lbl in settings.CHIMERE_ROUTING_SPEEDS[transport]: self.fields['speed'].widget.choices.append( ("%s_%d" % (transport, speed), _(lbl))) SearchForm = None if hasattr(settings, 'CHIMERE_SEARCH_ENGINE') \ and settings.CHIMERE_SEARCH_ENGINE: class SearchForm(HaystackSearchForm): pass