diff --git a/invenio.cfg b/invenio.cfg
index 5482f1e..ae490ee 100644
--- a/invenio.cfg
+++ b/invenio.cfg
@@ -6,6 +6,7 @@ This file was automatically generated by 'invenio-cli init'.
For the full list of settings and their values, see
https://inveniordm.docs.cern.ch/reference/configuration/.
"""
+from invenio_records_resources.services.custom_fields import TextCF, BooleanCF
#required to use custom permissions
from ultraviolet_saml.handlers import acs_handler_factory
from ultraviolet_permissions.policies import UltraVioletPermissionPolicy
@@ -71,6 +72,13 @@ APP_DEFAULT_SECURE_HEADERS = {
"blob:", # for pdf preview
# Add your own policies here (e.g. analytics)
],
+ 'img-src': [
+ "'self'",
+ 'data:',
+ "https://*.basemaps.cartocdn.com",
+ "https://maps-public.geo.nyu.edu",
+ "https://maps-restricted.geo.nyu.edu",
+ ]
},
'content_security_policy_report_only': False,
'content_security_policy_report_uri': None,
@@ -421,6 +429,8 @@ if APP_ENVIRONMENT == 'local':
'adminUV@test.com': 'adminUV'
}
+ RATELIMIT_ENABLED = False
+
# UltraViolet custom vars
# =======================
@@ -451,3 +461,72 @@ NYU_LIBRARIES_HOMEPAGE = 'https://library.nyu.edu'
# Max file size for displaying download buttons and link (50 GB)
MAX_FILE_SIZE = 50 * 1024 * 1024 * 1024
+
+RDM_NAMESPACES = {
+ "geoserver": "https://geoserver.org/"
+}
+
+RDM_CUSTOM_FIELDS = [
+ BooleanCF(name="geoserver:has_wms_layer"),
+ BooleanCF(name="geoserver:has_wfs_layer"),
+ TextCF(name="geoserver:layer_name"),
+ TextCF(name="geoserver:bounds"),
+]
+
+GEOSERVER_PUBLIC_URL = "https://maps-public.geo.nyu.edu/geoserver/sdr"
+GEOSERVER_RESTRICTED_URL = "https://maps-restricted.geo.nyu.edu/geoserver/sdr"
+
+RDM_CUSTOM_FIELDS_UI = [
+ {
+ "section": _("GeoServer"),
+ "hide_from_landing_page": True,
+ "fields": [
+ dict(
+ field="geoserver:layer_name",
+ ui_widget="Input",
+ props=dict(
+ label="Layer Name",
+ placeholder="sdr:nyu_2451_12345",
+ icon="pencil",
+ description="Name of the GeoServer Layer this data can be found in",
+ required=False
+ )
+ ),
+ dict(
+ field="geoserver:has_wms_layer",
+ ui_widget="BooleanCheckbox",
+ props=dict(
+ label="WMS Layer?",
+ icon="check",
+ description="Does this record have a WMS layer in GeoServer?",
+ required=False,
+ trueLabel="Yes",
+ falseLabel="No",
+ )
+ ),
+ dict(
+ field="geoserver:has_wfs_layer",
+ ui_widget="BooleanCheckbox",
+ props=dict(
+ label="WFS Layer?",
+ icon="check",
+ description="Does this record have a WFS layer in GeoServer?",
+ required=False,
+ trueLabel="Yes",
+ falseLabel="No",
+ )
+ ),
+ dict(
+ field="geoserver:bounds",
+ ui_widget="Input",
+ props=dict(
+ label="Bounds",
+ placeholder="ENVELOPE(-178.2176, -66.969275, 71.406235818, 18.921781818)",
+ icon="pencil",
+ description="The envelope for the bounds of this layer",
+ required=False
+ )
+ )
+ ]
+ }
+]
diff --git a/site/ultraviolet/assets/semantic-ui/css/ultraviolet/.gitkeep b/site/ultraviolet/assets/semantic-ui/css/ultraviolet/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/site/ultraviolet/assets/semantic-ui/css/ultraviolet/geoserver.css b/site/ultraviolet/assets/semantic-ui/css/ultraviolet/geoserver.css
new file mode 100644
index 0000000..6747d5e
--- /dev/null
+++ b/site/ultraviolet/assets/semantic-ui/css/ultraviolet/geoserver.css
@@ -0,0 +1,16 @@
+@import "leaflet/dist/leaflet.css";
+@import 'ol/ol.css';
+
+#map {
+ width: 100%;
+ height: 440px;
+ margin-top: 1em;
+ cursor: crosshair !important;
+ border: 1px solid #ced4da;
+ z-index: 99; /* Place map under invenio-menu */
+}
+
+.attributes {
+ max-height: 450px;
+ overflow-y: scroll;
+}
diff --git a/site/ultraviolet/assets/semantic-ui/js/ultraviolet/geoserver.js b/site/ultraviolet/assets/semantic-ui/js/ultraviolet/geoserver.js
new file mode 100644
index 0000000..a9f6564
--- /dev/null
+++ b/site/ultraviolet/assets/semantic-ui/js/ultraviolet/geoserver.js
@@ -0,0 +1,162 @@
+import L from "leaflet"
+import "leaflet/dist/leaflet.css";
+
+const populateAttributeTable = (data) => {
+ const attributesElement = document.getElementById("attributes");
+ attributesElement.innerHTML = '';
+
+ Object.keys(data.properties).sort().forEach(property => {
+ const nameTd = document.createElement('td');
+ nameTd.textContent = property
+ const typeTd = document.createElement("td")
+ typeTd.textContent = data.properties[property]
+
+ const tr = document.createElement("tr")
+ tr.appendChild(nameTd)
+ tr.appendChild(typeTd)
+ attributesElement.appendChild(tr)
+ })
+}
+
+const retrieveAttributeTypes = (wfsUrl, layerNames) => {
+ const attributesElement = document.getElementById("attributes");
+
+ const formData = new FormData();
+ formData.append("url", wfsUrl);
+ formData.append("layers", layerNames);
+
+ fetch("/geoserver/describe_feature_type", {
+ method: "POST", body: formData
+ })
+ .then(response => response.json())
+ .then(data => {
+ const attributes = data.featureTypes[0].properties.sort((a, b) => {
+ return a.name.localeCompare(b.name)
+ })
+
+ attributes.forEach(attribute => {
+ const nameTd = document.createElement('td');
+ nameTd.textContent = attribute.name
+ const typeTd = document.createElement("td")
+ typeTd.textContent = attribute.localType
+
+ const tr = document.createElement("tr")
+ tr.appendChild(nameTd)
+ tr.appendChild(typeTd)
+ attributesElement.appendChild(tr)
+ })
+ })
+ .catch(error => {
+ console.error('Error:', error);
+ });
+};
+
+const addFeatureInspectionHandler = (map, url, layerNames) => {
+ map.on("click", async (e) => {
+ const attributesElement = document.getElementById("attributes");
+ attributesElement.innerHTML = '
Loading... | ';
+
+ try {
+ const response = await fetch("/geoserver/get_feature_info", {
+ method: "POST", headers: {
+ "Content-Type": "application/json",
+ }, body: JSON.stringify({
+ url: url,
+ layers: layerNames,
+ bbox: map.getBounds().toBBoxString(),
+ width: Math.round(document.getElementById("map").clientWidth),
+ height: Math.round(document.getElementById("map").clientHeight),
+ query_layers: layerNames,
+ x: Math.round(e.containerPoint.x),
+ y: Math.round(e.containerPoint.y),
+ }),
+ });
+
+ if (!response.ok) throw new Error("Network response was not ok.");
+
+ const response_data = await response.json();
+
+ if (response_data.hasOwnProperty("error") || response_data.hasOwnProperty('exceptions') || response_data.features.length === 0) {
+ const attributesElement = document.getElementById("attributes");
+ attributesElement.innerHTML = '
No feature found | ';
+
+ return;
+ }
+ const data = response_data.features[0];
+
+ populateAttributeTable(data);
+ } catch (error) {
+ console.error("Fetch error: ", error);
+ }
+ });
+}
+
+const zoomMapToBoundingBox = (map) => {
+ const mapElement = document.getElementById("map");
+ const preview = mapElement.getAttribute("data-preview");
+ const bounds = mapElement.getAttribute("data-bounds")
+
+ const regex = /ENVELOPE\(([-\d.]+), ([-\d.]+), ([-\d.]+), ([-\d.]+)\)/;
+ const match = bounds.match(regex);
+
+ if (match) {
+ let [_, minLon, maxLon, minLat, maxLat] = match;
+
+ const bounds = [[minLat, minLon], [maxLat, maxLon]];
+
+ map.fitBounds(bounds);
+
+ if (preview === "False") {
+ map.addLayer(L.rectangle(bounds, {color: "#3388FF", weight: 3}));
+ }
+ }
+};
+
+const addWmsLayer = (map) => {
+ const mapElement = document.getElementById("map");
+ const baseUrl = mapElement.getAttribute("data-wms-url")
+ const layerName = mapElement.getAttribute("data-layer-name")
+
+ const wmsLayer = L.tileLayer.wms(baseUrl, {
+ layers: layerName,
+ format: 'image/png',
+ transparent: true,
+ opacity: 0.75
+ });
+
+ wmsLayer.addTo(map);
+ wmsLayer.setOpacity(0.75);
+};
+
+const addWfsInspection = map => {
+ const attributesElement = document.getElementById("attributes");
+
+ if (attributesElement) {
+ const wfsUrl = attributesElement.getAttribute("data-wfs-url");
+ const layerNames = attributesElement.getAttribute("data-layer-names");
+
+ addFeatureInspectionHandler(map, wfsUrl, layerNames);
+ retrieveAttributeTypes(wfsUrl, layerNames);
+ }
+};
+
+document.addEventListener("DOMContentLoaded", async () => {
+ const mapElement = document.getElementById("map");
+ const preview = mapElement.getAttribute("data-preview");
+
+ const map = L.map('map').setView([0, 0], 13);
+
+ L.tileLayer('https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{retina}.png', {
+ attribution: '© OpenStreetMap contributors, © Carto',
+ maxZoom: 18,
+ worldCopyJump: true,
+ retina: "@2x",
+ }).addTo(map);
+
+ if (preview === "True") {
+ addWmsLayer(map);
+ addWfsInspection(map);
+ }
+
+ zoomMapToBoundingBox(map);
+});
diff --git a/site/ultraviolet/geoserver/__init__.py b/site/ultraviolet/geoserver/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/site/ultraviolet/geoserver/describe_feature_type.py b/site/ultraviolet/geoserver/describe_feature_type.py
new file mode 100644
index 0000000..132005e
--- /dev/null
+++ b/site/ultraviolet/geoserver/describe_feature_type.py
@@ -0,0 +1,27 @@
+import urllib
+
+from flask import request, Response
+from flask.views import MethodView
+
+import requests
+
+
+class DescribeFeatureType(MethodView):
+ """Proxy for GeoServer DescribeFeatureType requests."""
+
+ def post(self):
+ """Pass DescribeFeatureType requests to GeoServer and hand back results."""
+ url = request.form.get('url', default=None)
+ layers = request.form.get('layers', default=None)
+
+ query_string = urllib.parse.urlencode({
+ "outputFormat": "application/json",
+ "request": "DescribeFeatureType",
+ "service": "WFS",
+ "typeName": layers,
+ "version": "1.1.0",
+ })
+
+ response = requests.get("{0}?{1}".format(url, query_string))
+
+ return Response(response.text, mimetype='application/json')
diff --git a/site/ultraviolet/geoserver/get_feature_info.py b/site/ultraviolet/geoserver/get_feature_info.py
new file mode 100644
index 0000000..c88bfbd
--- /dev/null
+++ b/site/ultraviolet/geoserver/get_feature_info.py
@@ -0,0 +1,35 @@
+import urllib
+
+from flask import request, Response
+from flask.views import MethodView
+
+import requests
+
+
+class GetFeatureInfo(MethodView):
+ """Proxy for GeoServer GetFeatureInfo requests."""
+
+ def post(self):
+ """Pass GetFeatureInfo requests to GeoServer and hand back results."""
+ data = request.get_json()
+
+ url = data.get('url', None)
+ query_string = urllib.parse.urlencode({
+ "service": "WMS",
+ "version": "1.1.1",
+ "request": "GetFeatureInfo",
+ "layers": data.get('layers', None),
+ "query_layers": data.get('layers', None),
+ "bbox": data.get('bbox', None),
+ "width": data.get('width', None),
+ "height": data.get('height', None),
+ "x": data.get('x', None),
+ "y": data.get('y', None),
+ "srs": "EPSG:4326",
+ "info_format": "application/json",
+ "styles": ""
+ })
+
+ response = requests.get("{0}?{1}".format(url, query_string))
+
+ return Response(response.text, mimetype='application/json')
diff --git a/site/ultraviolet/templates/semantic-ui/ultraviolet/records/details/map.html b/site/ultraviolet/templates/semantic-ui/ultraviolet/records/details/map.html
new file mode 100644
index 0000000..17e0348
--- /dev/null
+++ b/site/ultraviolet/templates/semantic-ui/ultraviolet/records/details/map.html
@@ -0,0 +1,55 @@
+{% set previewable = record.access.files == 'public' or (current_user and current_user.is_authenticated) %}
+{% set geoserver_base_url = config.GEOSERVER_PUBLIC_URL if (record.access.files == 'public') else config.GEOSERVER_RESTRICTED_URL %}
+
+{% if record.custom_fields["geoserver:layer_name"] and record.custom_fields["geoserver:has_wms_layer"] %}
+ Map
+
+
+
+ {% if record.custom_fields["geoserver:has_wfs_layer"] and (previewable) %}
+ Attributes
+
+
+
+
+
+ Attribute |
+ Type/Value |
+
+
+
+
+
+ {% endif %}
+
+ {% if previewable %}
+ Web Services
+
+
+
+ {{ record.custom_fields["geoserver:layer_name"] }}
+ {% if record.custom_fields["geoserver:has_wfs_layer"] %}
+
+ {{ geoserver_base_url }}/wfs
+ {% endif %}
+ {% if record.custom_fields["geoserver:has_wms_layer"] %}
+
+ {{ geoserver_base_url }}/wms
+ {% endif %}
+
+ {% endif %}
+{% endif %}
+
+{% block javascript %}
+ {{ webpack['geoserver_js.js'] }}
+ {{ webpack['geoserver_css.css'] }}
+{% endblock %}
diff --git a/site/ultraviolet/views.py b/site/ultraviolet/views.py
index 89f90fa..4e21e38 100644
--- a/site/ultraviolet/views.py
+++ b/site/ultraviolet/views.py
@@ -2,6 +2,9 @@
from flask import Blueprint
+from .geoserver.describe_feature_type import DescribeFeatureType
+from .geoserver.get_feature_info import GetFeatureInfo
+
#
# Registration
#
@@ -13,5 +16,14 @@ def create_blueprint(app):
template_folder="./templates",
)
- # Add URL rules
+ blueprint.add_url_rule(
+ "/geoserver/describe_feature_type",
+ view_func=DescribeFeatureType.as_view("describe_feature_type"),
+ )
+
+ blueprint.add_url_rule(
+ "/geoserver/get_feature_info",
+ view_func=GetFeatureInfo.as_view("get_feature_info"),
+ )
+
return blueprint
diff --git a/site/ultraviolet/webpack.py b/site/ultraviolet/webpack.py
index c277969..d79ecfb 100644
--- a/site/ultraviolet/webpack.py
+++ b/site/ultraviolet/webpack.py
@@ -9,8 +9,13 @@
themes={
"semantic-ui": dict(
entry={
- # Add your webpack entrypoints
+ "geoserver_js": "./js/ultraviolet/geoserver.js",
+ "geoserver_css": "./css/ultraviolet/geoserver.css",
},
+ dependencies={
+ "leaflet": "^1.9.4",
+ "ol": "^10.2.1"
+ }
),
},
)
diff --git a/templates/semantic-ui/invenio_app_rdm/records/details/description.html b/templates/semantic-ui/invenio_app_rdm/records/details/description.html
new file mode 100644
index 0000000..7c364b2
--- /dev/null
+++ b/templates/semantic-ui/invenio_app_rdm/records/details/description.html
@@ -0,0 +1,25 @@
+{#
+ Copyright (C) 2020 CERN.
+ Copyright (C) 2020 Northwestern University.
+ Copyright (C) 2024 New York University.
+
+ Invenio RDM Records is free software; you can redistribute it and/or modify
+ it under the terms of the MIT License; see LICENSE file for more details.
+ #}
+
+{%- from "invenio_app_rdm/records/macros/detail.html" import show_add_descriptions %}
+
+{% set description = metadata.get('description') %}
+{% if description %}
+
+ {{ _('Description') }}
+ {# description data is being sanitized by marshmallow in the backend #}
+
+
{{ description | safe }}
+
+
+{% endif %}
+
+{{ show_add_descriptions(record.ui.additional_descriptions) if record.ui.additional_descriptions }}
+
+{% include "ultraviolet/records/details/map.html" %}
diff --git a/tests/conftest.py b/tests/conftest.py
index 6ac0e8d..f3c79ea 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -10,7 +10,6 @@
# conftest.py
"""Pytest fixtures for ultraviolet testing"""
-
import sys
import pytest
import os
@@ -68,11 +67,13 @@ def app_config(app_config):
}
return app_config
+
# overriding instance path allows us to make sure we use ultraviolet templates
@pytest.fixture(scope="module")
def instance_path():
return os.path.join(sys.prefix, "var", "instance")
+
# Copied from https://github.dev/inveniosoftware/invenio-rdm-records/tree/maint-1.3.x/tests/records
# -*- coding: utf-8 -*-
@@ -132,6 +133,7 @@ def instance_path():
from invenio_vocabularies.records.api import Vocabulary
from invenio_access.models import ActionUsers
+
@pytest.fixture(scope="function")
def full_record(users):
"""Full record data as dict coming from the external world."""
@@ -327,6 +329,26 @@ def minimal_record():
}
+@pytest.fixture()
+def geospatial_record(minimal_record):
+ minimal_record["metadata"]["title"] = "Geospatial Data"
+ minimal_record["custom_fields"] = {
+ "geoserver:has_wms_layer": True,
+ "geoserver:has_wfs_layer": True,
+ "geoserver:layer_name": "sdr:nyu_2451_34156",
+ "geoserver:bounds": "ENVELOPE(-74.2556640887564, -73.700009054899, 40.9157739339836, 40.4960925239255)"
+ }
+
+ return minimal_record
+
+
+@pytest.fixture()
+def restricted_geospatial_record(geospatial_record):
+ geospatial_record["access"]["files"] = "restricted"
+
+ return geospatial_record
+
+
@pytest.fixture()
def client_with_login(app, client, users):
"""Log in a user to the client."""
@@ -654,8 +676,8 @@ def licenses_v(app, licenses):
"tags": ["recommended", "all"],
"description": {
"en": "The Creative Commons Attribution license allows"
- " re-distribution and re-use of a licensed work on"
- " the condition that the creator is appropriately credited."
+ " re-distribution and re-use of a licensed work on"
+ " the condition that the creator is appropriately credited."
},
"type": "licenses",
},
diff --git a/tests/ui/test_map_view.py b/tests/ui/test_map_view.py
new file mode 100644
index 0000000..aeb25e9
--- /dev/null
+++ b/tests/ui/test_map_view.py
@@ -0,0 +1,50 @@
+from invenio_access.permissions import system_identity
+
+
+def test_public_map_view_when_anonymous(services, geospatial_record, client):
+ published_record = publish_draft(geospatial_record, services)
+
+ html = client.get("/records/" + published_record['id']).data.decode("utf-8")
+
+ assert "Geospatial Data" in html
+ assert "Web Services" in html
+
+ assert "data-preview=\"True\"" in html
+ assert "data-wms-url=\"https://maps-public.geo.nyu.edu/geoserver/sdr/wms\"" in html
+ assert "data-layer-name=\"sdr:nyu_2451_34156\"" in html
+ assert "data-bounds=\"ENVELOPE(-74.2556640887564, -73.700009054899, 40.9157739339836, 40.4960925239255)\"" in html
+
+
+def test_restricted_map_view_when_logged_in(services, restricted_geospatial_record, client_with_login):
+ published_record = publish_draft(restricted_geospatial_record, services)
+
+ html = client_with_login.get("/records/" + published_record['id']).data.decode("utf-8")
+
+ assert "Geospatial Data" in html
+ assert "Web Services" in html
+
+ assert "data-preview=\"True\"" in html
+ assert "data-wms-url=\"https://maps-restricted.geo.nyu.edu/geoserver/sdr/wms\"" in html
+ assert "data-layer-name=\"sdr:nyu_2451_34156\"" in html
+ assert "data-bounds=\"ENVELOPE(-74.2556640887564, -73.700009054899, 40.9157739339836, 40.4960925239255)\"" in html
+
+
+def test_restricted_map_view_when_anonymous(services, restricted_geospatial_record, client):
+ published_record = publish_draft(restricted_geospatial_record, services)
+
+ html = client.get("/records/" + published_record['id']).data.decode("utf-8")
+
+ assert "Geospatial Data" in html
+ assert "data-preview=\"False\"" in html
+ assert "data-bounds=\"ENVELOPE(-74.2556640887564, -73.700009054899, 40.9157739339836, 40.4960925239255)\"" in html
+
+ assert "Web Services" not in html
+ assert "data-wms-url=\"https://maps-restricted.geo.nyu.edu/geoserver/sdr/wms\"" not in html
+ assert "data-layer-name=\"sdr:nyu_2451_34156\"" not in html
+
+
+def publish_draft(data, services):
+ draft = services.create(system_identity, data)
+ record = services.publish(system_identity, draft.id)
+
+ return record