wcs/wcs/wf/geolocate.py

265 lines
9.7 KiB
Python

# w.c.s. - web application for online forms
# Copyright (C) 2005-2016 Entr'ouvert
#
# 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 2 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/>.
import collections
import json
import urllib.parse
try:
from PIL import Image
from PIL.TiffImagePlugin import IFDRational
except ImportError:
Image = None
from quixote import get_publisher
from wcs.workflows import WorkflowStatusItem, register_item_class
from ..qommon import _, force_str, get_logger
from ..qommon.errors import ConnectionError
from ..qommon.form import CheckboxWidget, ComputedExpressionWidget, RadiobuttonsWidget
from ..qommon.misc import http_get_page, normalize_geolocation
class GeolocateWorkflowStatusItem(WorkflowStatusItem):
description = _('Geolocation')
key = 'geolocate'
category = 'formdata-action'
method = 'address_string'
address_string = None
map_variable = None
photo_variable = None
overwrite = True
def get_parameters(self):
return ('method', 'address_string', 'map_variable', 'photo_variable', 'overwrite', 'condition')
def add_parameters_widgets(self, form, parameters, prefix='', formdef=None, **kwargs):
super().add_parameters_widgets(form, parameters, prefix=prefix, formdef=formdef, **kwargs)
methods = collections.OrderedDict(
[
('address_string', _('Address String')),
('map_variable', _('Map Variable')),
('photo_variable', _('Photo Variable')),
]
)
if Image is None:
del methods['photo_variable']
if 'method' in parameters:
form.add(
RadiobuttonsWidget,
'%smethod' % prefix,
title=_('Method'),
options=list(methods.items()),
value=self.method,
attrs={'data-dynamic-display-parent': 'true'},
extra_css_class='widget-inline-radio',
)
if 'address_string' in parameters:
form.add(
ComputedExpressionWidget,
'%saddress_string' % prefix,
size=50,
title=_('Address String'),
value=self.address_string,
attrs={
'data-dynamic-display-child-of': '%smethod' % prefix,
'data-dynamic-display-value': methods.get('address_string'),
},
)
if 'map_variable' in parameters:
form.add(
ComputedExpressionWidget,
'%smap_variable' % prefix,
size=50,
title=_('Map Variable'),
value=self.map_variable,
attrs={
'data-dynamic-display-child-of': '%smethod' % prefix,
'data-dynamic-display-value': methods.get('map_variable'),
},
)
if 'photo_variable' in parameters:
form.add(
ComputedExpressionWidget,
'%sphoto_variable' % prefix,
size=50,
title=_('Photo Variable'),
value=self.photo_variable,
attrs={
'data-dynamic-display-child-of': '%smethod' % prefix,
'data-dynamic-display-value': methods.get('photo_variable'),
},
)
if 'overwrite' in parameters:
form.add(
CheckboxWidget,
'%soverwrite' % prefix,
title=_('Overwrite existing geolocation'),
value=self.overwrite,
)
def perform(self, formdata):
if not self.method:
return
if not formdata.formdef.geolocations:
return
geolocation_point = list(formdata.formdef.geolocations.keys())[0]
if not formdata.geolocations:
formdata.geolocations = {}
if formdata.geolocations.get(geolocation_point) and not self.overwrite:
return
location = getattr(self, 'geolocate_' + self.method)(formdata)
if location:
formdata.geolocations[geolocation_point] = location
formdata.store()
def geolocate_address_string(self, formdata, compute_template=True):
if compute_template:
try:
address = self.compute(self.address_string, record_errors=False, raises=True)
except Exception as e:
get_publisher().record_error(
_('error in template for address string [%s]') % str(e), formdata=formdata, exception=e
)
return
else:
# this is when the action is being executed to prefill a map field;
# the template has already been rendered.
address = self.address_string
if not address:
get_logger().debug('error geolocating string (empty string)')
return
url = get_publisher().get_geocoding_service_url()
if '?' in url:
url += '&'
else:
url += '?'
url += 'q=%s' % urllib.parse.quote(address)
url += '&format=json'
url += '&accept-language=%s' % (get_publisher().get_site_language() or 'en')
try:
dummy, dummy, data, dummy = http_get_page(url, raise_on_http_errors=True)
except ConnectionError as e:
get_publisher().record_error(
_('error calling geocoding service [%s]') % str(e), formdata=formdata, exception=e
)
return
try:
data = json.loads(force_str(data))
except ValueError:
get_logger().debug('non-JSON response from geocoding service')
return
if len(data) == 0 or isinstance(data, dict):
get_logger().debug('error finding location')
return
coords = data[0]
return normalize_geolocation({'lon': coords['lon'], 'lat': coords['lat']})
def geolocate_map_variable(self, formdata):
value = self.compute(self.map_variable)
if not value:
return
try:
lat, lon = str(value).split(';')
lat_lon = normalize_geolocation({'lon': lon, 'lat': lat})
except Exception as e:
get_publisher().record_error(
_('error geolocating from map variable'), formdata=formdata, exception=e
)
return
return lat_lon
def geolocate_photo_variable(self, formdata):
if Image is None:
get_logger().debug('error geolocating from file (missing PIL)')
return
value = self.compute(self.photo_variable)
if not value:
get_logger().debug('error geolocating from photo, no image')
return
if not hasattr(value, 'get_file_pointer'):
get_logger().debug('error geolocating from photo, invalid variable')
return
try:
image = Image.open(value.get_file_pointer())
except IOError:
get_logger().debug('error geolocating from photo, invalid file')
return
try:
exif_data = image._getexif()
except AttributeError:
get_logger().debug('error geolocating from photo, failed to get EXIF data')
return
if exif_data:
gps_info = exif_data.get(0x8825)
if gps_info and 2 in gps_info and 4 in gps_info:
# lat_ref will be N/S, lon_ref wil l be E/W
# lat and lon will be degrees/minutes/seconds (value, denominator),
# like ((33, 1), (51, 1), (2191, 100))
lat, lon = gps_info[2], gps_info[4]
try:
lat_ref = gps_info[1]
except KeyError:
lat_ref = 'N'
try:
lon_ref = gps_info[3]
except KeyError:
lon_ref = 'E'
if isinstance(lat[0], IFDRational):
lat = 1.0 * lat[0] + 1.0 * lat[1] / 60 + 1.0 * lat[2] / 3600
lon = 1.0 * lon[0] + 1.0 * lon[1] / 60 + 1.0 * lon[2] / 3600
else:
# Pillow < 7.2 compat
try:
lat = (
1.0 * lat[0][0] / lat[0][1]
+ 1.0 * lat[1][0] / lat[1][1] / 60
+ 1.0 * lat[2][0] / lat[2][1] / 3600
)
lon = (
1.0 * lon[0][0] / lon[0][1]
+ 1.0 * lon[1][0] / lon[1][1] / 60
+ 1.0 * lon[2][0] / lon[2][1] / 3600
)
except ZeroDivisionError:
get_logger().debug(
'error geolocating from photo, invalid EXIF data (%r / %r)'
% (gps_info[2], gps_info[4])
)
return
if lat_ref == 'S':
lat = -lat
if lon_ref == 'W':
lon = -lon
return normalize_geolocation({'lon': lon, 'lat': lat})
return
register_item_class(GeolocateWorkflowStatusItem)