From 6043bc1947a108f60caaa63779edcc9e0903183d Mon Sep 17 00:00:00 2001 From: Valentin Deniaud Date: Tue, 24 Mar 2020 16:39:37 +0100 Subject: [PATCH 3/6] base: add generic BaseQuery (#20535) --- passerelle/apps/arcgis/models.py | 136 ++++-------------- .../templates/arcgis/arcgis_detail.html | 16 --- passerelle/base/models.py | 85 +++++++++++ passerelle/utils/api.py | 12 +- tests/test_arcgis.py | 10 +- 5 files changed, 129 insertions(+), 130 deletions(-) delete mode 100644 passerelle/apps/arcgis/templates/arcgis/arcgis_detail.html diff --git a/passerelle/apps/arcgis/models.py b/passerelle/apps/arcgis/models.py index ae0404df..96d42bab 100644 --- a/passerelle/apps/arcgis/models.py +++ b/passerelle/apps/arcgis/models.py @@ -29,7 +29,7 @@ from django.utils.html import mark_safe, format_html from passerelle.utils.jsonresponse import APIError from passerelle.utils.api import endpoint from passerelle.utils.templates import render_to_string, validate_template -from passerelle.base.models import BaseResource, HTTPResource +from passerelle.base.models import BaseResource, HTTPResource, BaseQuery class ArcGISError(APIError): @@ -195,13 +195,6 @@ class ArcGIS(BaseResource, HTTPResource): description=_('Query'), pattern=r'^(?P[\w:_-]+)/$', perm='can_access', - parameters={ - 'q': {'description': _('Search text in display field')}, - 'full': { - 'description': _('Returns all ArcGIS informations (geometry, metadata)'), - 'type': 'bool', - }, - }, show=False) def q(self, request, query_slug, q=None, full=False, **kwargs): query = get_object_or_404(Query, resource=self, slug=query_slug) @@ -226,8 +219,8 @@ class ArcGIS(BaseResource, HTTPResource): if instance and overwrite: Query.objects.filter(resource=instance).delete() for query in queries: + query['resource'] = instance q = Query.import_json(query) - q.resource = instance new.append(q) Query.objects.bulk_create(new) return instance @@ -257,22 +250,11 @@ def validate_where(format_string): raise ValidationError(_('invalid reference')) -@six.python_2_unicode_compatible -class Query(models.Model): +class Query(BaseQuery): resource = models.ForeignKey( to=ArcGIS, verbose_name=_('Resource')) - name = models.CharField( - verbose_name=_('Name'), - max_length=128) - slug = models.SlugField( - verbose_name=_('Slug'), - max_length=128) - description = models.TextField( - verbose_name=_('Description'), - blank=True) - folder = models.CharField( verbose_name=_('ArcGis Folder'), max_length=64, @@ -308,12 +290,10 @@ class Query(models.Model): validators=[validate_template], blank=True) - class Meta: - unique_together = [ - ('resource', 'name'), - ('resource', 'slug'), - ] - ordering = ['name'] + def __init__(self, *args, **kwargs): + super(Query, self).__init__(*args, **kwargs) + self.endpoint = self.resource.mapservice_query + self.called_from = self.resource.q @property def where_references(self): @@ -339,93 +319,29 @@ class Query(models.Model): 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) - - class QueryEndpoint: - http_method = 'get' - - def __init__(self, query): - self.object = query.resource - self.query = query - self.name = 'q/%s/' % query.slug - - @property - def description(self): - return self.query.name - - @property - def long_description(self): - return self.query.description - - def example_url(self): - kwargs = { - 'connector': self.object.get_connector_slug(), - 'slug': self.object.slug, - 'endpoint': self.name, - } - query_string = '' - if self.query.where_references: - query_string = '?' + '&'.join( - ['%s=%s' % (ref, '') for ref, _ in self.query.where_references + [('q', ''), ('full', '')]]) - return reverse('generic-endpoint', kwargs=kwargs) + query_string - - def example_url_as_html(self): - kwargs = { - 'connector': self.object.get_connector_slug(), - 'slug': self.object.slug, - 'endpoint': self.name, - } - query_string = '' - if self.query.where_references: - query_string = '?' + '&'.join( - [format_html('{0}={1}', ref, ref) - for ref, klass in self.query.where_references + [('q', ''), ('full', '')]]) - return mark_safe(reverse('generic-endpoint', kwargs=kwargs) + query_string) - - def get_params(self): - params = [] - for ref, klass in self.query.where_references: - params.append({ - 'name': ref, - 'type': 'integer' if klass is int else 'string', - }) - - # Copy generic params descriptions from mapservice_query if they - # are not overloaded by the query - for param in self.object.mapservice_query.endpoint_info.get_params(): - if (param['name'] in ('folder', 'service', 'layer', 'id_template') - and getattr(self.query, param['name'])): - continue - if param['name'] == 'template' and self.query.text_template: - continue - params.append(param) - - return params + return self.endpoint(request, q=q, full=full, **kwargs) - @property - def endpoint(self): - return self.QueryEndpoint(self) + def as_endpoint(self): + endpoint = super(Query, self).as_endpoint() + endpoint.exclude_params = [param for param in ('folder', 'service', 'layer', 'id_template') + if getattr(self, param)] + endpoint.exclude_params.extend(['lat', 'lon', 'latmin', 'lonmin', 'latmax', 'lonmax']) + if self.text_template: + endpoint.exclude_params.append('template') - def __str__(self): - return self.name + if not self.where_references: + return endpoint - def export_json(self): - d = {} - fields = [ - f for f in self.__class__._meta.get_fields() - if f.concrete and ( - not f.is_relation - or f.one_to_one - or (f.many_to_one and f.related_model) - ) and f.name not in ['id', 'resource'] - ] - for field in fields: - d[field.name] = getattr(self, field.name) - return d + for ref, klass in self.where_references: + endpoint.extra_params.append({ + 'name': ref, + 'type': 'integer' if klass is int else 'string', + }) - @classmethod - def import_json(cls, d): - return cls(**d) + params = [ref for ref, _ in self.where_references] + params = {param: {'example_value': ''} for param in params} + endpoint.parameters.update(params) + return endpoint def delete_url(self): return reverse('arcgis-query-delete', diff --git a/passerelle/apps/arcgis/templates/arcgis/arcgis_detail.html b/passerelle/apps/arcgis/templates/arcgis/arcgis_detail.html deleted file mode 100644 index 18af32dc..00000000 --- a/passerelle/apps/arcgis/templates/arcgis/arcgis_detail.html +++ /dev/null @@ -1,16 +0,0 @@ -{% extends "passerelle/manage/service_view.html" %} -{% load i18n passerelle %} - -{% block endpoints %} -{{ block.super }} - - -{% if object.query_set.exists %} -

{% trans "Custom queries" %}

- -{% endif %} -{% endblock %} diff --git a/passerelle/base/models.py b/passerelle/base/models.py index 3e56dc23..3e542c00 100644 --- a/passerelle/base/models.py +++ b/passerelle/base/models.py @@ -256,8 +256,15 @@ class BaseResource(models.Model): endpoint_info.http_method = http_method endpoints.append(endpoint_info) endpoints.sort(key=lambda x: (x.name or '', x.pattern or '')) + if hasattr(self, 'query_set'): + self.append_custom_queries(endpoints) return endpoints + def append_custom_queries(self, endpoints): + for query in self.query_set.all(): + if hasattr(query, 'as_endpoint'): + endpoints.append(query.as_endpoint()) + def get_connector_permissions(self): perms = {} for endpoint_info in self.get_endpoints_infos(): @@ -955,3 +962,81 @@ class SMSResource(BaseResource): class Meta: abstract = True + + +@six.python_2_unicode_compatible +class BaseQuery(models.Model): + '''Base for building custom queries. + + A query stores parameters, which are then used to call an endpoint. + It must define "resource" attribute as a ForeignKey to a BaseResource subclass, + as well as "endpoint" which should be the endpoint to call, and "called_from" + which should be the endpoint that triggers the query. + ''' + + name = models.CharField( + verbose_name=_('Name'), + max_length=128) + slug = models.SlugField( + verbose_name=_('Slug'), + max_length=128) + description = models.TextField( + verbose_name=_('Description'), + blank=True) + + http_method = 'get' + + class Meta: + abstract = True + unique_together = [ + ('resource', 'name'), + ('resource', 'slug'), + ] + ordering = ['name'] + + def as_endpoint(self): + # deepcopying is too dangerous, instead copy then deepcopy only relevant attributes + endpoint = copy.copy(self.endpoint.endpoint_info) + attr_to_change = ['descriptions', 'long_descriptions', 'parameters', 'extra_params'] + for attr in attr_to_change: + setattr(endpoint, attr, copy.deepcopy(getattr(endpoint, attr))) + endpoint.name = '%s/%s/' % (self.called_from.endpoint_info.name, self.slug) + endpoint.http_method = self.http_method + endpoint.descriptions[self.http_method] = self.name + endpoint.long_descriptions[self.http_method] = self.description + new_params = self.called_from.endpoint_info.parameters + endpoint.parameters.update(new_params) + for param, info in new_params.items(): + d = {'name': param} + d.update(info) + endpoint.extra_params.append(d) + return endpoint + + def __str__(self): + return self.name + + def export_json(self): + d = {} + fields = [ + f for f in self.__class__._meta.get_fields() + if f.concrete and ( + not f.is_relation + or f.one_to_one + or (f.many_to_one and f.related_model) + ) and f.name not in ['id', 'resource'] + ] + for field in fields: + d[field.name] = getattr(self, field.name) + return d + + @classmethod + def import_json(cls, d): + return cls(**d) + + def delete_url(self): + return reverse(self.delete_view, + kwargs={'slug': self.resource.slug, 'pk': self.pk}) + + def edit_url(self): + return reverse(self.edit_view, + kwargs={'slug': self.resource.slug, 'pk': self.pk}) diff --git a/passerelle/utils/api.py b/passerelle/utils/api.py index 3a3c899a..4510bb43 100644 --- a/passerelle/utils/api.py +++ b/passerelle/utils/api.py @@ -43,7 +43,9 @@ class endpoint(object): parameters=None, cache_duration=None, post=None, - show=True): + show=True, + extra_params=None, + exclude_params=None): self.perm = perm self.methods = methods self.serializer_type = serializer_type @@ -71,6 +73,8 @@ class endpoint(object): if post.get('long_description'): self.long_descriptions['post'] = post.get('long_description') self.show = show + self.extra_params = extra_params or [] + self.exclude_params = exclude_params or set() def __call__(self, func): func.endpoint_info = self @@ -81,7 +85,7 @@ class endpoint(object): def get_example_params(self): return {param: info['example_value'] for param, info in self.parameters.items() - if 'example_value' in info} + if not param in self.exclude_params and 'example_value' in info} def get_query_parameters(self): query_parameters = [] @@ -190,5 +194,7 @@ class endpoint(object): typ = type_to_str(defaults[param]) if typ: param_info['type'] = typ - params.append(param_info) + if param not in self.exclude_params: + params.append(param_info) + params.extend(self.extra_params) return params diff --git a/tests/test_arcgis.py b/tests/test_arcgis.py index 2445d735..aafa9b61 100644 --- a/tests/test_arcgis.py +++ b/tests/test_arcgis.py @@ -281,7 +281,7 @@ def query(arcgis): resource=arcgis, name='Adresses', slug='adresses', - description='Recherche d\'une adresse', + description='Rechercher une adresse', id_template='{{ attributes.ident }}', text_template='{{ attributes.address }} - {{ attributes.codepost }}', folder='fold', @@ -338,6 +338,14 @@ def test_q_endpoint(arcgis, query, app): } +def test_query_documentation(arcgis, query, app): + resp = app.get(arcgis.get_absolute_url()) + assert query.name in resp.text + assert query.description in resp.text + # additional parameter appears in endpoint documentation + assert 'adress' in resp.text + + def test_export_import(query): assert ArcGIS.objects.count() == 1 assert Query.objects.count() == 1 -- 2.20.1