View on GitHub

geonode-training

Add an app to a geonode project

In this section, we will show how to create and setup the skeleton of a custom app using the django facilities.

The Geocollections app will show in a single page, resources and users grouped by a GeoNode Profile.

We will be able to assign arbitrary resources, a profile and a name to a Geocollection; the latter will be used to build a dedicated URL too.

Create the django app

Add custom models, views and URLs

Create admin panel for geocollections models

We need a user interface allowing us to create geocollections.

Django makes this very easy, we just need adding them to the admin.py file as follows

vim geocollections/admin.py
from django.contrib import admin

from .models import Geocollection


class GeocollectionAdmin(admin.ModelAdmin):
    prepopulated_fields = {"slug": ("name",)}
    filter_horizontal = ('resources',)

admin.site.register(Geocollection, GeocollectionAdmin)

Browse http://localhost:8000/admin/ and search for the Geocollections tab:

image

Create a new Geocollection named boulder and add some resources to it

image

Adding the Geocollections Details Template

Last thing we need to add in order to render the Geocollection details, is the HTML template used by the django view

mkdir -p my_geonode/templates/geocollections/
vim my_geonode/templates/geocollections/geocollection_detail.html
{% extends "geonode_base.html" %}
{% block body %}
    <h2>Geocollection {{ object.name }}</h2>
    <p>Group: {{ object.group.title }}</p>
    <p>Resources:</p>
    <ul>
        {% for resource in object.resources.all %}
            <li>{{ resource.title }}</li>
        {% endfor %}
    </ul>
{% endblock %}

Try now visiting the geocollection we just created; go to http://localhost:8000/geocollections/boulder/

The URLs of the geocollection are in the form http://localhost:8000/geocollections/<the-name-of-the-created-geocollection>

image

Permissions

The permissions in GeoNode are managed by django-guardian, a python library allowing to set object level permissions (django has table level authorization).

First thing to do is to add the permissions object to the database. We can do this by adding the following Meta class to our Geocollection model, guardian will take care of creating the objects for us.

vim geocollections/models.py

Here the new model class

--- geocollections/models_00.py	2021-10-28 17:35:06.499794009 +0200
+++ geocollections/models_01.py	2021-10-28 17:36:12.791491477 +0200
@@ -15,3 +15,8 @@
 
    def __str__(self):
       return self.name
+
+   class Meta:
+      permissions = (
+         ('access_geocollection', 'Can view geocollection'),
+      )

Run makemigrations and migrate management commands to install them

./manage_dev.sh makemigrations
./manage_dev.sh migrate

Please note that it is not possible to define any permissions with prefixes like view_, add_, delete_ or something, because those have been natively introduced by Django since version 2.1.

Permission logic methods

Let’s add few methods to the Geocollection models and views in order to be able to manage the permissions

vim geocollections/models.py

Here the new model class.
(We are not showing the diff here because it would be too long.)

Default permissions

Let’s test the set_default_permissions method:

./manage_dev.sh shell
Python 3.8.10 (default, Jun  2 2021, 10:49:15) 
Type 'copyright', 'credits' or 'license' for more information
IPython 7.24.1 -- An enhanced Interactive Python. Type '?' for help.

In [1]: from geocollections.models import Geocollection

In [2]: Geocollection.objects.first().set_default_permissions()

In [3]: quit()

Permissions Setter on perm_spec

A perm_spec in GeoNode is an object declaring the set of permissions to assign to users, groups or both.

Please note that in this context a group is a Django authority group which is related to a GeoNode GroupProfile through its slug.

A sample perm_spec is something like this (you may want to use this code later in the python shell):

perm_spec = {
    "users": {
        "AnonymousUser": [],
        "test_user1": ["access_geocollection"],
        "test_user2": [],
    },
    "groups": {
        "registered-members": ["access_geocollection"]
    }
}

Let’s test the set_permissions method:

