512 lines
19 KiB
Python
512 lines
19 KiB
Python
# passerelle - uniform access to multiple data sources and services
|
|
# Copyright (C) 2018 Entr'ouvert
|
|
#
|
|
# This program is free software: you can redistribute it and/or modify it
|
|
# under the terms of the GNU Affero General Public License as published
|
|
# by the Free Software Foundation, either version 3 of the License, or
|
|
# (at your option) any later version.
|
|
#
|
|
# This program is distributed in the hope that it will be useful,
|
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
# GNU Affero General Public License for more details.
|
|
#
|
|
# You should have received a copy of the GNU Affero General Public License
|
|
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
|
|
|
import string
|
|
from urllib import parse as urlparse
|
|
|
|
from django.core.exceptions import ValidationError
|
|
from django.db import models
|
|
from django.http import HttpResponse
|
|
from django.shortcuts import get_object_or_404
|
|
from django.urls import reverse
|
|
from django.utils.text import slugify
|
|
from django.utils.translation import gettext_lazy as _
|
|
|
|
from passerelle.base.models import BaseQuery, BaseResource, HTTPResource
|
|
from passerelle.utils import mark_safe_lazy
|
|
from passerelle.utils.api import endpoint
|
|
from passerelle.utils.conversion import num2deg
|
|
from passerelle.utils.jsonresponse import APIError
|
|
from passerelle.utils.templates import render_to_string, validate_template
|
|
|
|
|
|
class ArcGISError(APIError):
|
|
pass
|
|
|
|
|
|
class ArcGIS(BaseResource, HTTPResource):
|
|
category = _('Geographic information system')
|
|
|
|
base_url = models.URLField(_('Webservice Base URL'))
|
|
|
|
token_username = models.CharField(
|
|
max_length=128, verbose_name=_('User name of the user who wants to get a token'), blank=True
|
|
)
|
|
token_password = models.CharField(
|
|
max_length=128, verbose_name=_('Password of the user who wants to get a token'), blank=True
|
|
)
|
|
|
|
class Meta:
|
|
verbose_name = _('ArcGIS REST API')
|
|
|
|
def request(self, url, params=None, data=None, query_type='query'):
|
|
if data:
|
|
response = self.requests.post(url, params=params, data=data)
|
|
else:
|
|
response = self.requests.get(url, params=params, data=data)
|
|
if response.status_code // 100 != 2:
|
|
raise ArcGISError('ArcGIS/%s returned status code %s' % (query_type, response.status_code))
|
|
try:
|
|
response = response.json()
|
|
except ValueError:
|
|
raise ArcGISError('ArcGIS/%s returned invalid JSON content: %r' % (query_type, response.content))
|
|
if not isinstance(response, dict):
|
|
raise ArcGISError('ArcGIS/%s not returned a dict: %r' % (query_type, response))
|
|
if 'error' in response:
|
|
if not isinstance(response['error'], dict) or 'message' not in response['error']:
|
|
err_desc = 'unknown ArcGIS/%s error' % query_type
|
|
else:
|
|
err_desc = response['error']['message']
|
|
raise ArcGISError(err_desc, data=response)
|
|
return response
|
|
|
|
def generate_token(self):
|
|
if not self.token_username and not self.token_password:
|
|
return None
|
|
url = urlparse.urljoin(self.base_url, 'info')
|
|
info = self.request(url, params={'f': 'json'}, query_type='token')
|
|
token_url = info.get('authInfo', {}).get('tokenServicesUrl')
|
|
if not token_url:
|
|
raise ArcGISError('ArcGIS/token responded no authInfo/tokenServicesUrl in info: %r' % info)
|
|
response = self.request(
|
|
token_url,
|
|
data={
|
|
'username': self.token_username,
|
|
'password': self.token_password,
|
|
'client': 'referer',
|
|
'referer': urlparse.urljoin(self.base_url, 'services'),
|
|
'f': 'json',
|
|
},
|
|
query_type='token',
|
|
)
|
|
if 'token' not in response:
|
|
raise ArcGISError('ArcGIS/token returned no token: %r' % response)
|
|
return response['token']
|
|
|
|
def build_common_params(self, lat, lon, latmin, lonmin, latmax, lonmax, **kwargs):
|
|
# build common query params, see:
|
|
# https://developers.arcgis.com/rest/services-reference/query-map-service-layer-.htm
|
|
# https://developers.arcgis.com/rest/services-reference/enterprise/query-feature-service-layer-.htm
|
|
params = {
|
|
'f': 'json',
|
|
'inSR': '4326',
|
|
'outSR': '4326',
|
|
'outFields': '*',
|
|
}
|
|
if lat and lon:
|
|
try:
|
|
lon, lat = float(lon), float(lat)
|
|
except (ValueError,):
|
|
raise APIError('<lon> and <lat> must be floats', http_status=400)
|
|
params['geometry'] = f'{lon},{lat}'
|
|
params['geometryType'] = 'esriGeometryPoint'
|
|
elif latmin and lonmin and latmax and lonmax:
|
|
try:
|
|
lonmin, latmin = float(lonmin), float(latmin)
|
|
lonmax, latmax = float(lonmax), float(latmax)
|
|
except (ValueError,):
|
|
raise APIError('<lonmin> <latmin> <lonmax> and <latmax> must be floats', http_status=400)
|
|
params['geometry'] = f'{lonmin},{latmin},{lonmax},{latmax}'
|
|
params['geometryType'] = 'esriGeometryEnvelope'
|
|
# consider all remaining parameters as ArcGIS ones
|
|
params.update(kwargs)
|
|
if 'distance' in params and 'units' not in params:
|
|
params['units'] = 'esriSRUnit_Meter'
|
|
# add a token if applicable
|
|
if 'token' not in params:
|
|
token = self.generate_token()
|
|
if token is not None:
|
|
params['token'] = token
|
|
return params
|
|
|
|
def get_query_response(self, uri, params, id_template, text_template, full, text_fieldname=None):
|
|
url = urlparse.urljoin(self.base_url, uri)
|
|
infos = self.request(url, params=params)
|
|
|
|
features = infos.pop('features', [])
|
|
id_fieldname = infos.get('objectIdFieldName') or 'OBJECTID'
|
|
text_fieldname = text_fieldname or infos.get('displayFieldName')
|
|
aliases = {}
|
|
for field in infos.get('fields') or []:
|
|
if field.get('alias') and field.get('name'):
|
|
aliases[field['alias']] = field['name']
|
|
if infos.get('fieldAliases'):
|
|
for name, alias in infos['fieldAliases'].items():
|
|
aliases[alias] = name
|
|
|
|
data = []
|
|
|
|
def get_feature_attribute(feature, attribute):
|
|
if attribute in feature['attributes']:
|
|
return feature['attributes'][attribute]
|
|
return feature['attributes'].get(aliases.get(attribute))
|
|
|
|
for n, feature in enumerate(features):
|
|
if 'attributes' in feature:
|
|
feature['id'] = '%s' % get_feature_attribute(feature, id_fieldname)
|
|
feature['text'] = (
|
|
'%s' % get_feature_attribute(feature, text_fieldname) if text_fieldname else feature['id']
|
|
)
|
|
else:
|
|
feature['id'] = feature['text'] = '%d' % (n + 1)
|
|
if id_template:
|
|
feature['id'] = render_to_string(id_template, feature)
|
|
if text_template:
|
|
feature['text'] = render_to_string(text_template, feature)
|
|
if not full and 'geometry' in feature:
|
|
del feature['geometry']
|
|
data.append(feature)
|
|
|
|
if full:
|
|
return {'data': data, 'metadata': infos}
|
|
return {'data': data}
|
|
|
|
@endpoint(
|
|
name='mapservice-query',
|
|
description=_('Map Service Query'),
|
|
perm='can_access',
|
|
parameters={
|
|
'folder': {
|
|
'description': _('Folder name'),
|
|
'example_value': 'Specialty',
|
|
},
|
|
'service': {
|
|
'description': _('Service name'),
|
|
'example_value': 'ESRI_StateCityHighway_USA',
|
|
},
|
|
'layer': {
|
|
'description': _('Layer or table name'),
|
|
'example_value': '1',
|
|
},
|
|
'lat': {'description': _('Latitude')},
|
|
'lon': {'description': _('Longitude')},
|
|
'latmin': {'description': _('Minimal latitude (envelope)')},
|
|
'lonmin': {'description': _('Minimal longitude (envelope)')},
|
|
'latmax': {'description': _('Maximal latitude (envelope)')},
|
|
'lonmax': {'description': _('Maximal longitude (envelope)')},
|
|
'q': {'description': _('Search text in display field')},
|
|
'template': {
|
|
'description': _('Django template for text attribute'),
|
|
'example_value': '{{ attributes.STATE_NAME }} ({{ attributes.STATE_ABBR }})',
|
|
},
|
|
'id_template': {
|
|
'description': _('Django template for id attribute'),
|
|
},
|
|
'full': {
|
|
'description': _('Returns all ArcGIS informations (geometry, metadata)'),
|
|
'type': 'bool',
|
|
},
|
|
},
|
|
)
|
|
def mapservice_query(
|
|
self,
|
|
request,
|
|
service,
|
|
layer='0',
|
|
folder='',
|
|
lat=None,
|
|
lon=None,
|
|
latmin=None,
|
|
lonmin=None,
|
|
latmax=None,
|
|
lonmax=None,
|
|
q=None,
|
|
template=None,
|
|
id_template=None,
|
|
full=False,
|
|
**kwargs,
|
|
):
|
|
uri = 'services/'
|
|
if folder:
|
|
uri += folder + '/'
|
|
uri = uri + service + '/MapServer/' + layer + '/query'
|
|
|
|
params = self.build_common_params(lat, lon, latmin, lonmin, latmax, lonmax, **kwargs)
|
|
if q is not None and 'text' not in params:
|
|
params['text'] = q
|
|
if 'where' not in params and 'text' not in params:
|
|
params['where'] = '1=1'
|
|
|
|
return self.get_query_response(
|
|
uri, params, id_template=id_template, text_template=template, full=full
|
|
)
|
|
|
|
@endpoint(
|
|
name='featureservice-query',
|
|
description=_('Feature Service Query'),
|
|
perm='can_access',
|
|
parameters={
|
|
'folder': {
|
|
'description': _('Folder name'),
|
|
'example_value': 'Specialty',
|
|
},
|
|
'service': {
|
|
'description': _('Service name'),
|
|
'example_value': 'ESRI_StateCityHighway_USA',
|
|
},
|
|
'layer': {
|
|
'description': _('Layer or table name'),
|
|
'example_value': '1',
|
|
},
|
|
'lat': {'description': _('Latitude')},
|
|
'lon': {'description': _('Longitude')},
|
|
'latmin': {'description': _('Minimal latitude (envelope)')},
|
|
'lonmin': {'description': _('Minimal longitude (envelope)')},
|
|
'latmax': {'description': _('Maximal latitude (envelope)')},
|
|
'lonmax': {'description': _('Maximal longitude (envelope)')},
|
|
'text_fieldname': {
|
|
'description': _('Field name for text attribute'),
|
|
'example_value': 'STATE_NAME',
|
|
},
|
|
'template': {
|
|
'description': _('Django template for text attribute'),
|
|
'example_value': '{{ attributes.STATE_NAME }} ({{ attributes.STATE_ABBR }})',
|
|
},
|
|
'id_template': {
|
|
'description': _('Django template for id attribute'),
|
|
},
|
|
'full': {
|
|
'description': _('Returns all ArcGIS informations (geometry, metadata)'),
|
|
'type': 'bool',
|
|
},
|
|
},
|
|
)
|
|
def featureservice_query(
|
|
self,
|
|
request,
|
|
service,
|
|
layer='0',
|
|
folder='',
|
|
lat=None,
|
|
lon=None,
|
|
latmin=None,
|
|
lonmin=None,
|
|
latmax=None,
|
|
lonmax=None,
|
|
text_fieldname=None,
|
|
template=None,
|
|
id_template=None,
|
|
full=False,
|
|
**kwargs,
|
|
):
|
|
uri = 'services/'
|
|
if folder:
|
|
uri += folder + '/'
|
|
uri = uri + service + '/FeatureServer/' + layer + '/query'
|
|
|
|
params = self.build_common_params(lat, lon, latmin, lonmin, latmax, lonmax, **kwargs)
|
|
|
|
return self.get_query_response(
|
|
uri,
|
|
params,
|
|
id_template=id_template,
|
|
text_template=template,
|
|
full=full,
|
|
text_fieldname=text_fieldname,
|
|
)
|
|
|
|
@endpoint(
|
|
name='tile',
|
|
description=_('Tiles layer'),
|
|
perm='OPEN',
|
|
pattern=r'^(?P<layer>[\w/]+)/(?P<zoom>\d+)/(?P<tile_x>\d+)/(?P<tile_y>\d+)\.png$',
|
|
)
|
|
def tile(self, request, layer, zoom, tile_x, tile_y):
|
|
zoom = int(zoom)
|
|
tile_x = int(tile_x)
|
|
tile_y = int(tile_y)
|
|
|
|
bbox = '%.6f,%.6f,%.6f,%.6f' % (num2deg(tile_x, tile_y, zoom) + num2deg(tile_x + 1, tile_y + 1, zoom))
|
|
|
|
# imageSR=3857: default projection for leaflet
|
|
base_url = self.base_url
|
|
if not base_url.endswith('/'):
|
|
base_url += '/'
|
|
return HttpResponse(
|
|
self.requests.get(
|
|
base_url
|
|
+ '%s/MapServer/export' % layer
|
|
+ '?dpi=96&format=png24&bboxSR=4326&imageSR=3857&'
|
|
+ 'transparent=true&size=256,256&f=image&'
|
|
+ 'bbox=%s' % bbox
|
|
).content,
|
|
content_type='image/png',
|
|
)
|
|
|
|
@endpoint(
|
|
name='q',
|
|
description=_('Query'),
|
|
pattern=r'^(?P<query_slug>[\w:_-]+)/$',
|
|
perm='can_access',
|
|
show=False,
|
|
)
|
|
def q(self, request, query_slug, q=None, full=False, **kwargs):
|
|
query = get_object_or_404(Query, resource=self, slug=query_slug)
|
|
refs = [ref for ref, _ in query.where_references]
|
|
refs += ['q', 'full']
|
|
kwargs = {}
|
|
for key in request.GET:
|
|
if key not in refs:
|
|
kwargs[key] = request.GET[key]
|
|
return query.q(request, q=None, full=full, **kwargs)
|
|
|
|
def export_json(self):
|
|
d = super().export_json()
|
|
d['queries'] = [query.export_json() for query in self.queries.all()]
|
|
return d
|
|
|
|
@classmethod
|
|
def import_json_real(cls, overwrite, instance, d, **kwargs):
|
|
queries = d.pop('queries', [])
|
|
instance = super().import_json_real(overwrite, instance, d, **kwargs)
|
|
new = []
|
|
if instance and overwrite:
|
|
Query.objects.filter(resource=instance).delete()
|
|
for query in queries:
|
|
q = Query.import_json(query)
|
|
q.resource = instance
|
|
new.append(q)
|
|
Query.objects.bulk_create(new)
|
|
return instance
|
|
|
|
def create_query_url(self):
|
|
return reverse('arcgis-query-new', kwargs={'slug': self.slug})
|
|
|
|
|
|
class SqlFormatter(string.Formatter):
|
|
def format_field(self, value, format_spec):
|
|
if format_spec and format_spec[-1].isalpha() and format_spec[-1] == 'd':
|
|
value = int(value)
|
|
if format_spec and format_spec[-1] == 'l':
|
|
return '(' + ', '.join(SqlFormatter().format('{x}', x=x) for x in value.split(',')) + ')'
|
|
formatted = super().format_field(value, format_spec)
|
|
if not format_spec or not format_spec[-1].isalpha() or format_spec[-1] == 's':
|
|
formatted = "'%s'" % formatted.replace("'", "''")
|
|
return formatted
|
|
|
|
|
|
def validate_where(format_string):
|
|
formatter = SqlFormatter()
|
|
for dummy, ref, dummy, dummy in formatter.parse(format_string):
|
|
if ref is None:
|
|
pass
|
|
elif ref == '':
|
|
raise ValidationError(_('missing reference'))
|
|
elif ref != slugify(ref):
|
|
raise ValidationError(_('invalid reference'))
|
|
|
|
|
|
class Query(BaseQuery):
|
|
resource = models.ForeignKey(
|
|
to=ArcGIS, related_name='queries', verbose_name=_('Resource'), on_delete=models.CASCADE
|
|
)
|
|
|
|
folder = models.CharField(verbose_name=_('ArcGis Folder'), max_length=64, blank=True)
|
|
service = models.CharField(verbose_name=_('ArcGis Service'), max_length=64)
|
|
layer = models.CharField(verbose_name=_('ArcGis Layer'), max_length=8, blank=True)
|
|
|
|
where = models.TextField(
|
|
verbose_name=_('ArcGis Where Clause'),
|
|
blank=True,
|
|
validators=[validate_where],
|
|
help_text=mark_safe_lazy(
|
|
_(
|
|
'<div>Use syntax <tt>{name}</tt> to introduce a string '
|
|
'parameter and <tt>{name:d}</tt> for a decimal parameter. '
|
|
'Use syntax <tt>{name:l}</tt> for a parameter that should be '
|
|
'interpreted a a list of strings (comma-separated). ex.:</div>'
|
|
'<tt>adress LIKE (\'%\' || UPPER({adress}) || \'%\')</tt><br>'
|
|
'<tt>population < {population:d}</tt><br>'
|
|
'<tt>ID IN {ids:l}</tt> (with ids being "11,13,17")'
|
|
)
|
|
),
|
|
)
|
|
|
|
id_template = models.TextField(
|
|
verbose_name=_('Id template'),
|
|
validators=[validate_template],
|
|
help_text=_(
|
|
'Use Django\'s template syntax. Attributes can be accessed through {{ attributes.name }}'
|
|
),
|
|
blank=True,
|
|
)
|
|
|
|
text_template = models.TextField(
|
|
verbose_name=_('Text template'),
|
|
help_text=_(
|
|
'Use Django\'s template syntax. Attributes can be accessed through {{ attributes.name }}'
|
|
),
|
|
validators=[validate_template],
|
|
blank=True,
|
|
)
|
|
|
|
delete_view = 'arcgis-query-delete'
|
|
edit_view = 'arcgis-query-edit'
|
|
|
|
@property
|
|
def where_references(self):
|
|
if self.where:
|
|
return [
|
|
(ref, int if spec and spec[-1] == 'd' else str)
|
|
for _, ref, spec, _ in SqlFormatter().parse(self.where)
|
|
if ref is not None
|
|
]
|
|
else:
|
|
return []
|
|
|
|
def q(self, request, q=None, full=False, **kwargs):
|
|
kwargs.update(
|
|
{
|
|
'service': self.service,
|
|
}
|
|
)
|
|
if self.id_template:
|
|
kwargs['id_template'] = self.id_template
|
|
if self.text_template:
|
|
kwargs['template'] = self.text_template
|
|
if self.folder:
|
|
kwargs['folder'] = self.folder
|
|
if self.layer:
|
|
kwargs['layer'] = self.layer
|
|
if self.where:
|
|
format_kwargs = {key: request.GET.get(key, '') for key, klass in self.where_references}
|
|
formatter = SqlFormatter()
|
|
kwargs['where'] = formatter.format(self.where, **format_kwargs)
|
|
return self.resource.mapservice_query(request, q=q, full=full, **kwargs)
|
|
|
|
def as_endpoint(self):
|
|
endpoint = super().as_endpoint(path=self.resource.q.endpoint_info.name)
|
|
|
|
mapservice_endpoint = self.resource.mapservice_query.endpoint_info
|
|
endpoint.func = mapservice_endpoint.func
|
|
endpoint.show_undocumented_params = False
|
|
|
|
# Copy generic params descriptions from mapservice_query if they
|
|
# are not overloaded by the query
|
|
for param in mapservice_endpoint.parameters:
|
|
if param in ('folder', 'service', 'layer', 'id_template') and getattr(self, param):
|
|
continue
|
|
if param == 'template' and self.text_template:
|
|
continue
|
|
endpoint.parameters[param] = mapservice_endpoint.parameters[param]
|
|
|
|
for ref, klass in self.where_references:
|
|
endpoint.parameters[ref] = {
|
|
'type': 'integer' if klass is int else 'string',
|
|
'example_value': '',
|
|
}
|
|
return endpoint
|