Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 42 additions & 1 deletion geonode_mapstore_client/admin.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,48 @@
import os
from django import forms
from django.contrib import admin
from geonode_mapstore_client.models import SearchService
from geonode_mapstore_client.models import SearchService, Extension


@admin.register(SearchService)
class SearchServiceAdmin(admin.ModelAdmin):
pass


class ExtensionAdminForm(forms.ModelForm):
class Meta:
model = Extension
fields = '__all__'

def clean_uploaded_file(self):
"""
It checks the uploaded file's name for uniqueness before the model is saved.
"""
uploaded_file = self.cleaned_data.get('uploaded_file')

if uploaded_file:
extension_name = os.path.splitext(os.path.basename(uploaded_file.name))[0]

queryset = Extension.objects.filter(name=extension_name)

# If we are updating an existing instance, we can exclude it from the check
if self.instance.pk:
queryset = queryset.exclude(pk=self.instance.pk)

# If the queryset finds any conflicting extension, raise a validation error
if queryset.exists():
raise forms.ValidationError(
f"An extension with the name '{extension_name}' already exists. Please upload a file with a different name."
)

return uploaded_file


@admin.register(Extension)
class ExtensionAdmin(admin.ModelAdmin):

form = ExtensionAdminForm
list_display = ('name', 'active', 'is_map_extension', 'updated_at')
list_filter = ('active', 'is_map_extension')
search_fields = ('name',)
readonly_fields = ('name', 'created_at', 'updated_at')
3 changes: 3 additions & 0 deletions geonode_mapstore_client/apps.py
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,9 @@ def run_setup_hooks(*args, **kwargs):
pass

urlpatterns += [
re_path("/client/extension", views.ExtensionsView.as_view(), name="mapstore-extension"),
re_path("/client/pluginsconfig", views.PluginsConfigView.as_view(), name="mapstore-pluginsconfig"),
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I tested /client/pluginsconfig locally but I'm seeing only the uploaded extension in the list, all the static plugins are missing

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I’m currently using this path for the static plugins. Is this different from what it should be, @allyoucanmap?

        base_config_path = os.path.join(
            settings.PROJECT_ROOT, "static", "mapstore", "configs", "pluginsConfig.json"
        )

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@nrjadkry I'm checking again locally, if I access from browser the path /static/mapstore/configs/pluginsConfig.json I have a full list of plugins corresponding to this file:

image

but when I try to access the new endpoint /client/pluginsconfig, I'm seeing only a plugin

image


re_path(
r"^catalogue/",
TemplateView.as_view(
Expand Down
66 changes: 66 additions & 0 deletions geonode_mapstore_client/migrations/0005_extension.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
# Generated by Django 4.2.23 on 2025-10-03 12:14

from django.db import migrations, models
import geonode_mapstore_client.models


class Migration(migrations.Migration):

dependencies = [
("geonode_mapstore_client", "0004_auto_20231114_1705"),
]

operations = [
migrations.CreateModel(
name="Extension",
fields=[
(
"id",
models.AutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"name",
models.CharField(
blank=True,
help_text="Name of the extension, derived from the zip file name. Must be unique.",
max_length=255,
unique=True,
),
),
(
"uploaded_file",
models.FileField(
help_text="Upload the MapStore extension as a zip folder.",
upload_to=geonode_mapstore_client.models.extension_upload_path,
validators=[geonode_mapstore_client.models.validate_zip_file],
),
),
(
"active",
models.BooleanField(
default=True,
help_text="Whether the extension is active and should be included in the index.",
),
),
(
"is_map_extension",
models.BooleanField(
default=False,
help_text="Check if this extension is a map-specific plugin for Map Viewers.",
),
),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
],
options={
"verbose_name": "MapStore Extension",
"verbose_name_plural": "MapStore Extensions",
"ordering": ("name",),
},
),
]
87 changes: 86 additions & 1 deletion geonode_mapstore_client/models.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,21 @@
import os
import shutil
import zipfile
from django.db import models
from django.utils.translation import gettext_lazy as _
from django.contrib.postgres.fields import ArrayField
from django.dispatch import receiver
from django.db.models import signals
from django.core.cache import caches

from django.db import models
from geonode_mapstore_client.utils import validate_zip_file, clear_extension_caches
from geonode_mapstore_client.templatetags.get_search_services import (
populate_search_service_options,
)
from django.conf import settings

# Define the target directory for static extensions
EXTENSIONS_STATIC_DIR = os.path.join(settings.STATIC_ROOT, 'extensions')

class SearchService(models.Model):
class Meta:
Expand Down Expand Up @@ -73,3 +80,81 @@ def post_save_search_service(instance, sender, created, **kwargs):
services_cache.delete("search_services")

services_cache.set("search_services", populate_search_service_options(), 300)



def extension_upload_path(instance, filename):
return f"mapstore_extensions/{filename}"


class Extension(models.Model):
name = models.CharField(
max_length=255,
unique=True,
blank=True, # Will be populated from the zip filename
help_text="Name of the extension, derived from the zip file name. Must be unique.",
)
uploaded_file = models.FileField(
upload_to=extension_upload_path,
validators=[validate_zip_file],
help_text="Upload the MapStore extension as a zip folder.",
)
active = models.BooleanField(
default=True,
help_text="Whether the extension is active and should be included in the index.",
)
is_map_extension = models.BooleanField(
default=False,
help_text="Check if this extension is a map-specific plugin for Map Viewers.",
)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)

def __str__(self):
return self.name

def save(self, *args, **kwargs):
if not self.name and self.uploaded_file:
self.name = os.path.splitext(os.path.basename(self.uploaded_file.name))[0]
super().save(*args, **kwargs)

class Meta:
ordering = ("name",)
verbose_name = "MapStore Extension"
verbose_name_plural = "MapStore Extensions"


@receiver(signals.post_save, sender=Extension)
def handle_extension_upload(sender, instance, **kwargs):
"""
Unzips the extension file and clears the API cache after saving.
"""
os.makedirs(EXTENSIONS_STATIC_DIR, exist_ok=True)
target_path = os.path.join(EXTENSIONS_STATIC_DIR, instance.name)

if os.path.exists(target_path):
shutil.rmtree(target_path)

try:
with zipfile.ZipFile(instance.uploaded_file.path, "r") as zip_ref:
zip_ref.extractall(target_path)
except FileNotFoundError:
pass

clear_extension_caches()


@receiver(signals.post_delete, sender=Extension)
def handle_extension_delete(sender, instance, **kwargs):
"""
Removes the extension's files and clears the API cache on deletion.
"""
if instance.name:
extension_path = os.path.join(EXTENSIONS_STATIC_DIR, instance.name)
if os.path.exists(extension_path):
shutil.rmtree(extension_path)

if instance.uploaded_file and os.path.exists(instance.uploaded_file.path):
os.remove(instance.uploaded_file.path)

clear_extension_caches()
31 changes: 30 additions & 1 deletion geonode_mapstore_client/utils.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,15 @@
import os
import json

import zipfile
from django.core.exceptions import ValidationError
from geoserver.catalog import FailedRequestError
from geonode.geoserver.helpers import gs_catalog
from geonode.layers.models import Dataset
from django.core.cache import cache

MAPSTORE_PLUGINS_CACHE_KEY = "mapstore_plugins_config"
MAPSTORE_EXTENSIONS_CACHE_KEY = "mapstore_extensions_index"
MAPSTORE_EXTENSION_CACHE_TIMEOUT = 60 * 60 * 24 * 1 # 1 day


def set_default_style_to_open_in_visual_mode(instance, **kwargs):
Expand All @@ -25,3 +31,26 @@ def set_default_style_to_open_in_visual_mode(instance, **kwargs):
style.name, resp.status_code, resp.text
)
)