./manage_dev.sh shell
Python 3.8.10 (default, Jun  2 2021, 10:49:15) 
Type 'copyright', 'credits' or 'license' for more information
IPython 7.24.1 -- An enhanced Interactive Python. Type '?' for help.

In [1]: from geocollections.models import Geocollection

In [2]: Geocollection.objects.first().set_default_permissions()

In [3]: Geocollection.objects.first().get_all_level_info()
Out[3]: 
{'users': {},
 'groups': {<Group: anonymous>: ['access_geocollection'],
  <Group: registered-members>: ['access_geocollection']}}

In [4]: Geocollection.objects.first().remove_object_permissions()

In [5]: Geocollection.objects.first().get_all_level_info()
Out[5]: {'users': {}, 'groups': {}}

# *** PASTE AND COPY FROM THE PREVIOUS CLEAN JSON CODE
In [6]: perm_spec = { 
   ...:     "users": {
   ...:         "AnonymousUser": [],
   ...:         "test_user1": ["access_geocollection"],
   ...:         "test_user2": [],
   ...:     },
   ...:     "groups": {
   ...:         "registered-members": ["access_geocollection"]
   ...:     }
   ...: }

# *** MAKE SURE YOU HAVE THE USERS test_user1 AND test_user2  
In [7]: Geocollection.objects.first().set_permissions(perm_spec)
 assign_perm 'AnonymousUser' -> []
 assign_perm 'test_user1' -> ['access_geocollection']
 assign_perm 'test_user2' -> []

In [8]: Geocollection.objects.first().get_all_level_info()
Out[8]: 
{'users': {<Profile: test_user1>: ['access_geocollection']},
 'groups': {<Group: registered-members>: ['access_geocollection']}}

In [9]: quit()

Permissions Views and Urls

Let’s use the access_geocollection permissions to control access to the views.

We will also define a specific view allowing us to check/set the geocollection permissions.

vim geocollections/views.py

Here the new views file

--- geocollections/views_00.py	2021-10-28 19:49:21.335072043 +0200
+++ geocollections/views_01.py	2021-10-28 19:47:48.067500735 +0200
@@ -1,7 +1,32 @@
+import json
+import logging
+import traceback
+
+from django.shortcuts import render
+from django.http import HttpResponse
 from django.views.generic import DetailView
+from django.core.exceptions import PermissionDenied
+from django.contrib.auth.mixins import PermissionRequiredMixin
 
 from .models import Geocollection
 
+logger = logging.getLogger(__name__)
+
 
-class GeocollectionDetail(DetailView):
+class GeocollectionDetail(PermissionRequiredMixin, DetailView):
     model = Geocollection
+
+    def has_permission(self):
+        return self.request.user.has_perm('geocollection.access_geocollection', self)
+
+def geocollection_permissions(request, collection_name):
+    geocollection = Geocollection.objects.get(name=collection_name)
+    user = request.user
+
+    if user.has_perm('access_geocollection', geocollection):
+        return HttpResponse(
+            (f'You have the permission to access the geocollection "{collection_name}". '
+             'Please customize a template for this view'),
+            content_type='text/plain')
+    else:
+        raise PermissionDenied

Now bind a new urlpattern to access the geocollection_permissions view.

vim geocollections/urls.py
--- geocollections/urls.py.org	2021-09-13 23:43:40.534056180 +0100
+++ geocollections/urls.py	2021-09-13 23:46:45.172949596 +0100
@@ -1,9 +1,12 @@
 from django.conf.urls import url
 
-from .views import GeocollectionDetail
+from .views import GeocollectionDetail, geocollection_permissions
 
 urlpatterns = [
     url(r'^(?P<slug>[-\w]+)/$',
         GeocollectionDetail.as_view(),
         name='geocollection-detail'),
+    url(r'^permissions/(?P<collection_name>\w+)/$',
+        geocollection_permissions,
+        name='geocollection_permissions')
 ]

Trying to access the views as an admin we will be able to get both the details and the permissions check

