Repository: akvo/akvo-rsr

Issue:
Backend: Add attribute filter to GeoJSON endpoint
`/rest/v1/project_location_geojson` returns a GeoJSON of projects, however it includes too much information for our usecase (project map).

We just need the ID and `activeness` as extra information.

# TODO

**Add `activeness` to available fields**

In `akvo.rest.serializers.project.ProjectDirectorySerializer` the field is called `order_score`, but we should rename it in the GeoJSON endpoint for our purposes. We will need the field in order to sort the projects in the frontend.
It might be possible to use a [query aggregation](https://docs.djangoproject.com/en/3.2/topics/db/aggregation/) to make this less expensive

**`fields` param**

Add `fields` param to the endpoint which allows clients to restrict the included fields. The possible parameters should be restricted by the server - maybe to the current fields that are returned. When the param isn't provided, it should return the current fields.




Discussion:

Resolving patch:
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
@@ -5,7 +5,9 @@
 For additional details on the GNU license please see < http://www.gnu.org/licenses/agpl.html >.
 """
 
-from django.db.models import Q
+from datetime import timedelta
+from django.utils.timezone import now
+from django.db.models import Q, Count
 from django.shortcuts import get_object_or_404
 from django.views.decorators.cache import cache_page
 from rest_framework.authentication import SessionAuthentication
@@ -14,6 +16,7 @@
 from rest_framework.response import Response
 from rest_framework.status import HTTP_201_CREATED, HTTP_403_FORBIDDEN
 from geojson import Feature, Point, FeatureCollection
+from timeout_decorator import timeout
 
 from akvo.codelists.store.default_codelists import SECTOR_CATEGORY
 from akvo.rest.cache import serialized_project
@@ -29,7 +32,7 @@
 from akvo.rest.authentication import JWTAuthentication, TastyTokenAuthentication
 from akvo.rsr.models import Project, OrganisationCustomField, IndicatorPeriodData, ProjectRole
 from akvo.rsr.views.my_rsr import user_viewable_projects
-from akvo.utils import codelist_choices, single_period_dates
+from akvo.utils import codelist_choices, single_period_dates, get_thumbnail
 from ..viewsets import PublicProjectViewSet, ReadOnlyPublicProjectViewSet
 
 
@@ -320,22 +323,54 @@ def _project_list(request):
 @api_view(['GET'])
 def project_location_geojson(request):
     """Return a GeoJSON with all the project locations."""
-    projects = _project_list(request).prefetch_related('locations')
+    fields = request.GET.getlist('fields')
+    projects = _project_list(request)\
+        .exclude(primary_location__isnull=True)\
+        .select_related('primary_location')
+    if 'activeness' in fields:
+        nine_months = now() - timedelta(days=9 * 30)
+        result_update_count = Count(
+            'results__indicators__periods__data',
+            filter=Q(results__indicators__periods__data__created_at__gt=nine_months),
+            distinct=True
+        )
+        project_update_count = Count(
+            'project_updates',
+            filter=Q(project_updates__created_at__gt=nine_months),
+            distinct=True
+        )
+        projects = projects.annotate(result_update_count=result_update_count, project_update_count=project_update_count)
     features = [
-        Feature(geometry=Point((location.longitude, location.latitude)),
-                properties=dict(
-                    project_title=project.title,
-                    project_subtitle=project.subtitle,
-                    project_url=request.build_absolute_uri(project.get_absolute_url()),
-                    project_id=project.pk,
-                    name=location.name,
-                    description=location.description))
+        _make_project_location_feature(project, fields)
         for project in projects
-        for location in project.locations.all()
-        if location.is_valid()
+        if project.primary_location and project.primary_location.is_valid()
     ]
-    response = FeatureCollection(features)
-    return Response(response)
+    collection = FeatureCollection(features)
+    return Response(collection)
+
+
+def _make_project_location_feature(project, fields=[]):
+    props = dict(id=project.id)
+    point = Point((project.primary_location.longitude, project.primary_location.latitude))
+    if 'title' in fields:
+        props['title'] = project.title
+    if 'country' in fields:
+        props['country'] = project.primary_location.country.iso_code \
+            if project.primary_location and project.primary_location.country \
+            else ''
+    if 'activeness' in fields:
+        props['activeness'] = project.project_update_count + project.result_update_count
+    if 'image' in fields:
+        @timeout(1)
+        def get_thumbnail_with_timeout():
+            return get_thumbnail(project.current_image, '350x200', crop='smart', quality=99)
+        try:
+            image = get_thumbnail_with_timeout()
+            url = image.url
+        except Exception:
+            url = project.current_image.url if project.current_image.name else ''
+        props['image'] = url
+    return Feature(geometry=point, properties=props)
 
 
 @api_view(['POST'])


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
@@ -348,13 +348,14 @@ def test_custom_fields_added(self):
 
 
 class ProjectGeoJsonTestCase(BaseTestCase):
+    def setUp(self):
+        super().setUp()
+        self.project = self.create_project('Test Project')
+        self.project.current_image = 'foo'  # Set current_image to show project in the directory
+        ProjectLocation.objects.create(location_target=self.project, latitude='12', longitude='73')
+        ProjectLocation.objects.create(location_target=self.project, latitude='13', longitude='74')
 
-    def test_all_project_locations(self):
-        project = self.create_project('Test Project')
-        project.current_image = 'foo'  # Set current_image to show project in the directory
-        ProjectLocation.objects.create(location_target=project, latitude='12', longitude='73')
-        ProjectLocation.objects.create(location_target=project, latitude='13', longitude='74')
-
+    def test_all_project(self):
         project = self.create_project('Test Project 2')
         project.current_image = 'foo'  # Set current_image to show project in the directory
         ProjectLocation.objects.create(location_target=project, latitude='14', longitude='73')
@@ -363,12 +364,23 @@ def test_all_project_locations(self):
         response = self.c.get('/rest/v1/project_location_geojson')
 
         self.assertEqual(response.status_code, 200)
-        self.assertEqual(4, len(response.data['features']))
+        self.assertEqual(2, len(response.data['features']))
         self.assertEqual(
             {tuple(feature['geometry']['coordinates']) for feature in response.data['features']},
-            {(loc.longitude, loc.latitude) for loc in ProjectLocation.objects.filter()}
+            {(p.primary_location.longitude, p.primary_location.latitude) for p in Project.objects.all()}
         )
 
+    def test_params(self):
+        response = self.c.get('/rest/v1/project_location_geojson?fields=activeness&fields=title')
+        feature = response.data['features'][0]
+        self.assertIn('activeness', feature['properties'])
+        self.assertIn('title', feature['properties'])
+
+    def test_no_activeness(self):
+        response = self.c.get('/rest/v1/project_location_geojson')
+        feature = response.data['features'][0]
+        self.assertNotIn('activeness', feature['properties'])
+
 
 class AddProjectToProgramTestCase(BaseTestCase):