def validate_zip_file(file):
"""
Validates that the uploaded file is a zip and contains the required structure.
"""
if not zipfile.is_zipfile(file):
raise ValidationError("File is not a valid zip archive.")

file.seek(0)
with zipfile.ZipFile(file, 'r') as zip_ref:
filenames = zip_ref.namelist()
required_files = {'index.js', 'index.json'}
if not required_files.issubset(filenames):
raise ValidationError("The zip file must contain index.js and index.json at its root.")
file.seek(0)


def clear_extension_caches():
"""A helper function to clear all MapStore Extension caches."""
cache.delete(MAPSTORE_EXTENSIONS_CACHE_KEY)
cache.delete(MAPSTORE_PLUGINS_CACHE_KEY)
print("MapStore extension caches cleared.")
103 changes: 103 additions & 0 deletions geonode_mapstore_client/views.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,15 @@
import os
import json
from rest_framework.views import APIView
from django.shortcuts import render
from django.http import Http404
from django.utils.translation.trans_real import get_language_from_request
from dateutil import parser
from django.conf import settings
from django.templatetags.static import static
from rest_framework.response import Response
from django.core.cache import cache


def _parse_value(value, schema):
schema_type = schema.get('type')
Expand Down Expand Up @@ -70,3 +78,98 @@ def metadata(request, pk, template="geonode-mapstore-client/metadata.html"):

def metadata_embed(request, pk):
return metadata(request, pk, template="geonode-mapstore-client/metadata_embed.html")



class ExtensionsView(APIView):
permission_classes = []

def get(self, request, *args, **kwargs):
from geonode_mapstore_client.models import Extension
from geonode_mapstore_client.utils import (
MAPSTORE_EXTENSIONS_CACHE_KEY,
MAPSTORE_EXTENSION_CACHE_TIMEOUT,
)