Permissions Set View Template

Let’s modify the geocollection_permissions view in order to return a FORM allowing a user to set the perm_spec from the browser

vim geocollections/views.py

Here the new views file

--- geocollections/views.py.org	2021-09-13 23:36:59.410056180 +0100
+++ geocollections/views.py	2021-09-14 01:15:30.513828438 +0100
@@ -1,6 +1,56 @@
+import json
+import logging
+import traceback
+
+from django.shortcuts import render
+from django.http import HttpResponse
 from django.views.generic import DetailView
+from django.core.exceptions import PermissionDenied
+from django.contrib.auth.mixins import PermissionRequiredMixin
 
 from .models import Geocollection
 
-class GeocollectionDetail(DetailView):
+logger = logging.getLogger(__name__)
+
+
+class GeocollectionDetail(PermissionRequiredMixin, DetailView):
     model = Geocollection
+
+    def has_permission(self):
+        return self.request.user.has_perm('geocollection.access_geocollection', self)
+
+
+def geocollection_permissions(request, collection_name):
+
+    geocollection = Geocollection.objects.get(name=collection_name)
+    user = request.user
+
+    if not user.has_perm('access_geocollection', geocollection):
+        raise PermissionDenied
+
+    if request.method == 'GET':
+        return render(request, 'geocollections/geocollection_permissions.html', context={'object': geocollection})
+
+    elif request.method == 'POST':
+        success = True
+        message = "Permissions successfully updated!"
+        try:
+            perm_spec = json.loads(request.POST.get('perm_spec'))
+            logger.info(f" ---- setting perm_sepc: {perm_spec}")
+            geocollection.set_permissions(perm_spec)
+
+            return HttpResponse(
+                json.dumps({'success': success, 'message': message}),
+                status=200,
+                content_type='text/plain'
+            )
+        except Exception as e:
+            traceback.print_exc()
+            logger.exception(e)
+            success = False
+            message = f"Error updating permissions :(... error: {e}"
+            return HttpResponse(
+                json.dumps({'success': success, 'message': message}),
+                status=500,
+                content_type='text/plain'
+            )

Let’s define now the geocollections/geocollection_permissions.html template to render and manage the perm_spec request

vim my_geonode/templates/geocollections/geocollection_permissions.html
{% extends "geonode_base.html" %}
{% block body %}
    <h2>Geocollection {{ object.name }}</h2>
    <p>You have the permission to access the Geocollection: {{ object.name }}</p>
    <p>Set Permissions:</p>
    <form action="/geocollections/permissions/{{ object.name }}" method="POST" name="geocollections_perm_spec_form">
       {% csrf_token %}
       <label for="perm_spec">Perm Spec: </label><br>
       <textarea id="perm_spec" name="perm_spec" rows=4 cols="50">{"users": {"AnonymousUser": ["access_geocollection"]}, "groups": {}}</textarea><br>
       <input type="submit" value="Submit">
    </form>
{% endblock body%}

{% block extra_script %}
{{ block.super }}
{% endblock extra_script %}
./manage_dev.sh shell
Python 3.8.10 (default, Jun  2 2021, 10:49:15) 
Type 'copyright', 'credits' or 'license' for more information
IPython 7.24.1 -- An enhanced Interactive Python. Type '?' for help.

In [1]: from geocollections.models import Geocollection

In [2]: Geocollection.objects.get(name='boulder').get_all_level_info()
Out[2]: 
{'users': {<Profile: AnonymousUser>: ['access_geocollection']},
 'groups': {<Group: anonymous>: ['access_geocollection']}}

In [3]: quit()

Adding APIs to Geocollection App

In this section we are going to implement a very simple instance of GeoNode APIs for our Geocollection app, along with security integration.

Currently GeoNode provides two types of API endpoints that have been usually identified as API v1, provided through Django Tasypie, and API v2 which are provided by Django Restframework.

WARNING GeoNode 3.x still provides support for the API v1. Those are deprecated and will be dropped in future versions.

