Skip to content

Commit b48c241

Browse files
committed
initial commit
(based on SO!MAP implementation of FeatureInfo service)
0 parents  commit b48c241

10 files changed

+803
-0
lines changed

.gitignore

+2
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
.venv/
2+
__pycache__/

README.md

+52
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
FeatureInfo Service
2+
===================
3+
4+
Query layers at a geographic position using an API based on WMS GetFeatureInfo.
5+
6+
The query is handled for each layer by its layer info provider configured in the config file.
7+
8+
Layer info providers:
9+
10+
* WMS GetFeatureInfo: forward info request to the QGIS Server
11+
12+
The info results are each rendered into customizable HTML templates and returned as a GetFeatureInfoResponse XML.
13+
14+
15+
Usage
16+
-----
17+
18+
Base URL:
19+
20+
http://localhost:5015/
21+
22+
Service API:
23+
24+
http://localhost:5015/api/
25+
26+
Sample request:
27+
28+
curl 'http://localhost:5015/qwc_demo?layers=countries,edit_lines&i=51&j=51&height=101&width=101&bbox=671639%2C5694018%2C1244689%2C6267068&crs=EPSG%3A3857'
29+
30+
31+
Development
32+
-----------
33+
34+
Create a virtual environment:
35+
36+
virtualenv --python=/usr/bin/python3 --system-site-packages .venv
37+
38+
Without system packages:
39+
40+
virtualenv --python=/usr/bin/python3 .venv
41+
42+
Activate virtual environment:
43+
44+
source .venv/bin/activate
45+
46+
Install requirements:
47+
48+
pip install -r requirements.txt
49+
50+
Start local service:
51+
52+
python server.py

feature_info_service.py