cached_data = cache.get(MAPSTORE_EXTENSIONS_CACHE_KEY)
if cached_data:
return Response(cached_data)

final_extensions = {}
legacy_file_path = os.path.join(
settings.STATIC_ROOT, "geonode", "js", "extensions.json"
)

try:
with open(legacy_file_path, "r") as f:
final_extensions = json.load(f)
except (FileNotFoundError, json.JSONDecodeError):
pass

active_extensions = Extension.objects.filter(active=True)
dynamic_extensions = {}
for ext in active_extensions:
dynamic_extensions[ext.name] = {
"bundle": static(f"extensions/{ext.name}/index.js"),
"translations": static(f"extensions/{ext.name}/translations"),
"assets": static(f"extensions/{ext.name}/assets"),
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

at the moment we have two variables telling the client the location of extensions, to make this work we should change following variables:

  • MAPSTORE_EXTENSIONS_FOLDER_PATH in geonode settings.py
  • EXTENSIONS_FOLDER_PATH in geonode-mapstore-client context_processor.py

now they are "/static/mapstore/extensions/" but they should be an empty string "" if we want to use absolute paths in this endpoint

Copy link

@giohappy giohappy Oct 10, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure I get your point @allyoucanmap, but I guess:

  • MAPSTORE_EXTENSIONS_FOLDER_PATH (and EXTENSIONS_FOLDER_PATH that gets its value from the same settings) are used by extensions bundled with the project. Right?
  • MAPSTORE_EXTENSIONS_FOLDER_PATH should be defined with STATIC_URL
  • the endpoints should use MAPSTORE_EXTENSIONS_FOLDER_PATH (or EXTENSIONS_FOLDER_PATH) to ensure consistency

Am I wrong?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  • MAPSTORE_EXTENSIONS_FOLDER_PATH (and EXTENSIONS_FOLDER_PATH that gets its value from the same settings) are used by extensions bundled with the project. Right?

No, they represent the global location of all the extensions

  • MAPSTORE_EXTENSIONS_FOLDER_PATH should be defined with STATIC_URL
  • the endpoints should use MAPSTORE_EXTENSIONS_FOLDER_PATH (or EXTENSIONS_FOLDER_PATH) to ensure consistency

If we are doing this means MapStore will try to create the request with /static/mapstore/extensions/ (MAPSTORE_EXTENSIONS_FOLDER_PATH) + /static/mapstore/extensions/ExtensionName/index.js (path created in the client/extensions endpoint ).

So if we want always to describe the absolute path inside the extensions configuration MAPSTORE_EXTENSIONS_FOLDER_PATH needs to be an empty string

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I got it now. The client already uses EXTENSIONS_FOLDER_PATH from the geonode config.
As discussed @allyoucanmap we decide to keep the EXTENSIONS_FOLDER_PATH as it is and this PR will be updated to:

  • use the EXTENSIONS_FOLDER_PATH when EXTENSIONS_STATIC_DIR is used
  • the endpoints will use relative paths. i.e.
"bundle": "{ext.name}/index.js"),
"translations": "{ext.name}/translations"),
"assets": "{ext.name}/assets"),
Image

}

final_extensions.update(dynamic_extensions)

cache.set(
MAPSTORE_EXTENSIONS_CACHE_KEY,
final_extensions,
timeout=MAPSTORE_EXTENSION_CACHE_TIMEOUT,
)

return Response(final_extensions)


class PluginsConfigView(APIView):
permission_classes = []

def get(self, request, *args, **kwargs):
from geonode_mapstore_client.models import Extension
from geonode_mapstore_client.utils import (
MAPSTORE_PLUGINS_CACHE_KEY,
MAPSTORE_EXTENSION_CACHE_TIMEOUT,
)

cached_data = cache.get(MAPSTORE_PLUGINS_CACHE_KEY)
if cached_data:
return Response(cached_data)

base_config_path = os.path.join(
settings.PROJECT_ROOT, 'static', 'mapstore', 'configs', 'pluginsConfig.json'
)

config_data = {"plugins": []}

try:
with open(base_config_path, 'r') as f:
config_data = json.load(f)
except (FileNotFoundError, json.JSONDecodeError):
pass

plugins = config_data.get("plugins", [])
existing_plugin_names = {p.get("name") for p in plugins if isinstance(p, dict)}

map_extensions = Extension.objects.filter(active=True, is_map_extension=True)

for ext in map_extensions:
if ext.name not in existing_plugin_names:
plugins.append({
"name": ext.name,
"bundle": static(f'extensions/{ext.name}/index.js'),
"translations": static(f'extensions/{ext.name}/translations'),
"assets": static(f'extensions/{ext.name}/assets'),
})

cache.set(
MAPSTORE_PLUGINS_CACHE_KEY,
config_data,
timeout=MAPSTORE_EXTENSION_CACHE_TIMEOUT,
)

return Response({"plugins": plugins})