0003-opengis-add-query-system-for-features-20535.patch
passerelle/apps/opengis/forms.py | ||
---|---|---|
1 |
# passerelle - uniform access to multiple data sources and services |
|
2 |
# Copyright (C) 2020 Entr'ouvert |
|
3 |
# |
|
4 |
# This program is free software: you can redistribute it and/or modify it |
|
5 |
# under the terms of the GNU Affero General Public License as published |
|
6 |
# by the Free Software Foundation, either version 3 of the License, or |
|
7 |
# (at your option) any later version. |
|
8 |
# |
|
9 |
# This program is distributed in the hope that it will be useful, |
|
10 |
# but WITHOUT ANY WARRANTY; without even the implied warranty of |
|
11 |
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
|
12 |
# GNU Affero General Public License for more details. |
|
13 |
# |
|
14 |
# You should have received a copy of the GNU Affero General Public License |
|
15 |
# along with this program. If not, see <http://www.gnu.org/licenses/>. |
|
16 | ||
17 |
from xml.etree import ElementTree as ET |
|
18 | ||
19 |
from django import forms |
|
20 |
from django.utils.translation import ugettext_lazy as _ |
|
21 | ||
22 |
from . import models |
|
23 | ||
24 | ||
25 |
class QueryForm(forms.ModelForm): |
|
26 |
class Meta: |
|
27 |
model = models.Query |
|
28 |
fields = '__all__' |
|
29 |
exclude = ['resource'] |
|
30 | ||
31 |
def clean_filter_expression(self): |
|
32 |
filter_expression = self.cleaned_data['filter_expression'] |
|
33 |
if filter_expression: |
|
34 |
try: |
|
35 |
ET.fromstring(filter_expression) |
|
36 |
except ET.ParseError: |
|
37 |
raise forms.ValidationError(_('Filter is not valid XML.')) |
|
38 |
return filter_expression |
passerelle/apps/opengis/migrations/0007_auto_20200401_1032.py | ||
---|---|---|
1 |
# -*- coding: utf-8 -*- |
|
2 |
# Generated by Django 1.11.18 on 2020-04-08 09:54 |
|
3 |
from __future__ import unicode_literals |
|
4 | ||
5 |
from django.db import migrations, models |
|
6 |
import django.db.models.deletion |
|
7 | ||
8 | ||
9 |
class Migration(migrations.Migration): |
|
10 | ||
11 |
dependencies = [ |
|
12 |
('opengis', '0006_auto_20181118_0807'), |
|
13 |
] |
|
14 | ||
15 |
operations = [ |
|
16 |
migrations.CreateModel( |
|
17 |
name='Query', |
|
18 |
fields=[ |
|
19 |
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), |
|
20 |
('name', models.CharField(max_length=128, verbose_name='Name')), |
|
21 |
('slug', models.SlugField(max_length=128, verbose_name='Slug')), |
|
22 |
('description', models.TextField(blank=True, verbose_name='Description')), |
|
23 |
('typename', models.CharField(max_length=256, verbose_name='Feature type')), |
|
24 |
('filter_expression', models.TextField(blank=True, verbose_name='XML filter')), |
|
25 |
], |
|
26 |
options={ |
|
27 |
'ordering': ['name'], |
|
28 |
'abstract': False, |
|
29 |
}, |
|
30 |
), |
|
31 |
migrations.AlterField( |
|
32 |
model_name='opengis', |
|
33 |
name='projection', |
|
34 |
field=models.CharField(choices=[('EPSG:2154', 'EPSG:2154 (Lambert-93)'), ('EPSG:3857', 'EPSG:3857 (WGS 84 / Pseudo-Mercator)'), ('EPSG:3945', 'EPSG:3945 (CC45)'), ('EPSG:4326', 'EPSG:4326 (WGS 84)')], default='EPSG:3857', max_length=16, verbose_name='GIS projection'), |
|
35 |
), |
|
36 |
migrations.AddField( |
|
37 |
model_name='query', |
|
38 |
name='resource', |
|
39 |
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='queries', to='opengis.OpenGIS', verbose_name='Resource'), |
|
40 |
), |
|
41 |
migrations.AlterUniqueTogether( |
|
42 |
name='query', |
|
43 |
unique_together=set([('resource', 'name'), ('resource', 'slug')]), |
|
44 |
), |
|
45 |
] |
passerelle/apps/opengis/models.py | ||
---|---|---|
24 | 24 |
from django.core.cache import cache |
25 | 25 |
from django.db import models |
26 | 26 |
from django.http import HttpResponse |
27 |
from django.shortcuts import get_object_or_404 |
|
28 |
from django.urls import reverse |
|
27 | 29 |
from django.utils.six.moves.html_parser import HTMLParser |
28 | 30 |
from django.utils.text import slugify |
29 | 31 |
from django.utils.translation import ugettext_lazy as _ |
30 | 32 | |
31 |
from passerelle.base.models import BaseResource |
|
33 |
from passerelle.base.models import BaseResource, BaseQuery
|
|
32 | 34 |
from passerelle.utils.api import endpoint |
33 | 35 |
from passerelle.utils.conversion import num2deg |
34 | 36 |
from passerelle.utils.jsonresponse import APIError |
... | ... | |
143 | 145 |
}) |
144 | 146 |
response.raise_for_status() |
145 | 147 | |
146 |
def build_get_features_params(self, typename=None, property_name=None, cql_filter=None): |
|
148 |
def build_get_features_params(self, typename=None, property_name=None, cql_filter=None, |
|
149 |
xml_filter=None): |
|
147 | 150 |
params = { |
148 | 151 |
'version': self.get_wfs_service_version(), |
149 | 152 |
'service': 'WFS', |
150 | 153 |
'request': 'GetFeature', |
151 | 154 |
self.get_typename_label(): typename, |
152 |
'propertyName': property_name, |
|
153 | 155 |
'outputFormat': self.get_output_format(), |
154 | 156 |
} |
157 |
if property_name: |
|
158 |
params['propertyName'] = property_name |
|
155 | 159 |
if cql_filter: |
156 | 160 |
params['cql_filter'] = cql_filter |
161 |
if xml_filter: |
|
162 |
params['filter'] = xml_filter |
|
157 | 163 |
return params |
158 | 164 | |
159 | 165 |
@endpoint(perm='can_access', description='Get features', |
... | ... | |
180 | 186 |
}, |
181 | 187 |
'case_insensitive': { |
182 | 188 |
'description': _('Enables case-insensitive search'), |
189 |
}, |
|
190 |
'xml_filter': { |
|
191 |
'description': _('Filter applied to the query'), |
|
192 |
'example_value': '<Filter><PropertyIsEqualTo><PropertyName>typeparking' |
|
193 |
'</PropertyName></PropertyIsEqualTo></Filter>' |
|
183 | 194 |
} |
184 | 195 |
}) |
185 | 196 |
def features(self, request, type_names, property_name, cql_filter=None, |
186 |
filter_property_name=None, q=None, case_insensitive=False, **kwargs): |
|
197 |
filter_property_name=None, q=None, case_insensitive=False, |
|
198 |
xml_filter=None, **kwargs): |
|
187 | 199 |
if cql_filter: |
188 | 200 |
if filter_property_name and q: |
189 | 201 |
if 'case-insensitive' in kwargs or case_insensitive: |
... | ... | |
191 | 203 |
else: |
192 | 204 |
operator = 'LIKE' |
193 | 205 |
cql_filter += ' AND %s %s \'%%%s%%\'' % (filter_property_name, operator, q) |
194 |
params = self.build_get_features_params(type_names, property_name, cql_filter) |
|
206 |
params = self.build_get_features_params(type_names, property_name, cql_filter, xml_filter)
|
|
195 | 207 |
response = self.requests.get(self.wfs_service_url, params=params) |
196 | 208 |
data = [] |
197 | 209 |
try: |
198 |
response.json() |
|
210 |
response = response.json()
|
|
199 | 211 |
except ValueError: |
200 | 212 |
self.handle_opengis_error(response) |
201 | 213 |
# if handle_opengis_error did not raise an error, we raise a generic one |
202 | 214 |
raise APIError(u'OpenGIS Error: unparsable error', |
203 | 215 |
data={'content': repr(response.content[:1024])}) |
204 |
for feature in response.json()['features']:
|
|
216 |
for feature in response['features']: |
|
205 | 217 |
feature['text'] = feature['properties'].get(property_name) |
206 | 218 |
data.append(feature) |
207 | 219 |
return {'data': data} |
... | ... | |
356 | 368 |
break |
357 | 369 |
return result |
358 | 370 |
raise APIError('Unable to geocode') |
371 | ||
372 |
@endpoint(name='query', |
|
373 |
description=_('Query'), |
|
374 |
pattern=r'^(?P<query_slug>[\w:_-]+)/$', |
|
375 |
perm='can_access', |
|
376 |
show=False) |
|
377 |
def query(self, request, query_slug): |
|
378 |
query = get_object_or_404(Query, resource=self, slug=query_slug) |
|
379 |
return query.q(request) |
|
380 | ||
381 |
def export_json(self): |
|
382 |
d = super(OpenGIS, self).export_json() |
|
383 |
d['queries'] = [query.export_json() for query in self.queries.all()] |
|
384 |
return d |
|
385 | ||
386 |
@classmethod |
|
387 |
def import_json_real(cls, overwrite, instance, d, **kwargs): |
|
388 |
queries = d.pop('queries', []) |
|
389 |
instance = super(OpenGIS, cls).import_json_real(overwrite, instance, d, **kwargs) |
|
390 |
new = [] |
|
391 |
if instance and overwrite: |
|
392 |
Query.objects.filter(resource=instance).delete() |
|
393 |
for query in queries: |
|
394 |
q = Query.import_json(query) |
|
395 |
q.resource = instance |
|
396 |
new.append(q) |
|
397 |
Query.objects.bulk_create(new) |
|
398 |
return instance |
|
399 | ||
400 |
def create_query_url(self): |
|
401 |
return reverse('opengis-query-new', kwargs={'slug': self.slug}) |
|
402 | ||
403 | ||
404 |
class Query(BaseQuery): |
|
405 |
resource = models.ForeignKey( |
|
406 |
to=OpenGIS, |
|
407 |
on_delete=models.CASCADE, |
|
408 |
related_name='queries', |
|
409 |
verbose_name=_('Resource')) |
|
410 | ||
411 |
typename = models.CharField(_('Feature type'), max_length=256) |
|
412 |
filter_expression = models.TextField(_('XML filter'), blank=True) |
|
413 | ||
414 |
delete_view = 'opengis-query-delete' |
|
415 |
edit_view = 'opengis-query-edit' |
|
416 | ||
417 |
def as_endpoint(self): |
|
418 |
resource_endpoint = self.resource.query.endpoint_info |
|
419 |
endpoint = super(Query, self).as_endpoint(path=resource_endpoint.name) |
|
420 |
# use parameters added by resource endpoint |
|
421 |
endpoint.func = resource_endpoint.func |
|
422 |
endpoint.show_undocumented_params = False |
|
423 |
endpoint.parameters = resource_endpoint.parameters |
|
424 |
return endpoint |
|
425 | ||
426 |
def q(self, request): |
|
427 |
return self.resource.features(request, self.typename, property_name=None, |
|
428 |
xml_filter=self.filter_expression) |
passerelle/apps/opengis/templates/opengis/query_confirm_delete.html | ||
---|---|---|
1 |
{% extends "passerelle/manage/resource_child_confirm_delete.html" %} |
passerelle/apps/opengis/templates/opengis/query_form.html | ||
---|---|---|
1 |
{% extends "passerelle/manage/resource_child_form.html" %} |
passerelle/apps/opengis/urls.py | ||
---|---|---|
1 |
# passerelle - uniform access to multiple data sources and services |
|
2 |
# Copyright (C) 2020 Entr'ouvert |
|
3 |
# |
|
4 |
# This program is free software: you can redistribute it and/or modify it |
|
5 |
# under the terms of the GNU Affero General Public License as published |
|
6 |
# by the Free Software Foundation, either version 3 of the License, or |
|
7 |
# (at your option) any later version. |
|
8 |
# |
|
9 |
# This program is distributed in the hope that it will be useful, |
|
10 |
# but WITHOUT ANY WARRANTY; without even the implied warranty of |
|
11 |
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
|
12 |
# GNU Affero General Public License for more details. |
|
13 |
# |
|
14 |
# You should have received a copy of the GNU Affero General Public License |
|
15 |
# along with this program. If not, see <http://www.gnu.org/licenses/>. |
|
16 | ||
17 |
from django.conf.urls import url |
|
18 | ||
19 |
from . import views |
|
20 | ||
21 |
management_urlpatterns = [ |
|
22 |
url(r'^(?P<slug>[\w,-]+)/query/new/$', |
|
23 |
views.QueryNew.as_view(), name='opengis-query-new'), |
|
24 |
url(r'^(?P<slug>[\w,-]+)/query/(?P<pk>\d+)/$', |
|
25 |
views.QueryEdit.as_view(), name='opengis-query-edit'), |
|
26 |
url(r'^(?P<slug>[\w,-]+)/query/(?P<pk>\d+)/delete/$', |
|
27 |
views.QueryDelete.as_view(), name='opengis-query-delete'), |
|
28 |
] |
passerelle/apps/opengis/views.py | ||
---|---|---|
1 |
# passerelle - uniform access to multiple data sources and services |
|
2 |
# Copyright (C) 2020 Entr'ouvert |
|
3 |
# |
|
4 |
# This program is free software: you can redistribute it and/or modify it |
|
5 |
# under the terms of the GNU Affero General Public License as published |
|
6 |
# by the Free Software Foundation, either version 3 of the License, or |
|
7 |
# (at your option) any later version. |
|
8 |
# |
|
9 |
# This program is distributed in the hope that it will be useful, |
|
10 |
# but WITHOUT ANY WARRANTY; without even the implied warranty of |
|
11 |
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
|
12 |
# GNU Affero General Public License for more details. |
|
13 |
# |
|
14 |
# You should have received a copy of the GNU Affero General Public License |
|
15 |
# along with this program. If not, see <http://www.gnu.org/licenses/>. |
|
16 | ||
17 |
from django.views.generic import UpdateView, CreateView, DeleteView |
|
18 | ||
19 |
from passerelle.base.mixins import ResourceChildViewMixin |
|
20 | ||
21 |
from . import models |
|
22 |
from .forms import QueryForm |
|
23 | ||
24 | ||
25 |
class QueryNew(ResourceChildViewMixin, CreateView): |
|
26 |
model = models.Query |
|
27 |
form_class = QueryForm |
|
28 | ||
29 |
def form_valid(self, form): |
|
30 |
form.instance.resource = self.resource |
|
31 |
return super(QueryNew, self).form_valid(form) |
|
32 | ||
33 |
def get_changed_url(self): |
|
34 |
return self.object.get_absolute_url() |
|
35 | ||
36 | ||
37 |
class QueryEdit(ResourceChildViewMixin, UpdateView): |
|
38 |
model = models.Query |
|
39 |
form_class = QueryForm |
|
40 | ||
41 | ||
42 |
class QueryDelete(ResourceChildViewMixin, DeleteView): |
|
43 |
model = models.Query |
|
0 |
- |