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 |
widgets = { |
|
29 |
'filter_expression': forms.Textarea(), |
|
30 |
} |
|
31 |
fields = '__all__' |
|
32 |
exclude = ['resource'] |
|
33 | ||
34 |
def clean_filter_expression(self): |
|
35 |
filter_expression = self.cleaned_data['filter_expression'] |
|
36 |
try: |
|
37 |
ET.fromstring(filter_expression) |
|
38 |
except ET.ParseError: |
|
39 |
raise forms.ValidationError(_('Filter is not valid XML.')) |
|
40 |
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-01 08:32 |
|
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(blank=True, max_length=256, verbose_name='Feature type')), |
|
24 |
('filter_expression', models.CharField(blank=True, max_length=4096, 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 | ||
---|---|---|
14 | 14 |
# You should have received a copy of the GNU Affero General Public License |
15 | 15 |
# along with this program. If not, see <http://www.gnu.org/licenses/>. |
16 | 16 | |
17 |
import copy |
|
17 | 18 |
import math |
18 | 19 |
import xml.etree.ElementTree as ET |
19 | 20 | |
... | ... | |
24 | 25 |
from django.core.cache import cache |
25 | 26 |
from django.db import models |
26 | 27 |
from django.http import HttpResponse |
28 |
from django.shortcuts import get_object_or_404 |
|
29 |
from django.urls import reverse |
|
27 | 30 |
from django.utils.six.moves.html_parser import HTMLParser |
28 | 31 |
from django.utils.text import slugify |
29 | 32 |
from django.utils.translation import ugettext_lazy as _ |
30 | 33 | |
31 |
from passerelle.base.models import BaseResource |
|
34 |
from passerelle.base.models import BaseResource, BaseQuery
|
|
32 | 35 |
from passerelle.utils.api import endpoint |
33 | 36 |
from passerelle.utils.conversion import num2deg |
34 | 37 |
from passerelle.utils.jsonresponse import APIError |
... | ... | |
143 | 146 |
}) |
144 | 147 |
response.raise_for_status() |
145 | 148 | |
146 |
def build_get_features_params(self, typename=None, property_name=None, cql_filter=None): |
|
149 |
def build_get_features_params(self, typename=None, property_name=None, cql_filter=None, |
|
150 |
xml_filter=None): |
|
147 | 151 |
params = { |
148 | 152 |
'version': self.get_wfs_service_version(), |
149 | 153 |
'service': 'WFS', |
150 | 154 |
'request': 'GetFeature', |
151 | 155 |
self.get_typename_label(): typename, |
152 |
'propertyName': property_name, |
|
153 | 156 |
'outputFormat': self.get_output_format(), |
154 | 157 |
} |
158 |
if property_name: |
|
159 |
params['propertyName'] = property_name |
|
155 | 160 |
if cql_filter: |
156 | 161 |
params['cql_filter'] = cql_filter |
162 |
if xml_filter: |
|
163 |
params['filter'] = xml_filter |
|
157 | 164 |
return params |
158 | 165 | |
159 | 166 |
@endpoint(perm='can_access', description='Get features', |
... | ... | |
180 | 187 |
}, |
181 | 188 |
'case_insensitive': { |
182 | 189 |
'description': _('Enables case-insensitive search'), |
190 |
}, |
|
191 |
'xml_filter': { |
|
192 |
'description': _('Filter applied to the query'), |
|
193 |
'example_value': '<Filter><PropertyIsEqualTo><PropertyName>typeparking' |
|
194 |
'</PropertyName></PropertyIsEqualTo></Filter>' |
|
183 | 195 |
} |
184 | 196 |
}) |
185 | 197 |
def features(self, request, type_names, property_name, cql_filter=None, |
186 |
filter_property_name=None, q=None, case_insensitive=False, **kwargs): |
|
198 |
filter_property_name=None, q=None, case_insensitive=False, |
|
199 |
xml_filter=None, **kwargs): |
|
187 | 200 |
if cql_filter: |
188 | 201 |
if filter_property_name and q: |
189 | 202 |
if 'case-insensitive' in kwargs or case_insensitive: |
... | ... | |
191 | 204 |
else: |
192 | 205 |
operator = 'LIKE' |
193 | 206 |
cql_filter += ' AND %s %s \'%%%s%%\'' % (filter_property_name, operator, q) |
194 |
params = self.build_get_features_params(type_names, property_name, cql_filter) |
|
207 |
params = self.build_get_features_params(type_names, property_name, cql_filter, xml_filter)
|
|
195 | 208 |
response = self.requests.get(self.wfs_service_url, params=params) |
196 | 209 |
data = [] |
197 | 210 |
try: |
198 |
response.json() |
|
211 |
response = response.json()
|
|
199 | 212 |
except ValueError: |
200 | 213 |
self.handle_opengis_error(response) |
201 | 214 |
# if handle_opengis_error did not raise an error, we raise a generic one |
202 | 215 |
raise APIError(u'OpenGIS Error: unparsable error', |
203 | 216 |
data={'content': repr(response.content[:1024])}) |
204 |
for feature in response.json()['features']:
|
|
217 |
for feature in response['features']: |
|
205 | 218 |
feature['text'] = feature['properties'].get(property_name) |
206 | 219 |
data.append(feature) |
207 | 220 |
return {'data': data} |
... | ... | |
356 | 369 |
break |
357 | 370 |
return result |
358 | 371 |
raise APIError('Unable to geocode') |
372 | ||
373 |
@endpoint(name='query', |
|
374 |
description=_('Query'), |
|
375 |
pattern=r'^(?P<query_slug>[\w:_-]+)/$', |
|
376 |
perm='can_access', |
|
377 |
show=False) |
|
378 |
def query(self, request, query_slug): |
|
379 |
query = get_object_or_404(Query, resource=self, slug=query_slug) |
|
380 |
return query.q(request) |
|
381 | ||
382 |
def export_json(self): |
|
383 |
d = super(OpenGIS, self).export_json() |
|
384 |
d['queries'] = [query.export_json() for query in self.queries.all()] |
|
385 |
return d |
|
386 | ||
387 |
@classmethod |
|
388 |
def import_json_real(cls, overwrite, instance, d, **kwargs): |
|
389 |
queries = d.pop('queries', []) |
|
390 |
instance = super(OpenGIS, cls).import_json_real(overwrite, instance, d, **kwargs) |
|
391 |
new = [] |
|
392 |
if instance and overwrite: |
|
393 |
Query.objects.filter(resource=instance).delete() |
|
394 |
for query in queries: |
|
395 |
q = Query.import_json(query) |
|
396 |
q.resource = instance |
|
397 |
new.append(q) |
|
398 |
Query.objects.bulk_create(new) |
|
399 |
return instance |
|
400 | ||
401 |
def create_query_url(self): |
|
402 |
return reverse('opengis-query-new', kwargs={'slug': self.slug}) |
|
403 | ||
404 | ||
405 |
class Query(BaseQuery): |
|
406 |
resource = models.ForeignKey( |
|
407 |
to=OpenGIS, |
|
408 |
on_delete=models.CASCADE, |
|
409 |
related_name='queries', |
|
410 |
verbose_name=_('Resource')) |
|
411 | ||
412 |
typename = models.CharField( |
|
413 |
verbose_name=_('Feature type'), |
|
414 |
max_length=256, |
|
415 |
blank=True) |
|
416 |
filter_expression = models.CharField( |
|
417 |
verbose_name=_('XML filter'), |
|
418 |
max_length=4096, |
|
419 |
blank=True) |
|
420 | ||
421 |
delete_view = 'opengis-query-delete' |
|
422 |
edit_view = 'opengis-query-edit' |
|
423 | ||
424 |
def as_endpoint(self): |
|
425 |
resource_endpoint = self.resource.query.endpoint_info |
|
426 |
endpoint = super(Query, self).as_endpoint(path=resource_endpoint.name) |
|
427 |
# use parameters added by resource endpoint |
|
428 |
endpoint.func = resource_endpoint.func |
|
429 |
endpoint.show_undocumented_params = False |
|
430 |
endpoint.parameters = copy.deepcopy(resource_endpoint.parameters) |
|
431 |
return endpoint |
|
432 | ||
433 |
def q(self, request): |
|
434 |
return self.resource.features(request, self.typename, property_name=None, |
|
435 |
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 |
- |