API v1 - Tastypie

vim geocollections/api.py
import json
from tastypie.resources import ModelResource
from tastypie import fields
from tastypie.constants import ALL_WITH_RELATIONS, ALL

from geonode.api.api import ProfileResource, GroupResource
from geonode.api.resourcebase_api import ResourceBaseResource

from .models import Geocollection


class GeocollectionResource(ModelResource):

    users = fields.ToManyField(ProfileResource, attribute=lambda bundle: bundle.obj.group.group.user_set.all(), full=True)
    group = fields.ToOneField(GroupResource, 'group__group', full=True)
    resources = fields.ToManyField(ResourceBaseResource, 'resources', full=True)

    class Meta:
        queryset = Geocollection.objects.all().order_by('-group')
        ordering = ['group']
        allowed_methods = ['get']
        resource_name = 'geocollections'
        filtering = {
            'group': ALL_WITH_RELATIONS,
            'id': ALL
        }
vim geocollections/api.py
--- geocollections/api.py.org	2021-09-14 11:02:11.106936710 +0100
+++ geocollections/api.py	2021-09-14 11:02:14.948856713 +0100
@@ -2,6 +2,9 @@
 from tastypie.resources import ModelResource
 from tastypie import fields
 from tastypie.constants import ALL_WITH_RELATIONS, ALL
+from tastypie.authorization import DjangoAuthorization
+
+from guardian.shortcuts import get_objects_for_user
 
 from geonode.api.api import ProfileResource, GroupResource
 from geonode.api.resourcebase_api import ResourceBaseResource
@@ -9,6 +12,21 @@
 from .models import Geocollection
 
 
+class GeocollectionAuth(DjangoAuthorization):
+
+    def read_list(self, object_list, bundle):
+        permitted_ids = get_objects_for_user(
+            bundle.request.user,
+            'geocollections.access_geocollection').values('id')
+
+        return object_list.filter(id__in=permitted_ids)
+
+    def read_detail(self, object_list, bundle):
+        return bundle.request.user.has_perm(
+            'access_geocollection',
+            bundle.obj)
+
+
 class GeocollectionResource(ModelResource):
 
     users = fields.ToManyField(ProfileResource, attribute=lambda bundle: bundle.obj.group.group.user_set.all(), full=True)
@@ -16,6 +34,7 @@
     resources = fields.ToManyField(ResourceBaseResource, 'resources', full=True)
 
     class Meta:
+        authorization = GeocollectionAuth()
         queryset = Geocollection.objects.all().order_by('-group')
         ordering = ['group']
         allowed_methods = ['get']
vim my_geonode/urls.py
--- my_geonode/urls.py.org	2021-09-14 11:05:03.377028744 +0100
+++ my_geonode/urls.py	2021-09-14 11:05:54.934794761 +0100
@@ -24,8 +24,15 @@
 from geonode.urls import urlpatterns
 from geonode.base import register_url_event
 
+from geonode.api.urls import api
+
+from geocollections.api import GeocollectionResource
+
+api.register(GeocollectionResource())
+
 urlpatterns += [
 ## include your urls here
+    url(r'', include(api.urls)),
     url(r'^geocollections/', include('geocollections.urls')),
 ]

API v2 - REST

vim geocollections/views.py
--- geocollections/views.py.org_2	2021-09-14 13:41:23.290625216 +0100
+++ geocollections/views.py	2021-09-14 13:46:01.578625216 +0100
@@ -8,7 +8,18 @@
 from django.core.exceptions import PermissionDenied
 from django.contrib.auth.mixins import PermissionRequiredMixin
 
+from dynamic_rest.viewsets import DynamicModelViewSet
+from dynamic_rest.filters import DynamicFilterBackend, DynamicSortingFilter
+
+from rest_framework.permissions import IsAuthenticatedOrReadOnly
+from rest_framework.authentication import SessionAuthentication, BasicAuthentication
+from oauth2_provider.contrib.rest_framework import OAuth2Authentication
+
+from geonode.base.api.pagination import GeoNodeApiPagination
+
 from .models import Geocollection
