Create and configure: Custom resource page

GeoNode allows you to create custom pages that show only a chosen slice of the catalogue. With this approach, you can build a page that has its own title and content, and that displays only dashboards, only resources in a particular category or group, or any other subset that fits your needs.

The walkthrough below builds one such page, a “Custom datasets” landing page that lists only datasets. The same recipe works for any other resource type (maps, geostories, documents, dashboards, …) by changing the defaultQuery filter.

Create the page template

Create a new HTML template named custom_resources_page.html inside the templates/ folder of the my_geonode project.

touch /opt/geonode-project/my_geonode/src/my_geonode/templates/custom_resources_page.html

The project layout after creating the file looks like this:

/opt/geonode-project/my_geonode/src/
|-- ...
|-- my_geonode/
|    |-- ...
|    +-- templates/
|         |-- ...
|         +-- custom_resources_page.html
|-- ...

Open the new template and paste in the following content:

vim /opt/geonode-project/my_geonode/src/my_geonode/templates/custom_resources_page.html
{% extends "geonode-mapstore-client/resource_page_catalog.html" %}
{% load i18n %}
{% block content %}
    {% comment %}
        The i18n template tag allows to import functionality needed to support translations.
        It is also possible to access information about the current language in use with:

            {% get_current_language as LANG %}

        then the LANG variable can be used inside the template, e.g.:

            <div>{{ LANG }}</div>
    {% endcomment %}
    <div class="gn-resource-page-catalog-section">
        <div class="gn-resource-page-catalog-content">
            <h4>{% trans "Custom datasets" %}</h4>
        </div>
    </div>
    <div id="custom-grid" class="ms-plugin ResourcesGrid"></div>
{% endblock content %}

{% block ms_plugins %}
    msPluginsBlocks = [
        {
            "name": "ResourcesGrid",
            "cfg": {
                "id": "catalog",
                "title": "Custom datasets",
                "defaultQuery": {
                    "f": "dataset"
                },
                "targetSelector": "#custom-grid",
                "menuItems": []
            }
        },
        {
            "name": "ResourcesFiltersForm",
            "cfg": {
                "fields": getPageFilterForm()
            }
        }
    ];
{% endblock ms_plugins %}

A few things to notice in the template:

  • It extends geonode-mapstore-client/resource_page_catalog.html, the base template that geonode-mapstore-client ships for catalogue style pages.

  • The content block defines the visible layout: a heading and an empty <div id="custom-grid"> that the MapStore ResourcesGrid plugin fills in.

  • The ms_plugins block declares which MapStore plugins are mounted on the page. ResourcesGrid is bound to #custom-grid and filtered by defaultQuery.f = "dataset", so only datasets are listed. ResourcesFiltersForm adds the filters sidebar.

Wire the page into urls.py

Add the new page to the urlpatterns list of the project’s urls.py.

vim /opt/geonode-project/my_geonode/src/my_geonode/urls.py
from django.views.generic import TemplateView
from django.conf.urls import url

urlpatterns += [
    url('custom_resources_page', view=TemplateView.as_view(template_name='custom_resources_page.html'))
]

Restart the Django process so that the new URL is picked up. If you are running in development mode, the autoreloader does this for you.

Now open the browser and navigate to http://localhost:8000/custom_resources_page. You should see a new page rendering a grid of datasets from the database, with the filters sidebar on the side.

Customize for other resource types

To target a different resource type, change the defaultQuery.f value in the ResourcesGrid configuration. Examples:

Resource type

defaultQuery.f value

Datasets

dataset

Documents

document

Maps

map

GeoStories

geostory

Dashboards

dashboard

You can also combine filters (for example by category, group, or owner) by adding more keys to defaultQuery. The keys mirror the query parameters accepted by the GeoNode resources API.

Pass data from the backend (views.py) to the page

So far the URL has been wired straight to TemplateView, which renders the template with no extra data. When a page needs values that have to be computed on the server (summary counts shown next to the title, the active user’s profile, a feature flag, and so on), replace TemplateView with a subclass that overrides get_context_data and exposes those values through the template context.

Write a CustomPageView in views.py

If the project doesn’t already have a views.py, create one inside the Django app folder.

touch /opt/geonode-project/my_geonode/src/my_geonode/views.py
vim /opt/geonode-project/my_geonode/src/my_geonode/views.py

The example below upgrades the Custom datasets page from the previous sections so it computes the dataset counts that correspond to the My resources, Favorites and Featured filter chips offered by MapStore’s ResourcesGrid, and exposes them as custom_counts in the template context.

from django.db.models import Subquery
from django.views.generic import TemplateView

from geonode.favorite.models import Favorite
from geonode.security.utils import get_resources_with_perms


DATASET_RESOURCE_TYPE = "dataset"


