From dfa17ffc69a1b73627943c5fdc0125c7f42a2adf Mon Sep 17 00:00:00 2001
From: Nicolas ROCHE <nroche@entrouvert.com>
Date: Fri, 25 Jun 2021 19:24:46 +0200
Subject: [PATCH] opendatasoft: add sort field (#54442)

---
 .../migrations/0003_query_sort.py             | 25 +++++++++++++++++++
 passerelle/apps/opendatasoft/models.py        | 20 ++++++++++++---
 tests/test_opendatasoft.py                    | 17 ++++++++++---
 3 files changed, 56 insertions(+), 6 deletions(-)
 create mode 100644 passerelle/apps/opendatasoft/migrations/0003_query_sort.py

diff --git a/passerelle/apps/opendatasoft/migrations/0003_query_sort.py b/passerelle/apps/opendatasoft/migrations/0003_query_sort.py
new file mode 100644
index 00000000..c3397fd1
--- /dev/null
+++ b/passerelle/apps/opendatasoft/migrations/0003_query_sort.py
@@ -0,0 +1,25 @@
+# -*- coding: utf-8 -*-
+# Generated by Django 1.11.29 on 2021-06-25 17:01
+from __future__ import unicode_literals
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('opendatasoft', '0002_auto_20210625_1852'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='query',
+            name='sort',
+            field=models.CharField(
+                blank=True,
+                help_text='Sorts results by the specified field. A minus sign - may be used to perform an ascending sort.',
+                max_length=256,
+                verbose_name='Sort field',
+            ),
+        ),
+    ]
diff --git a/passerelle/apps/opendatasoft/models.py b/passerelle/apps/opendatasoft/models.py
index fa59b224..043b0c37 100644
--- a/passerelle/apps/opendatasoft/models.py
+++ b/passerelle/apps/opendatasoft/models.py
@@ -59,33 +59,37 @@ class OpenDataSoft(BaseResource):
             Query.objects.filter(resource=instance).delete()
         for data_query in data_queries:
             query = Query.import_json(data_query)
             query.resource = instance
             queries.append(query)
         Query.objects.bulk_create(queries)
         return instance
 
-    def call_search(self, dataset=None, text_template='', filter_expression='', id=None, q=None, limit=None):
+    def call_search(
+        self, dataset=None, text_template='', filter_expression='', sort='', id=None, q=None, limit=None
+    ):
         scheme, netloc, path, params, query, fragment = urlparse.urlparse(self.service_url)
         path = urlparse.urljoin(path, 'api/records/1.0/search/')
         url = urlparse.urlunparse((scheme, netloc, path, params, query, fragment))
 
         if id is not None:
             query = 'recordid:%s' % id
         else:
             query = q
         params = {
             'dataset': dataset,
             'q': query,
         }
         if self.api_key:
             params.update({'apikey': self.api_key})
         if limit:
             params.update({'rows': limit})
+        if sort:
+            params.update({'sort': sort})
         params.update(urlparse.parse_qs(filter_expression))
 
         result_response = self.requests.get(url, params=params)
         err_desc = result_response.json().get('error')
         if err_desc:
             raise APIError(err_desc, http_status=200)
 
         result = []
@@ -102,23 +106,24 @@ class OpenDataSoft(BaseResource):
         return result
 
     @endpoint(
         perm='can_access',
         description=_('Search'),
         parameters={
             'dataset': {'description': _('Dataset')},
             'text_template': {'description': _('Text template')},
+            'sort': {'description': _('Sort field')},
             'id': {'description': _('Record identifier')},
             'q': {'description': _('Full text query')},
             'limit': {'description': _('Maximum items')},
         },
     )
-    def search(self, request, dataset=None, text_template='', id=None, q=None, limit=None, **kwargs):
-        result = self.call_search(dataset, text_template, '', id, q, limit)
+    def search(self, request, dataset=None, text_template='', sort='', id=None, q=None, limit=None, **kwargs):
+        result = self.call_search(dataset, text_template, '', sort, id, q, limit)
         return {'data': result}
 
     @endpoint(
         name='q',
         description=_('Query'),
         pattern=r'^(?P<query_slug>[\w:_-]+)/$',
         perm='can_access',
         show=False,
@@ -148,27 +153,36 @@ class Query(BaseQuery):
         validators=[validate_template],
         blank=True,
     )
     filter_expression = models.TextField(
         verbose_name=_('filter'),
         help_text=_('Specify refine and exclude facet expressions separated lines'),
         blank=True,
     )
+    sort = models.CharField(
+        verbose_name=_('Sort field'),
+        help_text=_(
+            "Sorts results by the specified field. A minus sign - may be used to perform an ascending sort."
+        ),
+        max_length=256,
+        blank=True,
+    )
 
     delete_view = 'opendatasoft-query-delete'
     edit_view = 'opendatasoft-query-edit'
 
     def q(self, request, **kwargs):
         return self.resource.call_search(
             dataset=self.dataset,
             text_template=self.text_template,
             filter_expression='&'.join(
                 [x.strip() for x in str(self.filter_expression).splitlines() if x.strip()]
             ),
+            sort=self.sort,
             id=kwargs.get('id'),
             q=kwargs.get('q'),
             limit=kwargs.get('limit'),
         )
 
     def as_endpoint(self):
         endpoint = super(Query, self).as_endpoint(path=self.resource.q.endpoint_info.name)
 