+from .serializers import GeocollectionSerializer
+from .permissions import GeocollectionPermissionsFilter
 
 logger = logging.getLogger(__name__)
 
@@ -54,3 +65,19 @@
                 status=500,
                 content_type='text/plain'
             )
+
+
+class GeocollectionViewSet(DynamicModelViewSet):
+    """
+    API endpoint that allows geocollections to be viewed or edited.
+    """
+    authentication_classes = [SessionAuthentication, BasicAuthentication, OAuth2Authentication]
+    permission_classes = [IsAuthenticatedOrReadOnly, ]
+    filter_backends = [
+        DynamicFilterBackend, DynamicSortingFilter,
+        GeocollectionPermissionsFilter
+    ]
+    queryset = Geocollection.objects.all()
+    serializer_class = GeocollectionSerializer
+    pagination_class = GeoNodeApiPagination
vim geocollections/permissions.py
from django.conf import settings
from rest_framework.filters import BaseFilterBackend


class GeocollectionPermissionsFilter(BaseFilterBackend):
    """
    A filter backend that limits results to those where the requesting user
    has read object level permissions.
    """
    shortcut_kwargs = {
        'accept_global_perms': True,
    }

    def filter_queryset(self, request, queryset, view):
        # We want to defer this import until runtime, rather than import-time.
        # See https://github.com/encode/django-rest-framework/issues/4608
        # (Also see #1624 for why we need to make this import explicitly)
        from guardian.shortcuts import get_objects_for_user

        user = request.user

        obj_with_perms = get_objects_for_user(
            user,
            'geocollections.access_geocollection',
            **self.shortcut_kwargs
        )

        return queryset.filter(id__in=obj_with_perms.values('id'))
vim geocollections/serializers.py
from dynamic_rest.serializers import DynamicModelSerializer

from .models import Geocollection


class GeocollectionSerializer(DynamicModelSerializer):

    class Meta:
        model = Geocollection
        name = 'geocollection'
        fields = (
            'pk', 'name', 'group', 'resources'
        )
vim geocollections/urls.py
--- geocollections/urls.py.org	2021-09-13 23:43:40.534056180 +0100
+++ geocollections/urls.py	2021-09-14 14:01:28.609865444 +0100
@@ -1,9 +1,16 @@
-from django.conf.urls import url
+from django.conf.urls import url, include
+from geonode.api.urls import router
 
-from .views import GeocollectionDetail
+from .views import GeocollectionDetail, geocollection_permissions, GeocollectionViewSet
+
+router.register(r'geocollections', GeocollectionViewSet, 'geocollections')
 
 urlpatterns = [
     url(r'^(?P<slug>[-\w]+)/$',
         GeocollectionDetail.as_view(),
         name='geocollection-detail'),
+    url(r'^permissions/(?P<collection_name>\w+)$',
+        geocollection_permissions,
+        name='geocollection_permissions'),
+    url(r'^api/v2/', include(router.urls)),
 ]
vim geocollections/serializers.py
--- geocollections/serializers.py.org	2021-09-14 14:13:09.413865444 +0100
+++ geocollections/serializers.py	2021-09-14 14:18:26.073865444 +0100
@@ -1,4 +1,6 @@
 from dynamic_rest.serializers import DynamicModelSerializer
+from dynamic_rest.fields.fields import DynamicRelationField
+from geonode.base.api.serializers import GroupProfileSerializer, ResourceBaseSerializer
 
 from .models import Geocollection
 
@@ -11,3 +13,6 @@
         fields = (
             'pk', 'name', 'group', 'resources'
         )
+
+    group = GroupProfileSerializer()
+    resources = DynamicRelationField(ResourceBaseSerializer, embed=True, many=True)

Next Section: Add Translations to geonode-project