+275
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,275 @@
1+
import html
2+
import re
3+
from xml.dom.minidom import Document, Element, Text
4+
5+
from jinja2 import Template, TemplateError, TemplateSyntaxError
6+
7+
from info_modules.wms import layer_info as wms_layer_info
8+
from info_templates import default_info_template, layer_template
9+
10+
11+
class InfoFeature(object):
12+
"""InfoFeature class for dynamic properties"""
13+
def __init__(self):
14+
self._attributes = []
15+
16+
def add(self, name, value, alias, json_aliases):
17+
"""Add attribute and value.
18+
19+
:param str name: Attribute name
20+
:param obj value: Attribute value
21+
:param str alias: Attribute alias
22+
:param obj json_aliases: JSON attributes config
23+
"""
24+
# set attribute as class property
25+
setattr(self, name, value)
26+
# add to ordered attribute list
27+
self._attributes.append({
28+
'name': name,
29+
'value': value,
30+
'alias': alias,
31+
'type': type(value).__name__,
32+
'json_aliases': json_aliases
33+
})
34+
35+
36+
class FeatureInfoService():
37+
"""FeatureInfoService class
38+
39+
Query layers at a geographic position using different layer info providers.
40+
"""
41+
42+
def __init__(self, tenant, logger):
43+
"""Constructor
44+
45+
:param str tenant: Tenant ID
46+
:param Logger logger: Application logger
47+
"""
48+
self.tenant = tenant
49+
self.logger = logger
50+
51+
def query(self, identity, mapid, layers, params):
52+
"""Query layers and return info result as XML.
53+
54+
:param str identity: User identity
55+
:param str mapid: Map ID
56+
:param list(str): List of query layer names
57+
:param obj params: FeatureInfo service params
58+
"""
59+
60+
# calculate query coordinates and resolutions
61+
try:
62+
bbox = list(map(float, params["bbox"].split(",")))
63+
x = 0.5 * (bbox[0] + bbox[2])
64+
y = 0.5 * (bbox[1] + bbox[3])
65+
xres = (bbox[2] - bbox[0]) / params['width']
66+
yres = (bbox[3] - bbox[1]) / params['height']
67+
except Exception as e:
68+
x = 0
69+
y = 0
70+
xres = 0
71+
yres = 0
72+
73+
params['resolution'] = max(xres, yres)
74+
crs = params['crs']
75+
76+
# TODO: filter layers by permissions
77+
78+
# collect layer infos
79+
layer_infos = []
80+
for layer in layers:
81+
info = self.get_layer_info(
82+
identity, mapid, layer, x, y, crs, params
83+
)
84+
if info is not None:
85+
layer_infos.append(info)
86+
87+
info_xml = (
88+
"<GetFeatureInfoResponse>%s</GetFeatureInfoResponse>" %
89+
''.join(layer_infos)
90+
)
91+
return info_xml
92+
93+
def get_layer_info(self, identity, mapid, layer, x, y, crs, params):
94+
"""Get info for a layer rendered as info template.
95+
96+
:param str identity: User identity
97+
:param str mapid: Map ID
98+
:param str layer: Layer name
99+
:param float x: X coordinate of query
100+
:param float y: Y coordinate of query
101+
:param str crs: CRS of query coordinates
102+
:param obj params: FeatureInfo service params
103+
"""
104+
105+
# TODO: from config
106+
# TODO: filter by permissions
107+
layer_title = None
108+
info_template = None
109+
display_field = None
110+
feature_report = None
111+
parent_facade = None
112+
permitted_attributes = ['name', 'formal_en', 'pop_est', 'subregion']
113+
attribute_aliases = {}
114+
attribute_formats = {}
115+
json_attribute_aliases = {}
116+
117+
if info_template is None:
118+
self.logger.warning("No info template for layer '%s'" % layer)
119+
# fallback to WMS GetFeatureInfo with default info template
120+
info_template = {
121+
'template': default_info_template,
122+
'type': 'wms'
123+
}
124+
125+
info = None
126+
error_msg = None
127+
128+
# TODO: other info types
129+
info_type = info_template.get('type')
130+
if info_type == 'wms':
131+
# WMS GetFeatureInfo
132+
info = wms_layer_info(
133+
layer, x, y, crs, params, identity, mapid,
134+
permitted_attributes, attribute_aliases, attribute_formats,
135+
self.logger
136+
)
137+
138+
if info is None or not isinstance(info, dict):
139+
# info result failed or not a dict
140+
return None
141+
142+
if info.get('error'):
143+
# render layer template with error message
144+
error_html = (
145+
'<span class="info_error" style="color: red">%s</span>' %
146+
info.get('error')
147+
)
148+
features = [{
149+
'html_content': self.html_content(error_html)
150+
}]
151+
return layer_template.render(
152+
layer_name=layer, layer_title=layer_title,
153+
features=features, parent_facade=parent_facade
154+
)
155+
156+
if not info.get('features'):
157+
# info result is empty
158+
return layer_template.render(
159+
layer_name=layer, layer_title=layer_title,
160+
parent_facade=parent_facade
161+
)
162+
163+
template = info_template.get('template')
164+
165+
# lookup for attribute aliases from attribute names
166+
attribute_alias_lookup = {}
167+
for alias in attribute_aliases:
168+
attribute_alias_lookup[attribute_aliases.get(alias)] = alias
169+
170+
features = []
171+
for feature in info.get('features'):
172+
# create info feature with attributes
173+
info_feature = InfoFeature()
174+
for attr in feature.get('attributes', []):
175+
name = attr.get('name')
176+
value = self.parse_value(attr.get('value'))
177+
alias = attribute_alias_lookup.get(name, name)
178+
json_aliases = json_attribute_aliases.get(name)
179+
info_feature.add(name, value, alias, json_aliases)
180+
181+
fid = feature.get('id')
182+
bbox = feature.get('bbox')
183+
geometry = feature.get('geometry')
184+
185+
info_html = None
186+
try:
187+
# render feature template
188+
feature_template = Template(template)
189+
info_html = feature_template.render(
190+
feature=info_feature, fid=fid, bbox=bbox,
191+
geometry=geometry, layer=layer, x=x, y=y, crs=crs,
192+
render_value=self.render_value
193+
)
194+
except TemplateSyntaxError as e:
195+
error_msg = (
196+
"TemplateSyntaxError on line %d: %s" % (e.lineno, e)
197+
)
198+
except TemplateError as e:
199+
error_msg = "TemplateError: %s" % e
200+
if error_msg is not None:
201+
self.logger.error(error_msg)
202+
info_html = (
203+
'<span class="info_error" style="color: red">%s</span>' %
204+
error_msg
205+
)
206+
207+
features.append({
208+
'fid': fid,
209+
'html_content': self.html_content(info_html),
210+
'bbox': bbox,
211+
'wkt_geom': geometry,
212+
'attributes': info_feature._attributes
213+
})
214+
215+
# render layer template
216+
return layer_template.render(
217+
layer_name=layer, layer_title=layer_title, crs=crs,
218+
features=features,
219+
display_field=display_field,
220+
feature_report=feature_report,
221+
parent_facade=parent_facade
222+
)
223+
224+
def parse_value(self, value):
225+
"""Parse info result value and convert to dict if JSON.
226+
227+
:param obj value: Info value
228+
"""
229+
if isinstance(value, str):
230+
try:
231+
if value.startswith('{') or value.startswith('['):
232+
# parse JSON with original order of keys
233+
value = json.loads(value, object_pairs_hook=OrderedDict)
234+
except Exception as e:
235+
self.logger.error(
236+
"Could not parse value as JSON: '%s'\n%s" % (value, e)
237+
)
238+
239+
return value
240+
241+
def render_value(self, value, htmlEscape=True):
242+
"""Escape HTML special characters if requested, and detect
243+
special value formats in info result values and reformat them.
244+
245+
:param obj value: Info value
246+
:param bool htmlEscape: Whether to HTML escape the value
247+
"""
248+
if isinstance(value, str):
249+
if htmlEscape:
250+
value = html.escape(value)
251+
rules = [(
252+
# HTML links
253+
r'^(https?:\/\/.*)$',
254+
lambda m: m.expand(r'<a href="\1" target="_blank">Link</a>')
255+
)]
256+
for rule in rules:
257+
match = re.match(rule[0], value)
258+
if match:
259+
value = rule[1](match)
260+
break
261+
262+
return value
263+
264+
def html_content(self, info_html):
265+
"""Return <HtmlContent> tag with escaped HTML.
266+
267+
:param str info_html: Info HTML
268+
"""
269+
doc = Document()
270+
el = doc.createElement('HtmlContent')
271+
el.setAttribute('inline', '1')
272+
text = Text()
273+
text.data = info_html
274+
el.appendChild(text)
275+
return el.toxml()

info_modules/__init__.py

Whitespace-only changes.

info_modules/wms/__init__.py

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
from .layer_info import layer_info

0 commit comments

Comments
 (0)