diff --git a/tests/test_opendatasoft.py b/tests/test_opendatasoft.py
index c8fa1330..73107264 100644
--- a/tests/test_opendatasoft.py
+++ b/tests/test_opendatasoft.py
@@ -138,16 +138,17 @@ def connector():
 def query(connector):
     return Query.objects.create(
         resource=connector,
         name='Référenciel adresses de test',
         slug='my_query',
         description='Rechercher une adresse',
         dataset='referentiel-adresse-test',
         text_template='{{numero}} {{nom_rue}} {{nom_commun}}',
+        sort='-nom_rue',
         filter_expression='''
 refine.source=Ville et Eurométropole de Strasbourg
 exclude.numero=42
 exclude.numero=43
 ''',
     )
 
 
@@ -190,21 +191,29 @@ def test_search_empty_contents(mocked_get, app, connector):
 
 @mock.patch('passerelle.utils.Request.get')
 def test_search_using_q(mocked_get, app, connector):
     endpoint = utils.generic_endpoint_url('opendatasoft', 'search', slug=connector.slug)
     assert endpoint == '/opendatasoft/my_connector/search'
     params = {
         'dataset': 'referentiel-adresse-test',
         'text_template': '{{numero}} {{nom_rue}} {{nom_commun}}',
+        'sort': '-nom_rue',
         'q': "rue de l'aubepine",
         'limit': 3,
     }
     mocked_get.return_value = utils.FakedResponse(content=FAKED_CONTENT_Q_SEARCH, status_code=200)
     resp = app.get(endpoint, params=params, status=200)
+    assert mocked_get.call_args[1]['params'] == {
+        'apikey': 'my_secret',
+        'dataset': 'referentiel-adresse-test',
+        'sort': '-nom_rue',
+        'q': "rue de l'aubepine",
+        'rows': '3',
+    }
     assert not resp.json['err']
     assert len(resp.json['data']) == 3
     # check order is kept
     assert [x['id'] for x in resp.json['data']] == [
         'e00cf6161e52a4c8fe510b2b74d4952036cb3473',
         '7cafcd5c692773e8b863587b2d38d6be82e023d8',
         '0984a5e1745701f71c91af73ce764e1f7132e0ff',
     ]
@@ -238,22 +247,23 @@ def test_query_q_using_q(mocked_get, app, query):
     endpoint = '/opendatasoft/my_connector/q/my_query/'
     params = {
         'q': "rue de l'aubepine",
         'limit': 3,
     }
     mocked_get.return_value = utils.FakedResponse(content=FAKED_CONTENT_Q_SEARCH, status_code=200)
     resp = app.get(endpoint, params=params, status=200)
     assert mocked_get.call_args[1]['params'] == {
-        'dataset': 'referentiel-adresse-test',
-        'q': "rue de l'aubepine",
         'apikey': 'my_secret',
-        'rows': '3',
+        'dataset': 'referentiel-adresse-test',
         'refine.source': ['Ville et Eurométropole de Strasbourg'],
         'exclude.numero': ['42', '43'],
+        'sort': '-nom_rue',
+        'q': "rue de l'aubepine",
+        'rows': '3',
     }
     assert not resp.json['err']
     assert len(resp.json['data']) == 3
     # check order is kept
     assert [x['id'] for x in resp.json['data']] == [
         'e00cf6161e52a4c8fe510b2b74d4952036cb3473',
         '7cafcd5c692773e8b863587b2d38d6be82e023d8',
         '0984a5e1745701f71c91af73ce764e1f7132e0ff',
@@ -277,16 +287,17 @@ def test_query_q_using_id(mocked_get, app, query):
     mocked_get.return_value = utils.FakedResponse(content=FAKED_CONTENT_ID_SEARCH, status_code=200)
     resp = app.get(endpoint, params=params, status=200)
     assert mocked_get.call_args[1]['params'] == {
         'dataset': 'referentiel-adresse-test',
         'q': 'recordid:7cafcd5c692773e8b863587b2d38d6be82e023d8',
         'apikey': 'my_secret',
         'refine.source': ['Ville et Eurométropole de Strasbourg'],
         'exclude.numero': ['42', '43'],
+        'sort': '-nom_rue',
     }
     assert len(resp.json['data']) == 1
     assert resp.json['data'][0]['text'] == "19 RUE DE L'AUBEPINE Lipsheim"
 
 
 def test_opendatasoft_query_unicity(admin_user, app, connector, query):
     connector2 = OpenDataSoft.objects.create(
         slug='my_connector2',
-- 
2.30.2

