Repository: akvo/akvo-rsr

Issue:
Backend: Provide public project search endpoint
The new landing page will move away from searching a local cache to calling a search endpoint on the sever.

`/rest/v1/project_published_search`

# Specs

 - Only public and published projects should be searched
 - There should be a server-side limit on the number of projects that can be returned (django setting = 100)
 - Clients should be able to pass the max number of projects (limited by the server of course)

## Params

 - `query` - `String` **Optional**: the filter to use when finding projects - replicates what the frontend used to do
 - `sectors` - `List[String]` **Optional**: A list of sectors that the projects should be in (`OR`)
 - `orgs` - `List[Integer]` **Optional**: A list of organisation IDs that the the projects should be belong to (`OR`)
 - `limit` - `Integer` **Optional**: Max number of projects with data

## Result structure

```js
{
  "total": "<int>",
  // IDs of projects found
  "results": [
    "id": "<int>"
  ]
}
```

# TODO

 - Check if such an endpoint already exists or whether and existing endpoint can be modified to fit the specs
 - Add django setting `AKVO_PUBLIC_PROJECT_SEARCH_LIMIT` with an initial limit of 100
 - Add client side params


Discussion:

Resolving patch:
diff --git a/akvo/rest/urls.py b/akvo/rest/urls.py
--- a/akvo/rest/urls.py
+++ b/akvo/rest/urls.py
@@ -262,6 +262,9 @@
     url(r'v1/projects_by_id$',
         views.projects_by_id,
         name='projects_by_id'),
+    url(r'v1/project_published_search$',
+        views.project_published_search,
+        name='project_published_search'),
 )
 
 # GeoJSON views
diff --git a/akvo/rest/views/__init__.py b/akvo/rest/views/__init__.py
--- a/akvo/rest/views/__init__.py
+++ b/akvo/rest/views/__init__.py
@@ -69,7 +69,7 @@
 from .project import (ProjectViewSet, ProjectExtraViewSet, ProjectExtraDeepViewSet,
                       ProjectIatiExportViewSet, ProjectUpViewSet, project_location_geojson,
                       MyProjectsViewSet, ProjectHierarchyViewSet, add_project_to_program,
-                      project_directory, project_title, projects_by_id)
+                      project_directory, project_title, projects_by_id, project_published_search)
 from .project_editor import (project_editor_reorder_items,
                              project_editor_copy_results,
                              project_editor_import_result,
diff --git a/akvo/rest/views/project.py b/akvo/rest/views/project.py
--- a/akvo/rest/views/project.py
+++ b/akvo/rest/views/project.py
@@ -19,7 +19,7 @@
 from geojson import Feature, Point, FeatureCollection
 from timeout_decorator import timeout
 
-from akvo.codelists.store.default_codelists import SECTOR_CATEGORY
+from akvo.codelists.store.default_codelists import SECTOR_CATEGORY, SECTOR_VOCABULARY
 from akvo.rest.cache import serialized_project
 from akvo.rest.serializers import (ProjectSerializer, ProjectExtraSerializer,
                                    ProjectExtraDeepSerializer,
@@ -331,6 +331,30 @@ def projects_by_id(request):
     return Response(serializer.data)
 
 
+@api_view(['GET'])
+def project_published_search(request):
+    query = request.GET.get('query', '')
+    sectors = {sector for sector in request.GET.get('sectors', '').split(',') if sector}
+    orgs = {int(org) for org in request.GET.get('orgs', '').split(',') if org}
+    max_limit = settings.AKVO_PUBLIC_PROJECT_SEARCH_LIMIT
+    try:
+        limit = int(request.GET.get('limit', max_limit))
+    except ValueError:
+        limit = max_limit
+    limit = limit if 1 <= limit <= max_limit else max_limit
+    projects = _project_list(request)
+    if query:
+        projects = projects.filter(title__icontains=query)
+    if sectors:
+        projects = projects.filter(sectors__sector_code__in=sectors, sectors__vocabulary=SECTOR_VOCABULARY[2][0])
+    if orgs:
+        projects = projects.filter(partners__in=orgs)
+    return Response({
+        'total': projects.count(),
+        'results': [p.id for p in projects.all()[:limit]],
+    })
+
+
 @api_view(['GET'])
 def project_location_geojson(request):
     """Return a GeoJSON with all the project locations."""


Test patch:
diff --git a/akvo/rsr/tests/rest/test_project.py b/akvo/rsr/tests/rest/test_project.py
--- a/akvo/rsr/tests/rest/test_project.py
+++ b/akvo/rsr/tests/rest/test_project.py
@@ -15,9 +15,10 @@
 from django.contrib.auth.models import Group
 from django.test import TestCase, Client
 
+from akvo.codelists.store import default_codelists as codelists
 from akvo.rsr.models import (Project, Organisation, Partnership, User,
                              Employment, ProjectLocation, ProjectEditorValidationSet,
-                             OrganisationCustomField, ProjectCustomField, Result)
+                             OrganisationCustomField, ProjectCustomField, Result, Sector)
 from akvo.utils import check_auth_groups
 from akvo.rsr.tests.base import BaseTestCase
 
@@ -411,6 +412,45 @@ def test_fields_param(self):
         self.assertEqual(['id', 'title', 'subtitle', 'project_plan_summary'], [k for k in data[0].keys()])
 
 
+class ProjectPublishedSearchTestCase(BaseTestCase):
+
+    def setUp(self):
+        super().setUp()
+        self.org1 = self.create_organisation('Org #1')
+        self.org2 = self.create_organisation('Org #2')
+        self.project1 = self.create_project('Project One')
+        self.project1.current_image = 'foo'
+        self.project1.save()
+        self.make_partner(self.project1, self.org1)
+        self.project2 = self.create_project('Project Two')
+        self.project2.current_image = 'foo'
+        self.project2.save()
+        self.make_partner(self.project2, self.org2)
+        self.sector1 = codelists.SECTOR_CATEGORY[1][0]
+        self.sector2 = codelists.SECTOR_CATEGORY[2][0]
+        vocabulary = codelists.SECTOR_VOCABULARY[2][0]
+        Sector.objects.create(project=self.project1, vocabulary=vocabulary, sector_code=self.sector1)
+        Sector.objects.create(project=self.project2, vocabulary=vocabulary, sector_code=self.sector2)
+
+    def test_query(self):
+        response = self.c.get('/rest/v1/project_published_search?query=one&format=json')
+        data = response.data
+        self.assertEqual(1, data['total'])
+        self.assertEqual(self.project1.id, data['results'][0])
+
+    def test_orgs(self):
+        response = self.c.get(f'/rest/v1/project_published_search?query=project&orgs={self.org1.id}&format=json')
+        data = response.data
+        self.assertEqual(1, data['total'])
+        self.assertEqual(self.project1.id, data['results'][0])
+
+    def test_sectors(self):
+        response = self.c.get(f'/rest/v1/project_published_search?query=project&sectors={self.sector1}&format=json')
+        data = response.data
+        self.assertEqual(1, data['total'])
+        self.assertEqual(self.project1.id, data['results'][0])
+
+
 class AddProjectToProgramTestCase(BaseTestCase):
 
     def setUp(self):