class CustomPageView(TemplateView):
    """Renders the Custom datasets catalogue page.

    Adds the dataset counts for the My resources, Favorites and Featured
    filter chips to the template context, so the same numbers shown in
    the ResourcesGrid filters can also be displayed inline next to the
    page title.
    """

    template_name = "custom_resources_page.html"

    def get_context_data(self, **kwargs):
        context = super().get_context_data(**kwargs)

        user = self.request.user
        datasets = get_resources_with_perms(user).filter(resource_type=DATASET_RESOURCE_TYPE)

        featured_count = datasets.filter(featured=True).count()

        if user.is_authenticated:
            my_resources_count = datasets.filter(owner=user).count()
            favorites_count = datasets.filter(
                pk__in=Subquery(
                    Favorite.objects.filter(
                        user=user,
                        content_type__model=DATASET_RESOURCE_TYPE,
                    ).values_list("object_id", flat=True)
                )
            ).count()
        else:
            # The matching filter chips in the sidebar are disabled for
            # anonymous users, so report zero rather than running queries
            # scoped to a user that isn't there.
            my_resources_count = 0
            favorites_count = 0

        context["custom_counts"] = {
            "my_resources": my_resources_count,
            "favorites": favorites_count,
            "featured": featured_count,
        }
        return context

Things worth pointing out in this example:

  • get_resources_with_perms(user) is the GeoNode helper that returns only the resources the requesting user is allowed to see. It already takes care of permissions and anonymous access, so always start from it instead of querying the model directly. That way the counts you compute will match what MapStore actually shows in the grid.

  • The featured flag is global, so its count is always computed. The My resources and Favorites counts are guarded by user.is_authenticated, because the matching filter chips in the sidebar are disabled for anonymous users anyway.

  • Everything goes into one dictionary key (custom_counts), so the template only has to deal with a single namespace.

  • If you adapt this view for another page, the only project specific bits are DATASET_RESOURCE_TYPE, the featured filter (only datasets and maps carry that flag) and the dictionary key itself. Everything else, get_resources_with_perms, the Subquery(Favorite.objects.…) block, the get_context_data plumbing, can be reused as is.

Point urls.py at CustomPageView

Replace the plain TemplateView.as_view(...) registration created in Wire the page into urls.py with the new view class. Keep the same URL path so that any existing links keep working.

vim /opt/geonode-project/my_geonode/src/my_geonode/urls.py
from django.urls import re_path

from .views import CustomPageView

urlpatterns += [
    re_path(r"^custom_resources_page$", CustomPageView.as_view(), name="custom_resources_page"),
]

The name="custom_resources_page" argument makes the URL reverseable elsewhere in the project via {% url 'custom_resources_page' %} or reverse("custom_resources_page").

Render the context in the template

Inside the template referenced by template_name, every key returned from get_context_data is available as an ordinary Django variable. Open the same custom_resources_page.html you created earlier, add a counts list under the title and drive the grid title from the context as well:

vim /opt/geonode-project/my_geonode/src/my_geonode/templates/custom_resources_page.html
 {% extends "geonode-mapstore-client/resource_page_catalog.html" %}
 {% load i18n %}
 {% block content %}
     <div class="gn-resource-page-catalog-section">
         <div class="gn-resource-page-catalog-content">
             <h4>{% trans "Custom datasets" %}</h4>
+            <ul class="gn-custom-summary">
+                <li>{% trans "My resources" %}: <strong>{{ custom_counts.my_resources }}</strong></li>
+                <li>{% trans "Favorites" %}: <strong>{{ custom_counts.favorites }}</strong></li>
+                <li>{% trans "Featured" %}: <strong>{{ custom_counts.featured }}</strong></li>
+            </ul>
         </div>
     </div>
     <div id="custom-grid" class="ms-plugin ResourcesGrid"></div>
 {% endblock content %}

 {% block ms_plugins %}
     msPluginsBlocks = [
         {
             "name": "ResourcesGrid",
             "cfg": {
                 "id": "catalog",
                 "title": "Custom datasets",
                 "defaultQuery": {
                     "f": "dataset"
                 },
                 "targetSelector": "#custom-grid",
                 "menuItems": []
             }
         },
         {
             "name": "ResourcesFiltersForm",
             "cfg": {
                 "fields": getPageFilterForm()
             }
         }
     ];
 {% endblock ms_plugins %}

A few things to keep in mind:

  • Reading the data is just {{ ... }}. In the view we put a Python dictionary into custom_counts. In the template, you ask for one of its keys with {{ custom_counts.featured }}. The dot works for both attributes and dictionary keys, so dict["featured"] in Python becomes dict.featured here. No extra setup is needed.

  • You can use the same variables inside the ms_plugins block. That block is plain JavaScript that Django renders just like the HTML, so a tag like {{ custom_counts.featured }} works in there too. That’s how we baked the featured count into the grid title above.

    If you ever need to hand a larger chunk of data to the JavaScript (a list, a nested dict, etc.), don’t try to write it inline. Use the built-in json_script filter:

    {{ custom_counts|json_script:"custom-counts-data" }}
    

    Then read it from JavaScript:

    const counts = JSON.parse(document.getElementById("custom-counts-data").textContent);
    

    This is safer (Django escapes everything for you) and keeps the markup readable.

  • The view runs once per page load, not on every click. Context values are great for things that are decided when the page is first opened: who the current user is, what language they prefer, whether a feature is enabled. They are not good for things that change while the user clicks around. If you want numbers that update live (for example, when the user toggles a filter), let MapStore’s ResourcesGrid fetch them from the GeoNode resources API instead of putting them into the context.

Next Section: Configure a new plugin extension