From 8deb0d3454bdb190427ceca65813c932e3316c66 Mon Sep 17 00:00:00 2001 From: Jacob Johnson Date: Thu, 28 May 2026 02:00:48 -0600 Subject: [PATCH 01/50] * Added SecureMapService and SecureMapServiceSetting classes * Added middleware to allow apps to require users to be authenticated with Oauth or be redirected to settings to get their account connected * Added GRiD backend for Oauth authentication * Added new basemap capabilities to allow secure map services to be used as basemaps * Added proxy url route and view for secure requests to SecureMapService endpoints with authentication data * Added helper methods for interacting with SecureMapServices and settings --- .gitignore | 1 + pyproject.toml | 1 + tethys_apps/admin.py | 8 + tethys_apps/base/app_base.py | 91 +++++++++++ .../0009_securemapservicesetting.py | 44 +++++ tethys_apps/models.py | 150 ++++++++++++++++++ tethys_apps/urls.py | 7 +- tethys_apps/views.py | 41 ++++- .../tethys_gizmos/js/tethys_map_view.js | 53 ++++++- tethys_layouts/mixins/map_layout.py | 56 +++++++ tethys_portal/middleware.py | 28 ++++ tethys_portal/settings.py | 5 + tethys_sdk/app_settings.py | 1 + tethys_services/admin.py | 72 ++++++++- tethys_services/backends/grid.py | 35 ++++ .../0003_securemapservice_and_more.py | 85 ++++++++++ tethys_services/models.py | 62 ++++++++ .../js/secure_map_service_admin.js | 25 +++ 18 files changed, 760 insertions(+), 5 deletions(-) create mode 100644 tethys_apps/migrations/0009_securemapservicesetting.py create mode 100644 tethys_services/backends/grid.py create mode 100644 tethys_services/migrations/0003_securemapservice_and_more.py create mode 100644 tethys_services/static/tethys_services/js/secure_map_service_admin.js diff --git a/.gitignore b/.gitignore index 615baae503..6799f1edf8 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,7 @@ build/ dist/ docs/_build +*.env *~ tethys_gizmos/static/tethys_gizmos/less/bower_components/* node_modules diff --git a/pyproject.toml b/pyproject.toml index d1c551c263..0b901ad19a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -43,6 +43,7 @@ dependencies = [ "django-model-utils", "django-guardian", "djangorestframework_simplejwt", + "django-fernet-encrypted-fields", ] [project.optional-dependencies] diff --git a/tethys_apps/admin.py b/tethys_apps/admin.py index a5ecd1e44c..e04114b2e4 100644 --- a/tethys_apps/admin.py +++ b/tethys_apps/admin.py @@ -37,6 +37,7 @@ SpatialDatasetServiceSetting, WebProcessingServiceSetting, SchedulerSetting, + SecureMapServiceSetting, PersistentStoreConnectionSetting, PersistentStoreDatabaseSetting, ProxyApp, @@ -297,6 +298,12 @@ def get_queryset(self, request): return qs.filter( dynamic=False ) # Custom form for PersistentStoreDatabaseSetting + + +class SecureMapServiceSettingInline(TethysAppSettingInline): + readonly_fields = ("name", "description", "required") + fields = ("name", "description", "secure_map_service", "required") + model = SecureMapServiceSetting class TethysAppAdmin(GuardedModelAdmin): @@ -329,6 +336,7 @@ class TethysAppAdmin(GuardedModelAdmin): PersistentStoreDatabaseSettingInline, DatasetServiceSettingInline, SpatialDatasetServiceSettingInline, + SecureMapServiceSettingInline, WebProcessingServiceSettingInline, SchedulerSettingInline, TethysAppQuotasSettingInline, diff --git a/tethys_apps/base/app_base.py b/tethys_apps/base/app_base.py index 6c502d1cdc..879bbbe6ed 100644 --- a/tethys_apps/base/app_base.py +++ b/tethys_apps/base/app_base.py @@ -845,6 +845,37 @@ def web_processing_service_settings(self): return wps_services """ return None + + def secure_map_service_settings(self): + """ + Override this method to define secure map service connections for use in your app. + + Returns: + iterable: A list or tuple of ``SecureMapServiceSetting`` objects. + + **Example:** + + :: + + from tethys_sdk.app_settings import SecureMapServiceSetting + + class MyFirstApp(TethysAppBase): + + def secure_map_service_settings(self): + \""" + Example secure_map_service_settings method. + \""" + secure_map_services = ( + SecureMapServiceSetting( + name='primary_secure_map_service', + description='Secure Map Service for app to use', + required=True, + ), + ) + + return secure_map_services + """ + return None def scheduler_settings(self): """ @@ -1860,6 +1891,64 @@ def persistent_store_exists(cls, name): # Check if it exists ps_database_setting.persistent_store_database_exists() return True + + @classmethod + def get_secure_map_service(cls, name, as_endpoint=False, as_layer=False, as_response=False, param_overrides=None, request_user=None): + """ + Retrieves secure map service assigned to named SecureMapServiceSetting for the app. + + Args: + name(str): name of the SecureMapServiceSetting as defined in the app.py. + + Returns: + SecureMapService: SecureMapService assigned to setting. + """ + + from tethys_apps.models import TethysApp + + db_app = TethysApp.objects.get(package=cls.package) + secure_map_service_settings = db_app.secure_map_service_settings + + try: + secure_map_service_setting = secure_map_service_settings.get( + name=name + ) + return secure_map_service_setting.get_value( + as_endpoint=as_endpoint, + as_layer=as_layer, + as_response=as_response, + param_overrides=param_overrides, + request_user=request_user + ) + except ObjectDoesNotExist: + raise TethysAppSettingDoesNotExist( + "SecureMapServiceSetting", name, cls.name + ) + + @classmethod + def update_secure_map_service_setting_params(cls, name, params): + """ + Update the params for a given SecureMapServiceSetting. + + Args: + name(str): name of the SecureMapServiceSetting as defined in the app.py. + params(dict): dictionary of params to update for the setting. + """ + + from tethys_apps.models import TethysApp + + db_app = TethysApp.objects.get(package=cls.package) + secure_map_service_settings = db_app.secure_map_service_settings + + try: + secure_map_service_setting = secure_map_service_settings.get( + name=name + ) + secure_map_service_setting.update_params(params) + except ObjectDoesNotExist: + raise TethysAppSettingDoesNotExist( + "SecureMapServiceSetting", name, cls.name + ) def sync_all_settings(self, db_app): # custom settings @@ -1883,6 +1972,8 @@ def sync_all_settings(self, db_app): list(db_app.persistent_store_connection_settings) + list(db_app.persistent_store_database_settings), ) + # secure map service settings + db_app.sync_settings(self.secure_map_service_settings(), db_app.secure_map_service_settings) # scheduler settings db_app.sync_settings(self.scheduler_settings(), db_app.scheduler_settings) diff --git a/tethys_apps/migrations/0009_securemapservicesetting.py b/tethys_apps/migrations/0009_securemapservicesetting.py new file mode 100644 index 0000000000..ede15f6272 --- /dev/null +++ b/tethys_apps/migrations/0009_securemapservicesetting.py @@ -0,0 +1,44 @@ +# Generated by Django 5.2.14 on 2026-05-28 07:46 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ( + "tethys_apps", + "0008_remove_persistentstoreconnectionsetting_persistent_store_service_and_more", + ), + ("tethys_services", "0003_securemapservice_and_more"), + ] + + operations = [ + migrations.CreateModel( + name="SecureMapServiceSetting", + fields=[ + ( + "tethysappsetting_ptr", + models.OneToOneField( + auto_created=True, + on_delete=django.db.models.deletion.CASCADE, + parent_link=True, + primary_key=True, + serialize=False, + to="tethys_apps.tethysappsetting", + ), + ), + ( + "secure_map_service", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + to="tethys_services.securemapservice", + ), + ), + ], + bases=("tethys_apps.tethysappsetting",), + ), + ] diff --git a/tethys_apps/models.py b/tethys_apps/models.py index 2fe79c10f5..0d5bd7b1c8 100644 --- a/tethys_apps/models.py +++ b/tethys_apps/models.py @@ -7,21 +7,25 @@ * License: BSD 2-Clause ******************************************************************************** """ +from urllib.parse import urlencode from django.dispatch import receiver import logging import uuid import json +import requests from django.db import models from django.core.exceptions import ValidationError from django.contrib.auth.models import Permission from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.fields import GenericForeignKey +from django.shortcuts import reverse from model_utils.managers import InheritanceManager from tethys_apps.exceptions import ( TethysAppSettingNotAssigned, PersistentStoreInitializerError, ) +from tethys_sdk.gizmos import MVLayer from tethys_apps.base.mixins import TethysBaseMixin from tethys_compute.models.condor.condor_scheduler import CondorScheduler from tethys_compute.models.dask.dask_scheduler import DaskScheduler @@ -48,6 +52,7 @@ DatasetService, SpatialDatasetService, WebProcessingService, + SecureMapService, ) except RuntimeError: # pragma: no cover log.exception("An error occurred while trying to import tethys service models.") @@ -164,6 +169,13 @@ def persistent_store_database_settings(self): return self.settings_set.exclude( persistentstoredatabasesetting__isnull=True ).select_subclasses("persistentstoredatabasesetting") + + @property + def secure_map_service_settings(self): + return self.settings_set.exclude( + securemapservicesetting__isnull=True + ).select_subclasses("securemapservicesetting") + @property def configured(self): @@ -1104,6 +1116,144 @@ def create_persistent_store_database(self, refresh=False, force_first_time=False self.save() +class SecureMapServiceSetting(TethysAppSetting): + """ + Used to define a Secure Map Service Setting. + + Attributes: + name(str): Unique name used to identify the setting. + legend_title(str): The title to use for the legend when this service is added as a layer on a map. + endpoint(str): The endpoint URL for the secure map service. + authentication_method(str): The method used for authentication (e.g., "API Key", "OAuth"). + api_key(str): The API key used for authentication with the secure map service. + oauth_provider(str): The OAuth provider to use for authentication if the secure map service uses OAuth2 for authentication. + service_type(str): The type of map service (e.g., "WMS", "GML"). + params(dict): Additional parameters to include in requests to the secure map service. + use_proxy(bool): Whether to route requests through a proxy endpoint to handle authentication instead of sending credentials directly from the client. + description(str): Short description of the setting. + required(bool): A value will be required if True. + + **Example:** + + :: + + from tethys_sdk.app_settings import SecureMapServiceSetting + + secure_map_service_setting = SecureMapServiceSetting( + name='secure_map_service', + description='Secure Map service for app to use', + required=True, + ) + + """ + + secure_map_service = models.ForeignKey( + SecureMapService, on_delete=models.CASCADE, blank=True, null=True + ) + + def clean(self): + """ + Validate prior to saving changes. + """ + if not self.secure_map_service and self.required: + raise ValidationError("Required.") + + def generate_request(self, param_overrides=None): + """ + Generate a request to the secure map service, including any necessary authentication headers or parameters. + """ + if not self.secure_map_service: + raise TethysAppSettingNotAssigned( + f"Cannot generate request for SecureMapServiceSetting " + f'"{self.name}" for app "{self.tethys_app.package}": ' + f"no SecureMapService assigned." + ) + service = self.secure_map_service + endpoint = service.endpoint + params = service.params or {} + if param_overrides: + params.update(param_overrides) + + if service.use_proxy: + endpoint = reverse("secure_map_proxy", kwargs={"setting_id": service.pk}) + return endpoint + + params = service.get_resolved_params() + # If the API key is not already included in the params, add it + # This allows for the API key to be included in the params with a placeholder (e.g. ${api_key}) + # or to be assigned to a different parameter name if the service expects it that way + if service.authentication_method == "api_key" and service.api_key not in params.values(): + params["api_key"] = service.api_key + + query_string = urlencode(params) + url = f"{endpoint}?{query_string}" if query_string else endpoint + return url + + def build_layer(self, param_overrides=None, request_user=None): + endpoint = self.generate_request(param_overrides=param_overrides) + service = self.secure_map_service + options = {'url': endpoint} + if not service.use_proxy and service.authentication_method == "oauth": + options['token'] = service.get_oauth_token(request_user) + return MVLayer( + source=service.service_type, + layer_options={"visible": True}, + options=options, + legend_title=service.legend_title, + data={ + "show_legend": True, + "layer_id": service.pk + } + ) + + def fetch_response(self, param_overrides=None, request_user=None): + if not self.secure_map_service: + raise TethysAppSettingNotAssigned( + f"Cannot fetch response for SecureMapServiceSetting " + f'"{self.name}" for app "{self.tethys_app.package}": ' + f"no SecureMapService assigned." + ) + service = self.secure_map_service + params = dict(service.get_resolved_params() or {}) + if param_overrides: + params.update(param_overrides) + + headers = {} + if service.authentication_method == "oauth": + if not request_user: + raise ValueError("Request user must be provided to fetch response for OAuth authenticated service.") + headers['Authorization'] = f"Bearer {service.get_oauth_token(request_user)}" + + resp = requests.get(service.endpoint, params=params, headers=headers) + resp.raise_for_status() + return resp + + + + def get_value(self, as_endpoint=False, as_layer=False, as_response=False, param_overrides=None, request_user=None): + if as_endpoint: + return self.generate_request(param_overrides=param_overrides) + elif as_layer: + return self.build_layer(param_overrides=param_overrides, request_user=request_user) + elif as_response: + return self.fetch_response(param_overrides=param_overrides, request_user=request_user) + else: + return self.secure_map_service + + def update_params(self, new_params): + if not self.secure_map_service: + raise TethysAppSettingNotAssigned( + f"Cannot update params for SecureMapServiceSetting " + f'"{self.name}" for app "{self.tethys_app.package}": ' + f"no SecureMapService assigned." + ) + service = self.secure_map_service + params = service.params or {} + params.update(new_params) + service.params = params + service.save() + + class SchedulerSetting(TethysAppSetting): """ Used to define a Scheduler setting for Job processing services like HTCondor and Dask. diff --git a/tethys_apps/urls.py b/tethys_apps/urls.py index 6c315e00bf..105e98e34c 100644 --- a/tethys_apps/urls.py +++ b/tethys_apps/urls.py @@ -12,7 +12,7 @@ from django.urls import include, re_path from channels.routing import URLRouter from tethys_apps.harvester import SingletonHarvester -from tethys_apps.views import library, send_beta_feedback_email +from tethys_apps.views import library, send_beta_feedback_email, secure_map_proxy from tethys_apps.utilities import get_configured_standalone_app from django.conf import settings from django.views.generic.base import RedirectView @@ -24,6 +24,11 @@ re_path( r"^send-beta-feedback/$", send_beta_feedback_email, name="send_beta_feedback" ), + re_path( + r"^secure-map-proxy/(?P\d+)/$", + secure_map_proxy, + name="secure_map_proxy" + ) ] url_namespaces = None diff --git a/tethys_apps/views.py b/tethys_apps/views.py index e777c39662..a502208d83 100644 --- a/tethys_apps/views.py +++ b/tethys_apps/views.py @@ -9,9 +9,10 @@ """ import logging +import requests from django.shortcuts import render -from django.http import HttpResponse, JsonResponse +from django.http import HttpResponse, JsonResponse, StreamingHttpResponse from django.core.mail import send_mail from tethys_config.models import get_custom_template @@ -150,3 +151,41 @@ def send_beta_feedback_email(request): json = {"success": True, "result": "Emails sent to specified developers"} return JsonResponse(json) + +@login_required() +def secure_map_proxy(request, setting_id): + """ + Proxy view for securely accessing map services with credentials stored in Tethys Services or OAuth. + """ + from tethys_services.models import SecureMapService + + try: + service = SecureMapService.objects.get(id=setting_id) + except SecureMapService.DoesNotExist: + return HttpResponse("Service setting not found.", status=404) + + resolved_service_params = service.get_resolved_params() + + if service.service_type == "wms": + browser_params = { + key: value for key, value in request.GET.items() + } + params = {**resolved_service_params, **browser_params} + else: + params = resolved_service_params + + headers = {} + if service.authentication_method == "oauth": + access_token = service.get_oauth_token(request.user) + if not access_token: + return HttpResponse("Failed to retrieve OAuth token.", status=500) + headers["Authorization"] = f"Bearer {access_token}" + + + resp = requests.get(service.endpoint, params=params, headers=headers, stream=True) + + return StreamingHttpResponse( + resp.iter_content(chunk_size=8192), + status=resp.status_code, + content_type=resp.headers.get('Content-Type', 'application/octet-stream') + ) diff --git a/tethys_gizmos/static/tethys_gizmos/js/tethys_map_view.js b/tethys_gizmos/static/tethys_gizmos/js/tethys_map_view.js index ad5c7b3ec4..6506b9f9c3 100644 --- a/tethys_gizmos/static/tethys_gizmos/js/tethys_map_view.js +++ b/tethys_gizmos/static/tethys_gizmos/js/tethys_map_view.js @@ -236,6 +236,13 @@ ol_base_map_init = function() default_source_options: {}, label_property: null, }, + 'WMS': { + source_class: function(options) { + return new ol.source.TileWMS(options); + }, + default_source_options: {}, + label_property: null, + }, } if (is_defined(m_disable_base_map) && m_disable_base_map) { @@ -792,7 +799,8 @@ ol_layers_init = function() { // Constants var GEOJSON = 'GeoJSON', - KML = 'KML'; + KML = 'KML', + GML = 'GML'; var TILE_SOURCES = ['TileDebug', 'TileUTFGrid', 'UrlTile', 'TileImage', 'VectorTile', 'BingMaps', 'TileArcGISRest', 'TileJSON', 'TileWMS', 'WMTS', 'XYZ', 'Zoomify', 'CartoDB', 'OSM']; @@ -800,7 +808,7 @@ ol_layers_init = function() var IMAGE_SOURCES = ['ImageArcGISRest', 'ImageCanvas', 'ImageMapGuide', 'ImageStatic', 'ImageWMS', 'ImageVector', 'Raster']; - var VECTOR_SOURCES = ['GeoJSON', 'KML', 'Vector', 'Cluster']; + var VECTOR_SOURCES = ['GeoJSON', 'KML', 'GML', 'Vector', 'Cluster']; var STYLE_MAP = { 'fill' : ol.style.Fill, @@ -1017,6 +1025,47 @@ ol_layers_init = function() current_layer_layer_options['source'] = kml_source; layer = new ol.layer.Vector(current_layer_layer_options); } + } else if (current_layer.source === GML) { + // TODO look into different GML formats + let gmlFormat = new ol.format.WFS({ + version: '1.1.0', + gmlFormat: new ol.format.GML3(), + }); + + if (current_layer.options.hasOwnProperty('url')) { + let url = current_layer.options.url; + let gml_source = new ol.source.Vector(); + + let headers = {}; + if (current_layer.options.token) { + headers['Authorization'] = 'Bearer ' + current_layer.options.token; + } + fetch(url, {credentials: 'same-origin', headers: headers}) + .then(r => r.text()) + .then(text => { + let features = gmlFormat.readFeatures(text, { + dataProjection: 'EPSG:4326', + featureProjection: DEFAULT_PROJECTION, + }); + console.log('GML loaded:', features.length, 'features'); + gml_source.addFeatures(features); + }) + .catch(err => console.error('GML load failed:', err)); + + current_layer_layer_options['source'] = gml_source; + layer = new ol.layer.Vector(current_layer_layer_options); + } + + else if (current_layer.options.hasOwnProperty('gml')) { + let gml_source = new ol.source.Vector({ + features: gmlFormat.readFeatures(current_layer.options.gml, { + dataProjection: 'EPSG:4326', + featureProjection: DEFAULT_PROJECTION, + }), + }); + current_layer_layer_options['source'] = gml_source; + layer = new ol.layer.Vector(current_layer_layer_options); + } } // Generic vector case diff --git a/tethys_layouts/mixins/map_layout.py b/tethys_layouts/mixins/map_layout.py index c50c519726..56f604a60d 100644 --- a/tethys_layouts/mixins/map_layout.py +++ b/tethys_layouts/mixins/map_layout.py @@ -869,6 +869,62 @@ def build_arc_gis_layer( return mv_layer + @classmethod + def build_gml_layer( + cls, + endpoint, + layer_name, + layer_title, + layer_variable, + layer_id=None, + visible=True, + selectable=False, + extent=None, + public=True, + renamable=False, + removable=False, + show_legend=True, + legend_url=None, + ): + """ + Build a GML Map Server MVLayer object with supplied arguments. + + Args: + endpoint(str): Full ArcGIS REST URL for the layer (e.g.: "https://sampleserver1.arcgisonline.com/ArcGIS/rest/services/Specialty/ESRI_StateCityHighway_USA/MapServer"). + layer_name(str): Programmatic name of the layer (e.g.: "ESRI_StateCityHighway_USA"). + layer_title(str): Title of layer to display in Layer Picker (e.g.: "ESRI Highways"). + layer_variable(str): Variable type/class of the layer (e.g.: "highways"). + layer_id(UUID, int, str): layer_id for non geoserver layer where layer_name may not be unique. + visible(bool): Layer is visible when True. Defaults to True. + public(bool): Layer is publicly accessible when app is running in Open Portal Mode if True. Defaults to True. + extent(list): Extent for the layer. Optional. + renamable(bool): Show Rename option in layer context menu when True. Must implement the appropriate method to persist the change. Defaults to False. + removable(bool): Show Remove option in layer context menu when True. Must implement the appropriate method to persist the change. Defaults to False. + show_legend(bool): Show the legend for this layer when True and legends are enabled. Defaults to True. + legend_url(str): URL of a legend image to display for the layer when legends are enabled. + """ + + mv_layer = cls._build_mv_layer( + layer_id=layer_id, + layer_name=layer_name, + layer_source="GML", + layer_title=layer_title, + layer_variable=layer_variable, + options={ + "url": endpoint, + }, + extent=extent, + visible=visible, + public=public, + selectable=selectable, + renamable=renamable, + removable=removable, + show_legend=show_legend, + legend_url=legend_url, + ) + + return mv_layer + @classmethod def build_custom_layer( cls, diff --git a/tethys_portal/middleware.py b/tethys_portal/middleware.py index 52fce23d84..86ece0a557 100644 --- a/tethys_portal/middleware.py +++ b/tethys_portal/middleware.py @@ -12,9 +12,11 @@ from django.contrib import messages from django.core.exceptions import PermissionDenied from django.shortcuts import redirect +from django.urls import reverse from tethys_cli.cli_colors import pretty_output, FG_WHITE from tethys_apps.utilities import get_active_app, user_can_access_app from tethys_portal.views.error import handler_404 +from urllib.parse import urlencode from tethys_portal.optional_dependencies import optional_import, has_module @@ -155,3 +157,29 @@ def __call__(self, request): response = self.get_response(request) return response +class TethysOauthRequiredMiddleware: + def __init__(self, get_response): + self.get_response = get_response + self.requirements = getattr(settings, "OAUTH_REQUIREMENTS", {}) + + def __call__(self, request): + if not self.requirements: + return self.get_response(request) + + app = get_active_app(request) + if app is None: + return self.get_response(request) + + app_name = app.package + required_provider = self.requirements.get(app_name) + if not required_provider: + return self.get_response(request) + + if request.user.social_auth.filter(provider=required_provider).exists(): + return self.get_response(request) + + messages.info(request, f"This application requires authenticating with {required_provider}. Please link your {required_provider} account.") + next_param = urlencode({'next': request.get_full_path()}) + + settings_url = reverse('user:settings') + return redirect(f"{settings_url}?{next_param}") diff --git a/tethys_portal/settings.py b/tethys_portal/settings.py index 2c0057a10d..08ab96b3d3 100644 --- a/tethys_portal/settings.py +++ b/tethys_portal/settings.py @@ -21,6 +21,7 @@ """ # Build paths inside the project like this: BASE_DIR / '...' +import os import sys import yaml import logging @@ -285,6 +286,7 @@ "tethys_portal.middleware.TethysMfaRequiredMiddleware", "django.middleware.clickjacking.XFrameOptionsMiddleware", "tethys_portal.middleware.TethysAppAccessMiddleware", + "tethys_portal.middleware.TethysOauthRequiredMiddleware", ], ) if has_module("corsheaders"): @@ -707,3 +709,6 @@ def get__all__(mod): # Add any additional specified settings to module for setting, value in portal_config_settings.items(): setattr(this_module, setting, value) + +# Encryption keys +FERNET_KEYS = [os.environ.get("FERNET_KEY", None)] \ No newline at end of file diff --git a/tethys_sdk/app_settings.py b/tethys_sdk/app_settings.py index 8f31391219..0126ad04e1 100644 --- a/tethys_sdk/app_settings.py +++ b/tethys_sdk/app_settings.py @@ -20,5 +20,6 @@ SchedulerSetting, PersistentStoreConnectionSetting, PersistentStoreDatabaseSetting, + SecureMapServiceSetting, TethysAppSettingNotAssigned, ) diff --git a/tethys_services/admin.py b/tethys_services/admin.py index 93d2d6137f..63f8e7acd0 100644 --- a/tethys_services/admin.py +++ b/tethys_services/admin.py @@ -8,16 +8,27 @@ ******************************************************************************** """ +from django.conf import settings from django.contrib import admin +from django.utils.module_loading import import_string from django.utils.translation import gettext_lazy as _ from .models import ( DatasetService, + SecureMapService, SpatialDatasetService, WebProcessingService, PostgresPersistentStoreService, SQLitePersistentStoreService, ) -from django.forms import ModelForm, PasswordInput +from django.forms import ModelForm, PasswordInput, ChoiceField +from tethys_portal.optional_dependencies import ( + optional_import, + has_module, +) + +JSONEditorWidget = optional_import( + "JSONEditorWidget", from_module="django_json_widget.widgets" +) class DatasetServiceForm(ModelForm): @@ -81,6 +92,52 @@ class Meta: fields = ("name", "engine", "dir_path") +class SecureMapServiceForm(ModelForm): + class Meta: + model = SecureMapService + fields = "__all__" + labels = { + "name": _("Name"), + "endpoint": _("Endpoint"), + "api_key": _("API Key"), + "oauth_provider": _("OAuth Provider"), + "params": _("Parameters"), + "legend_title": _("Legend Title"), + "service_type": _("Service Type"), + "use_proxy": _("Use Proxy for Requests"), + } + + widgets = { + "api_key": PasswordInput(render_value=True), + } + + options_default = { + "modes": ["code", "text"], + "search": False, + "navigationBar": False, + } + + if has_module("django_json_widget"): + widgets["params"] = JSONEditorWidget( + width="60%", + height="300px", + options=options_default + ) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + choices = [] + if settings.AUTHENTICATION_BACKENDS: + for backend in settings.AUTHENTICATION_BACKENDS: + backend_class = import_string(backend) + if hasattr(backend_class, "name"): + choices.append((backend_class.name, backend_class.name)) + self.fields["oauth_provider"] = ChoiceField( + choices=choices, + required=False, + ) + + class DatasetServiceAdmin(admin.ModelAdmin): """ Admin model for Web Processing Service Model @@ -142,8 +199,21 @@ class SQLitePersistentStoreServiceAdmin(admin.ModelAdmin): fields = ("name", "engine", "dir_path") +class SecureMapServiceAdmin(admin.ModelAdmin): + """ + Admin model for Secure Map Service Model + """ + + form = SecureMapServiceForm + fields = ("name", "endpoint", "legend_title", "authentication_method", "api_key", "oauth_provider", "service_type", "use_proxy", "params") + + class Media: + js = ("tethys_services/js/secure_map_service_admin.js",) + + admin.site.register(DatasetService, DatasetServiceAdmin) admin.site.register(SpatialDatasetService, SpatialDatasetServiceAdmin) admin.site.register(WebProcessingService, WebProcessingServiceAdmin) admin.site.register(PostgresPersistentStoreService, PostgresPersistentStoreServiceAdmin) admin.site.register(SQLitePersistentStoreService, SQLitePersistentStoreServiceAdmin) +admin.site.register(SecureMapService, SecureMapServiceAdmin) diff --git a/tethys_services/backends/grid.py b/tethys_services/backends/grid.py new file mode 100644 index 0000000000..bcf9482f94 --- /dev/null +++ b/tethys_services/backends/grid.py @@ -0,0 +1,35 @@ +from social_core.backends.oauth import BaseOAuth2 + +class GRiDOAuth2(BaseOAuth2): + """ + GRiD OAuth2 authentication backend. + """ + auth_server_hostname = "grid.nga.mil" + http_scheme = "https" + name = "grid" + + auth_server_full_url = "{0}://{1}".format(http_scheme, auth_server_hostname) + AUTHORIZATION_URL = "{0}/grid/api/authorize".format(auth_server_full_url) + ACCESS_TOKEN_URL = "{0}/grid/api/token".format(auth_server_full_url) + ACCESS_TOKEN_METHOD = "POST" + + REDIRECT_STATE = False + + DEFAULT_SCOPE = ["api"] + + SCOPE_SEPARATOR = "," + + + def user_data(self, access_token, *args, **kwargs): + return self.get_json( + f"{self.auth_server_full_url}/grid/api/user", # ← replace with Grid's real endpoint + headers={"Authorization": f"Bearer {access_token}"}, + ) + + def get_user_details(self, response): + return { + "username": response.get("username", ""), + "email": response.get("email", ""), + "first_name": response.get("first_name", ""), + "last_name": response.get("last_name", ""), + } \ No newline at end of file diff --git a/tethys_services/migrations/0003_securemapservice_and_more.py b/tethys_services/migrations/0003_securemapservice_and_more.py new file mode 100644 index 0000000000..33c1c0247d --- /dev/null +++ b/tethys_services/migrations/0003_securemapservice_and_more.py @@ -0,0 +1,85 @@ +# Generated by Django 5.2.14 on 2026-05-28 07:46 + +import encrypted_fields.fields +import tethys_services.models +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("tethys_services", "0002_postgrespersistentstoreservice_and_more"), + ] + + operations = [ + migrations.CreateModel( + name="SecureMapService", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("name", models.CharField(max_length=30, unique=True)), + ("legend_title", models.CharField(max_length=100, unique=True)), + ( + "endpoint", + models.CharField( + max_length=1024, + validators=[tethys_services.models.validate_url], + ), + ), + ( + "authentication_method", + models.CharField( + blank=True, + choices=[("api_key", "API Key"), ("oauth", "OAuth")], + max_length=100, + ), + ), + ( + "api_key", + encrypted_fields.fields.EncryptedTextField(blank=True, null=True), + ), + ("oauth_provider", models.CharField(blank=True, max_length=100)), + ( + "service_type", + models.CharField( + choices=[ + ("ImageWMS", "WMS"), + ("GML", "GML"), + ("geojson", "GeoJSON"), + ], + default="ImageWMS", + max_length=50, + ), + ), + ("params", models.JSONField(blank=True, default=dict, null=True)), + ("use_proxy", models.BooleanField(default=False)), + ], + options={ + "verbose_name": "Secure Map Service", + "verbose_name_plural": "Secure Map Services", + }, + ), + migrations.AlterField( + model_name="postgrespersistentstoreservice", + name="engine", + field=models.CharField( + choices=[("postgresql", "PostgreSQL")], + default="postgresql", + max_length=50, + ), + ), + migrations.AlterField( + model_name="sqlitepersistentstoreservice", + name="engine", + field=models.CharField( + choices=[("sqlite", "SQLite")], default="sqlite", max_length=50 + ), + ), + ] diff --git a/tethys_services/models.py b/tethys_services/models.py index 759dc4b4a2..eee8eb3e08 100644 --- a/tethys_services/models.py +++ b/tethys_services/models.py @@ -12,6 +12,8 @@ from django.core.exceptions import ObjectDoesNotExist, ValidationError from urllib.error import HTTPError, URLError import os +from encrypted_fields.fields import EncryptedTextField +from string import Template from tethys_portal.optional_dependencies import optional_import, has_module @@ -402,3 +404,63 @@ def get_engine(self, spatial=False): def get_url(self): db_file = os.path.join(self.dir_path, f"{self.database}.sqlite") return f"sqlite:///{db_file}" + +class SecureMapService(models.Model): + """ + ORM for Secure Map Service settings. + """ + + name = models.CharField(max_length=30, unique=True) + legend_title= models.CharField(max_length=100, unique=True) + endpoint = models.CharField(max_length=1024, validators=[validate_url]) + authentication_method = models.CharField(max_length=100, blank=True, choices=[("api_key", "API Key"), ("oauth", "OAuth")]) + api_key = EncryptedTextField(blank=True, null=True) + oauth_provider = models.CharField(max_length=100, blank=True) + service_type = models.CharField(max_length=50, choices=[("ImageWMS", "WMS"), ("GML", "GML"), ("geojson", "GeoJSON")], default="ImageWMS") + params = models.JSONField(blank=True, null=True, default=dict) + use_proxy = models.BooleanField(default=False) # Hide API key in requests + + class Meta: + verbose_name = "Secure Map Service" + verbose_name_plural = "Secure Map Services" + + def __str__(self): + return self.name + + @classmethod + def get_authentication_method_options(cls): + return [value for value, _ in cls._meta.get_field('authentication_method').choices] + + def get_oauth_token(self, user): + if self.authentication_method != "oauth": + raise ValueError("Authentication method must be 'oauth' to retrieve an OAuth token.") + if not self.oauth_provider: + raise ValueError("OAuth provider must be specified to retrieve an OAuth token.") + + try: + auth = user.social_auth.get(provider=self.oauth_provider) + except ObjectDoesNotExist: + raise ValueError(f"User not linked to {self.oauth_provider}.") + + access_token = auth.extra_data.get('access_token') + if not access_token: + raise ValueError("No access token found for user.") + + return access_token + + + def get_resolved_params(self): + if not self.params: + return {} + + safe_attribute_names = { + f.name: str(getattr(self, f.name, '') or '') + for f in self._meta.fields + } + + resolved_params = {} + for key, value in self.params.items(): + if isinstance(value, str): + value = Template(value).safe_substitute(safe_attribute_names) + resolved_params[key] = value + return resolved_params diff --git a/tethys_services/static/tethys_services/js/secure_map_service_admin.js b/tethys_services/static/tethys_services/js/secure_map_service_admin.js new file mode 100644 index 0000000000..21650c93ac --- /dev/null +++ b/tethys_services/static/tethys_services/js/secure_map_service_admin.js @@ -0,0 +1,25 @@ +document.addEventListener("DOMContentLoaded", function () { + const authMethodField = document.getElementById("id_authentication_method") + const apiKeyRow = document.querySelector(".form-row.field-api_key") + const oauthProviderRow = document.querySelector(".form-row.field-oauth_provider") + function updateFields() { + const method = authMethodField.value + + if (method === "api_key") { + console.log("API Key authentication selected") + apiKeyRow.style.display = "" + oauthProviderRow.style.display = "none" + } else if (method === "oauth") { + console.log("OAuth authentication selected") + oauthProviderRow.style.display = "" + apiKeyRow.style.display = "none" + } else { + console.log("No authentication selected") + apiKeyRow.style.display = "none" + oauthProviderRow.style.display = "none" + } + } + + authMethodField.addEventListener("change", updateFields) + updateFields() +}) \ No newline at end of file From a8d844ded609bb3c603dec29bf36689697a66a0d Mon Sep 17 00:00:00 2001 From: Jacob Johnson Date: Mon, 6 Jul 2026 14:41:23 -0600 Subject: [PATCH 02/50] * Added missing dependency to environment.yml * Added better error logging in fetch_response util method * Updated secure map service proxy controller to accept any kind of request, not just GET * Added form_id and draw attributes to MapLayout to allow for drawing and passing geometry to forms * Fixed user_data in grid backend --- environment.yml | 1 + tethys_apps/models.py | 9 +++++++++ tethys_apps/views.py | 13 +++++++++++-- tethys_layouts/views/map_layout.py | 6 ++++++ tethys_services/backends/grid.py | 5 +---- 5 files changed, 28 insertions(+), 6 deletions(-) diff --git a/environment.yml b/environment.yml index 3f1ea2dc5f..09277f9b27 100644 --- a/environment.yml +++ b/environment.yml @@ -70,6 +70,7 @@ dependencies: - django-json-widget # enable json widget for app settings - djangorestframework # enable REST API framework - djangorestframework_simplejwt # JWT authentication for REST API + - django-fernet-encrypted-fields # enable encrypted fields for models # Map Layout - PyShp diff --git a/tethys_apps/models.py b/tethys_apps/models.py index 0d5bd7b1c8..8384b6711b 100644 --- a/tethys_apps/models.py +++ b/tethys_apps/models.py @@ -1225,6 +1225,15 @@ def fetch_response(self, param_overrides=None, request_user=None): headers['Authorization'] = f"Bearer {service.get_oauth_token(request_user)}" resp = requests.get(service.endpoint, params=params, headers=headers) + if not resp.ok: + log.error( + f"SecureMapService with name {service.name} request failed: \n" + f"url: {service.endpoint}\n" + f"params: {params}\n" + f"headers: {headers}\n" + f"status_code: {resp.status_code}\n" + f"response_text: {resp.text}" + ) resp.raise_for_status() return resp diff --git a/tethys_apps/views.py b/tethys_apps/views.py index a502208d83..b4ff371e8a 100644 --- a/tethys_apps/views.py +++ b/tethys_apps/views.py @@ -166,7 +166,7 @@ def secure_map_proxy(request, setting_id): resolved_service_params = service.get_resolved_params() - if service.service_type == "wms": + if service.service_type == "ImageWMS": browser_params = { key: value for key, value in request.GET.items() } @@ -181,8 +181,17 @@ def secure_map_proxy(request, setting_id): return HttpResponse("Failed to retrieve OAuth token.", status=500) headers["Authorization"] = f"Bearer {access_token}" + if request.content_type: + headers["Content-Type"] = request.content_type - resp = requests.get(service.endpoint, params=params, headers=headers, stream=True) + resp = requests.request( + method=request.method, + url=service.endpoint, + params=params, + headers=headers, + data=request.body if request.body else None, + stream=True + ) return StreamingHttpResponse( resp.iter_content(chunk_size=8192), diff --git a/tethys_layouts/views/map_layout.py b/tethys_layouts/views/map_layout.py index 04f05a7a69..1fe949fdd5 100644 --- a/tethys_layouts/views/map_layout.py +++ b/tethys_layouts/views/map_layout.py @@ -54,12 +54,14 @@ class MapLayout(TethysLayout, MapLayoutMixin): cesium_ion_token (str): Cesium Ion API token. Required if map_type is "cesium_map_view". See: https://cesium.com/learn/cesiumjs-learn/cesiumjs-quickstart/ default_disable_basemap (bool) Set to True to disable the basemap. default_map_extent = The default BBOX extent for the map. Defaults to [-65.69, 23.81, -129.17, 49.38]. + draw (MVDraw): An MVDraw object enabling the drawing toolbar on the map. Defaults to None (no drawing). enforce_permissions (bool): Enables permissions checks when True. Defaults to False. geocode_api_key (str): An Open Cage Geocoding API key. Required to enable address search/geocoding feature. See: https://opencagedata.com/api#quickstart geocode_extent (4-list): Bounding box defining search area for address search feature (e.g.: [-65.69, 23.81, -129.17, 49.38]). Alternatively, set to 'map-extent' to use map extent. geoserver_workspace (str): Name of the GeoServer workspace of layers if applicable. Defaults to None. feature_selection_multiselect (bool): Set to True to enable multi-selection when feature selection is enabled. Defaults to False. feature_selection_sensitivity (int): Feature selection sensitivity/relative search radius. Defaults to 4. + form_id (str): The id of a form on the page to which the hidden geometry text field will be linked. layer_tab_name (str) Name of the "Layers" tab. Defaults to "Layers". map_subtitle (str): The subtitle to display on the MapLayout view. map_title (str): The title to display on the MapLayout view. @@ -95,12 +97,14 @@ class MapLayout(TethysLayout, MapLayoutMixin): cesium_ion_token = None default_disable_basemap = False default_map_extent = [-65.69, 23.81, -129.17, 49.38] # USA EPSG:4326 + draw = None geocode_api_key = None enforce_permissions = False geocode_extent = None geoserver_workspace = "" feature_selection_multiselect = False feature_selection_sensitivity = 4 + form_id = None layer_tab_name = "Layers" map_type = "tethys_map_view" max_zoom = 28 @@ -476,6 +480,8 @@ def _build_map_view(self, request, view, extent, *args, **kwargs): basemap=self.basemaps, legend=False, show_clicks=self.show_map_clicks, + draw=self.draw, + form_id=self.form_id ) # Configure initial basemap visibility diff --git a/tethys_services/backends/grid.py b/tethys_services/backends/grid.py index bcf9482f94..abb01bc2c1 100644 --- a/tethys_services/backends/grid.py +++ b/tethys_services/backends/grid.py @@ -21,10 +21,7 @@ class GRiDOAuth2(BaseOAuth2): def user_data(self, access_token, *args, **kwargs): - return self.get_json( - f"{self.auth_server_full_url}/grid/api/user", # ← replace with Grid's real endpoint - headers={"Authorization": f"Bearer {access_token}"}, - ) + return {"access_token": access_token} def get_user_details(self, response): return { From f0c88ddd196988aa9a6fdd45ddb4e4bc0bb34fe6 Mon Sep 17 00:00:00 2001 From: Jacob Johnson Date: Thu, 30 Jul 2026 10:37:30 -0600 Subject: [PATCH 03/50] * Fixed tests * Formatting fixes --- docs/conf.py | 4 +- .../unit_tests/test_tethys_apps/test_admin.py | 24 ++++++--- .../test_base/test_app_base.py | 4 +- .../test_tethys_cli/test_gen_commands.py | 32 ++++++++--- tethys_apps/admin.py | 16 ++++-- tethys_apps/base/app_base.py | 36 +++++++------ tethys_apps/base/page_handler.py | 10 +++- tethys_apps/db_handlers.py | 4 +- tethys_apps/models.py | 53 +++++++++++-------- tethys_apps/urls.py | 8 +-- tethys_apps/views.py | 13 +++-- tethys_layouts/mixins/map_layout.py | 2 +- tethys_layouts/views/map_layout.py | 2 +- tethys_portal/middleware.py | 19 ++++--- tethys_portal/settings.py | 2 +- tethys_services/admin.py | 22 +++++--- tethys_services/models.py | 47 +++++++++------- 17 files changed, 190 insertions(+), 108 deletions(-) diff --git a/docs/conf.py b/docs/conf.py index e711cdce9d..5076571673 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -199,7 +199,9 @@ def __getattr__(cls, name): rst_epilog = """ .. |branch| replace:: {branch} -""".format(branch=branch) +""".format( + branch=branch +) # List of patterns, relative to source directory, that match files and # directories to ignore when looking for source files. diff --git a/tests/unit_tests/test_tethys_apps/test_admin.py b/tests/unit_tests/test_tethys_apps/test_admin.py index ac92323757..58c5de6941 100644 --- a/tests/unit_tests/test_tethys_apps/test_admin.py +++ b/tests/unit_tests/test_tethys_apps/test_admin.py @@ -13,6 +13,7 @@ JSONCustomSettingInline, DatasetServiceSettingInline, SpatialDatasetServiceSettingInline, + SecureMapServiceSettingInline, WebProcessingServiceSettingInline, PersistentStoreConnectionSettingInline, PersistentStoreConnectionSettingForm, @@ -514,6 +515,7 @@ def test_TethysAppAdmin(self): PersistentStoreDatabaseSettingInline, DatasetServiceSettingInline, SpatialDatasetServiceSettingInline, + SecureMapServiceSettingInline, WebProcessingServiceSettingInline, SchedulerSettingInline, TethysAppQuotasSettingInline, @@ -546,12 +548,16 @@ def test_TethysAppAdmin_manage_app_storage(self, mock_convert, mock_get_quota): mock_get_quota.return_value = {"quota": None} url = reverse("admin:clear_workspace", kwargs={"app_id": app.id}) - expected_html = format_html(""" + expected_html = format_html( + """ {} of {} Clear Workspace - """.format("0 bytes", "∞", url=url)) + """.format( + "0 bytes", "∞", url=url + ) + ) actual_html = ret.manage_app_storage(app) self.assertEqual(expected_html.replace(" ", ""), actual_html.replace(" ", "")) @@ -560,12 +566,16 @@ def test_TethysAppAdmin_manage_app_storage(self, mock_convert, mock_get_quota): mock_get_quota.return_value = {"quota": 5, "units": "gb"} url = reverse("admin:clear_workspace", kwargs={"app_id": app.id}) - expected_html = format_html(""" + expected_html = format_html( + """ {} of {} Clear Workspace - """.format("0 bytes", "0 bytes", url=url)) + """.format( + "0 bytes", "0 bytes", url=url + ) + ) actual_html = ret.manage_app_storage(app) self.assertEqual(expected_html.replace(" ", ""), actual_html.replace(" ", "")) @@ -576,14 +586,16 @@ def test_TethysAppAdmin_remove_app(self): app = mock.MagicMock() app.id = 1 - expected_html = format_html(""" + expected_html = format_html( + """ Remove App - """) + """ + ) actual_html = ret.remove_app(app) self.assertEqual(expected_html.replace(" ", ""), actual_html.replace(" ", "")) diff --git a/tests/unit_tests/test_tethys_apps/test_base/test_app_base.py b/tests/unit_tests/test_tethys_apps/test_base/test_app_base.py index b486721bf8..037cdbdd21 100644 --- a/tests/unit_tests/test_tethys_apps/test_base/test_app_base.py +++ b/tests/unit_tests/test_tethys_apps/test_base/test_app_base.py @@ -1472,7 +1472,7 @@ def test_sync_with_tethys_db( self.assertTrue(mock_ta().save.call_count == 2) # Check if add_settings is called 6 times - self.assertTrue(mock_ta().sync_settings.call_count == 6) + self.assertTrue(mock_ta().sync_settings.call_count == 7) mock_sync_cookies.assert_called_once_with( mock_path().path.__truediv__(), "p", "n" ) @@ -1492,7 +1492,7 @@ def test_sync_with_tethys_db_in_db(self, mock_ta, mock_ds): self.assertTrue(mock_app.save.call_count == 2) # Check if add_settings is called 6 times - self.assertTrue(mock_app.sync_settings.call_count == 6) + self.assertTrue(mock_app.sync_settings.call_count == 7) @mock.patch("django.conf.settings") @mock.patch("tethys_apps.models.TethysApp") diff --git a/tests/unit_tests/test_tethys_cli/test_gen_commands.py b/tests/unit_tests/test_tethys_cli/test_gen_commands.py index 3a53205465..eaa902828e 100644 --- a/tests/unit_tests/test_tethys_cli/test_gen_commands.py +++ b/tests/unit_tests/test_tethys_cli/test_gen_commands.py @@ -961,7 +961,9 @@ def test_parse_setup_py(): import textwrap # Write a fake setup.py into the temp folder - setup_path.write_text(textwrap.dedent(""" + setup_path.write_text( + textwrap.dedent( + """ app_package = 'test_app' from setuptools import setup @@ -973,7 +975,9 @@ def test_parse_setup_py(): keywords=['alpha', 'beta'], license='MIT', ) - """)) + """ + ) + ) metadata = parse_setup_py(setup_path) @@ -1010,7 +1014,9 @@ def test_parse_setup_py_invalid_package_name(mock_exit, mock_write_warning): import textwrap # Write a fake setup.py into the temp folder - setup_path.write_text(textwrap.dedent(""" + setup_path.write_text( + textwrap.dedent( + """ app_package = fake_function() from setuptools import setup @@ -1022,7 +1028,9 @@ def test_parse_setup_py_invalid_package_name(mock_exit, mock_write_warning): keywords=['alpha', 'beta'], license='MIT', ) - """)) + """ + ) + ) with pytest.raises(SystemExit): parse_setup_py(setup_path) @@ -1045,7 +1053,9 @@ def test_parse_setup_py_no_app_package(mock_exit, mock_write_warning): import textwrap # Write a fake setup.py into the temp folder - setup_path.write_text(textwrap.dedent(""" + setup_path.write_text( + textwrap.dedent( + """ from setuptools import setup setup( @@ -1055,7 +1065,9 @@ def test_parse_setup_py_no_app_package(mock_exit, mock_write_warning): keywords=['alpha', 'beta'], license='MIT', ) - """)) + """ + ) + ) with pytest.raises(SystemExit): parse_setup_py(setup_path) @@ -1076,7 +1088,9 @@ def test_parse_setup_py_invalid_setup_attr(mock_exit, mock_write_warning): import textwrap # Write a fake setup.py into the temp folder - setup_path.write_text(textwrap.dedent(""" + setup_path.write_text( + textwrap.dedent( + """ from setuptools import setup app_package = 'test_app' @@ -1088,7 +1102,9 @@ def test_parse_setup_py_invalid_setup_attr(mock_exit, mock_write_warning): keywords=['alpha', 'beta'], license='MIT', ) - """)) + """ + ) + ) with pytest.raises(SystemExit): parse_setup_py(setup_path) diff --git a/tethys_apps/admin.py b/tethys_apps/admin.py index 9dc1fd63a7..a5d3f3bc97 100644 --- a/tethys_apps/admin.py +++ b/tethys_apps/admin.py @@ -298,7 +298,7 @@ def get_queryset(self, request): return qs.filter( dynamic=False ) # Custom form for PersistentStoreDatabaseSetting - + class SecureMapServiceSettingInline(TethysAppSettingInline): readonly_fields = ("name", "description", "required") @@ -360,23 +360,29 @@ def manage_app_storage(self, app): url = reverse("admin:clear_workspace", kwargs={"app_id": app.id}) - return format_html(""" + return format_html( + """ {} of {} Clear Workspace - """.format(current_use, quota, url=url)) + """.format( + current_use, quota, url=url + ) + ) def remove_app(self, app): url = reverse("admin:remove_app", kwargs={"app_id": app.id}) - return format_html(f""" + return format_html( + f""" Remove App - """) + """ + ) class TethysExtensionAdmin(GuardedModelAdmin): diff --git a/tethys_apps/base/app_base.py b/tethys_apps/base/app_base.py index 879bbbe6ed..150a8d3525 100644 --- a/tethys_apps/base/app_base.py +++ b/tethys_apps/base/app_base.py @@ -845,7 +845,7 @@ def web_processing_service_settings(self): return wps_services """ return None - + def secure_map_service_settings(self): """ Override this method to define secure map service connections for use in your app. @@ -1891,9 +1891,17 @@ def persistent_store_exists(cls, name): # Check if it exists ps_database_setting.persistent_store_database_exists() return True - + @classmethod - def get_secure_map_service(cls, name, as_endpoint=False, as_layer=False, as_response=False, param_overrides=None, request_user=None): + def get_secure_map_service( + cls, + name, + as_endpoint=False, + as_layer=False, + as_response=False, + param_overrides=None, + request_user=None, + ): """ Retrieves secure map service assigned to named SecureMapServiceSetting for the app. @@ -1910,21 +1918,19 @@ def get_secure_map_service(cls, name, as_endpoint=False, as_layer=False, as_resp secure_map_service_settings = db_app.secure_map_service_settings try: - secure_map_service_setting = secure_map_service_settings.get( - name=name - ) + secure_map_service_setting = secure_map_service_settings.get(name=name) return secure_map_service_setting.get_value( - as_endpoint=as_endpoint, - as_layer=as_layer, + as_endpoint=as_endpoint, + as_layer=as_layer, as_response=as_response, param_overrides=param_overrides, - request_user=request_user + request_user=request_user, ) except ObjectDoesNotExist: raise TethysAppSettingDoesNotExist( "SecureMapServiceSetting", name, cls.name ) - + @classmethod def update_secure_map_service_setting_params(cls, name, params): """ @@ -1941,9 +1947,7 @@ def update_secure_map_service_setting_params(cls, name, params): secure_map_service_settings = db_app.secure_map_service_settings try: - secure_map_service_setting = secure_map_service_settings.get( - name=name - ) + secure_map_service_setting = secure_map_service_settings.get(name=name) secure_map_service_setting.update_params(params) except ObjectDoesNotExist: raise TethysAppSettingDoesNotExist( @@ -1973,7 +1977,9 @@ def sync_all_settings(self, db_app): + list(db_app.persistent_store_database_settings), ) # secure map service settings - db_app.sync_settings(self.secure_map_service_settings(), db_app.secure_map_service_settings) + db_app.sync_settings( + self.secure_map_service_settings(), db_app.secure_map_service_settings + ) # scheduler settings db_app.sync_settings(self.scheduler_settings(), db_app.scheduler_settings) @@ -2008,7 +2014,6 @@ def sync_with_tethys_db(self): show_in_apps_library=self.show_in_apps_library, ) db_app.save() - self.sync_all_settings(db_app) # If the app is in the database, update developer priority attributes @@ -2016,7 +2021,6 @@ def sync_with_tethys_db(self): db_app = db_apps[0] db_app.index = self.index db_app.root_url = self.root_url - self.sync_all_settings(db_app) # In debug mode, update all fields, not just developer priority attributes diff --git a/tethys_apps/base/page_handler.py b/tethys_apps/base/page_handler.py index 4be549a133..9d87df0a6a 100644 --- a/tethys_apps/base/page_handler.py +++ b/tethys_apps/base/page_handler.py @@ -77,14 +77,20 @@ def page_component_wrapper(app, user, layout, page_func, extras=None): page_obj = lib.html.div( lib.html.script(key=str(uuid4()), type="importmap")(lib.get_importmap()), page_obj, - (lib.html.script(""" + ( + lib.html.script( + """ setTimeout(() => { const loadingRoot = document.getElementById("loading-root"); if (loadingRoot) { loadingRoot.style.display = "none"; } }, 1000); - """) if hide_loading else None), + """ + ) + if hide_loading + else None + ), ) return page_obj diff --git a/tethys_apps/db_handlers.py b/tethys_apps/db_handlers.py index 9fff022933..b99ffbeb31 100644 --- a/tethys_apps/db_handlers.py +++ b/tethys_apps/db_handlers.py @@ -69,7 +69,9 @@ def drop_database(self, model): FROM pg_stat_activity WHERE pg_stat_activity.datname = '{0}' AND pg_stat_activity.pid <> pg_backend_pid(); - """.format(namespaced_ps_name) + """.format( + namespaced_ps_name + ) if drop_connection: drop_connection.execute(disconnect_sessions_statement) diff --git a/tethys_apps/models.py b/tethys_apps/models.py index 8384b6711b..351c1906b4 100644 --- a/tethys_apps/models.py +++ b/tethys_apps/models.py @@ -7,6 +7,7 @@ * License: BSD 2-Clause ******************************************************************************** """ + from urllib.parse import urlencode from django.dispatch import receiver @@ -169,14 +170,13 @@ def persistent_store_database_settings(self): return self.settings_set.exclude( persistentstoredatabasesetting__isnull=True ).select_subclasses("persistentstoredatabasesetting") - + @property def secure_map_service_settings(self): return self.settings_set.exclude( securemapservicesetting__isnull=True ).select_subclasses("securemapservicesetting") - @property def configured(self): required_settings = [s for s in self.settings if s.required] @@ -1157,7 +1157,7 @@ def clean(self): """ if not self.secure_map_service and self.required: raise ValidationError("Required.") - + def generate_request(self, param_overrides=None): """ Generate a request to the secure map service, including any necessary authentication headers or parameters. @@ -1177,12 +1177,15 @@ def generate_request(self, param_overrides=None): if service.use_proxy: endpoint = reverse("secure_map_proxy", kwargs={"setting_id": service.pk}) return endpoint - + params = service.get_resolved_params() # If the API key is not already included in the params, add it # This allows for the API key to be included in the params with a placeholder (e.g. ${api_key}) # or to be assigned to a different parameter name if the service expects it that way - if service.authentication_method == "api_key" and service.api_key not in params.values(): + if ( + service.authentication_method == "api_key" + and service.api_key not in params.values() + ): params["api_key"] = service.api_key query_string = urlencode(params) @@ -1192,20 +1195,17 @@ def generate_request(self, param_overrides=None): def build_layer(self, param_overrides=None, request_user=None): endpoint = self.generate_request(param_overrides=param_overrides) service = self.secure_map_service - options = {'url': endpoint} + options = {"url": endpoint} if not service.use_proxy and service.authentication_method == "oauth": - options['token'] = service.get_oauth_token(request_user) + options["token"] = service.get_oauth_token(request_user) return MVLayer( source=service.service_type, layer_options={"visible": True}, options=options, legend_title=service.legend_title, - data={ - "show_legend": True, - "layer_id": service.pk - } + data={"show_legend": True, "layer_id": service.pk}, ) - + def fetch_response(self, param_overrides=None, request_user=None): if not self.secure_map_service: raise TethysAppSettingNotAssigned( @@ -1221,9 +1221,11 @@ def fetch_response(self, param_overrides=None, request_user=None): headers = {} if service.authentication_method == "oauth": if not request_user: - raise ValueError("Request user must be provided to fetch response for OAuth authenticated service.") - headers['Authorization'] = f"Bearer {service.get_oauth_token(request_user)}" - + raise ValueError( + "Request user must be provided to fetch response for OAuth authenticated service." + ) + headers["Authorization"] = f"Bearer {service.get_oauth_token(request_user)}" + resp = requests.get(service.endpoint, params=params, headers=headers) if not resp.ok: log.error( @@ -1237,18 +1239,27 @@ def fetch_response(self, param_overrides=None, request_user=None): resp.raise_for_status() return resp - - - def get_value(self, as_endpoint=False, as_layer=False, as_response=False, param_overrides=None, request_user=None): + def get_value( + self, + as_endpoint=False, + as_layer=False, + as_response=False, + param_overrides=None, + request_user=None, + ): if as_endpoint: return self.generate_request(param_overrides=param_overrides) elif as_layer: - return self.build_layer(param_overrides=param_overrides, request_user=request_user) + return self.build_layer( + param_overrides=param_overrides, request_user=request_user + ) elif as_response: - return self.fetch_response(param_overrides=param_overrides, request_user=request_user) + return self.fetch_response( + param_overrides=param_overrides, request_user=request_user + ) else: return self.secure_map_service - + def update_params(self, new_params): if not self.secure_map_service: raise TethysAppSettingNotAssigned( diff --git a/tethys_apps/urls.py b/tethys_apps/urls.py index 105e98e34c..ca575c26da 100644 --- a/tethys_apps/urls.py +++ b/tethys_apps/urls.py @@ -25,10 +25,10 @@ r"^send-beta-feedback/$", send_beta_feedback_email, name="send_beta_feedback" ), re_path( - r"^secure-map-proxy/(?P\d+)/$", - secure_map_proxy, - name="secure_map_proxy" - ) + r"^secure-map-proxy/(?P\d+)/$", + secure_map_proxy, + name="secure_map_proxy", + ), ] url_namespaces = None diff --git a/tethys_apps/views.py b/tethys_apps/views.py index b4ff371e8a..7acefff70d 100644 --- a/tethys_apps/views.py +++ b/tethys_apps/views.py @@ -152,6 +152,7 @@ def send_beta_feedback_email(request): json = {"success": True, "result": "Emails sent to specified developers"} return JsonResponse(json) + @login_required() def secure_map_proxy(request, setting_id): """ @@ -163,17 +164,15 @@ def secure_map_proxy(request, setting_id): service = SecureMapService.objects.get(id=setting_id) except SecureMapService.DoesNotExist: return HttpResponse("Service setting not found.", status=404) - + resolved_service_params = service.get_resolved_params() if service.service_type == "ImageWMS": - browser_params = { - key: value for key, value in request.GET.items() - } + browser_params = {key: value for key, value in request.GET.items()} params = {**resolved_service_params, **browser_params} else: params = resolved_service_params - + headers = {} if service.authentication_method == "oauth": access_token = service.get_oauth_token(request.user) @@ -190,11 +189,11 @@ def secure_map_proxy(request, setting_id): params=params, headers=headers, data=request.body if request.body else None, - stream=True + stream=True, ) return StreamingHttpResponse( resp.iter_content(chunk_size=8192), status=resp.status_code, - content_type=resp.headers.get('Content-Type', 'application/octet-stream') + content_type=resp.headers.get("Content-Type", "application/octet-stream"), ) diff --git a/tethys_layouts/mixins/map_layout.py b/tethys_layouts/mixins/map_layout.py index 56f604a60d..f4f7cbf3f8 100644 --- a/tethys_layouts/mixins/map_layout.py +++ b/tethys_layouts/mixins/map_layout.py @@ -924,7 +924,7 @@ def build_gml_layer( ) return mv_layer - + @classmethod def build_custom_layer( cls, diff --git a/tethys_layouts/views/map_layout.py b/tethys_layouts/views/map_layout.py index 1fe949fdd5..223456c39a 100644 --- a/tethys_layouts/views/map_layout.py +++ b/tethys_layouts/views/map_layout.py @@ -481,7 +481,7 @@ def _build_map_view(self, request, view, extent, *args, **kwargs): legend=False, show_clicks=self.show_map_clicks, draw=self.draw, - form_id=self.form_id + form_id=self.form_id, ) # Configure initial basemap visibility diff --git a/tethys_portal/middleware.py b/tethys_portal/middleware.py index 86ece0a557..53efae685e 100644 --- a/tethys_portal/middleware.py +++ b/tethys_portal/middleware.py @@ -157,19 +157,21 @@ def __call__(self, request): response = self.get_response(request) return response + + class TethysOauthRequiredMiddleware: def __init__(self, get_response): self.get_response = get_response self.requirements = getattr(settings, "OAUTH_REQUIREMENTS", {}) - + def __call__(self, request): if not self.requirements: return self.get_response(request) - + app = get_active_app(request) if app is None: return self.get_response(request) - + app_name = app.package required_provider = self.requirements.get(app_name) if not required_provider: @@ -177,9 +179,12 @@ def __call__(self, request): if request.user.social_auth.filter(provider=required_provider).exists(): return self.get_response(request) - - messages.info(request, f"This application requires authenticating with {required_provider}. Please link your {required_provider} account.") - next_param = urlencode({'next': request.get_full_path()}) - settings_url = reverse('user:settings') + messages.info( + request, + f"This application requires authenticating with {required_provider}. Please link your {required_provider} account.", + ) + next_param = urlencode({"next": request.get_full_path()}) + + settings_url = reverse("user:settings") return redirect(f"{settings_url}?{next_param}") diff --git a/tethys_portal/settings.py b/tethys_portal/settings.py index 08ab96b3d3..c38bca7c2b 100644 --- a/tethys_portal/settings.py +++ b/tethys_portal/settings.py @@ -711,4 +711,4 @@ def get__all__(mod): setattr(this_module, setting, value) # Encryption keys -FERNET_KEYS = [os.environ.get("FERNET_KEY", None)] \ No newline at end of file +FERNET_KEYS = [os.environ.get("FERNET_KEY", None)] diff --git a/tethys_services/admin.py b/tethys_services/admin.py index 63f8e7acd0..f6bd22e150 100644 --- a/tethys_services/admin.py +++ b/tethys_services/admin.py @@ -97,8 +97,8 @@ class Meta: model = SecureMapService fields = "__all__" labels = { - "name": _("Name"), - "endpoint": _("Endpoint"), + "name": _("Name"), + "endpoint": _("Endpoint"), "api_key": _("API Key"), "oauth_provider": _("OAuth Provider"), "params": _("Parameters"), @@ -106,7 +106,7 @@ class Meta: "service_type": _("Service Type"), "use_proxy": _("Use Proxy for Requests"), } - + widgets = { "api_key": PasswordInput(render_value=True), } @@ -119,9 +119,7 @@ class Meta: if has_module("django_json_widget"): widgets["params"] = JSONEditorWidget( - width="60%", - height="300px", - options=options_default + width="60%", height="300px", options=options_default ) def __init__(self, *args, **kwargs): @@ -205,7 +203,17 @@ class SecureMapServiceAdmin(admin.ModelAdmin): """ form = SecureMapServiceForm - fields = ("name", "endpoint", "legend_title", "authentication_method", "api_key", "oauth_provider", "service_type", "use_proxy", "params") + fields = ( + "name", + "endpoint", + "legend_title", + "authentication_method", + "api_key", + "oauth_provider", + "service_type", + "use_proxy", + "params", + ) class Media: js = ("tethys_services/js/secure_map_service_admin.js",) diff --git a/tethys_services/models.py b/tethys_services/models.py index eee8eb3e08..ace7e1a5fd 100644 --- a/tethys_services/models.py +++ b/tethys_services/models.py @@ -405,20 +405,27 @@ def get_url(self): db_file = os.path.join(self.dir_path, f"{self.database}.sqlite") return f"sqlite:///{db_file}" + class SecureMapService(models.Model): """ ORM for Secure Map Service settings. """ name = models.CharField(max_length=30, unique=True) - legend_title= models.CharField(max_length=100, unique=True) + legend_title = models.CharField(max_length=100, unique=True) endpoint = models.CharField(max_length=1024, validators=[validate_url]) - authentication_method = models.CharField(max_length=100, blank=True, choices=[("api_key", "API Key"), ("oauth", "OAuth")]) + authentication_method = models.CharField( + max_length=100, blank=True, choices=[("api_key", "API Key"), ("oauth", "OAuth")] + ) api_key = EncryptedTextField(blank=True, null=True) oauth_provider = models.CharField(max_length=100, blank=True) - service_type = models.CharField(max_length=50, choices=[("ImageWMS", "WMS"), ("GML", "GML"), ("geojson", "GeoJSON")], default="ImageWMS") + service_type = models.CharField( + max_length=50, + choices=[("ImageWMS", "WMS"), ("GML", "GML"), ("geojson", "GeoJSON")], + default="ImageWMS", + ) params = models.JSONField(blank=True, null=True, default=dict) - use_proxy = models.BooleanField(default=False) # Hide API key in requests + use_proxy = models.BooleanField(default=False) # Hide API key in requests class Meta: verbose_name = "Secure Map Service" @@ -426,38 +433,42 @@ class Meta: def __str__(self): return self.name - + @classmethod def get_authentication_method_options(cls): - return [value for value, _ in cls._meta.get_field('authentication_method').choices] - + return [ + value for value, _ in cls._meta.get_field("authentication_method").choices + ] + def get_oauth_token(self, user): if self.authentication_method != "oauth": - raise ValueError("Authentication method must be 'oauth' to retrieve an OAuth token.") + raise ValueError( + "Authentication method must be 'oauth' to retrieve an OAuth token." + ) if not self.oauth_provider: - raise ValueError("OAuth provider must be specified to retrieve an OAuth token.") - + raise ValueError( + "OAuth provider must be specified to retrieve an OAuth token." + ) + try: auth = user.social_auth.get(provider=self.oauth_provider) except ObjectDoesNotExist: raise ValueError(f"User not linked to {self.oauth_provider}.") - - access_token = auth.extra_data.get('access_token') + + access_token = auth.extra_data.get("access_token") if not access_token: raise ValueError("No access token found for user.") - - return access_token + return access_token def get_resolved_params(self): if not self.params: return {} - + safe_attribute_names = { - f.name: str(getattr(self, f.name, '') or '') - for f in self._meta.fields + f.name: str(getattr(self, f.name, "") or "") for f in self._meta.fields } - + resolved_params = {} for key, value in self.params.items(): if isinstance(value, str): From 1d69e0faf57f0a7ca4dc38519fd486ab02eb6310 Mon Sep 17 00:00:00 2001 From: Jacob Johnson Date: Thu, 30 Jul 2026 10:37:43 -0600 Subject: [PATCH 04/50] More formatting fixes --- tethys_services/backends/grid.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/tethys_services/backends/grid.py b/tethys_services/backends/grid.py index abb01bc2c1..ffda295751 100644 --- a/tethys_services/backends/grid.py +++ b/tethys_services/backends/grid.py @@ -1,9 +1,11 @@ from social_core.backends.oauth import BaseOAuth2 + class GRiDOAuth2(BaseOAuth2): """ GRiD OAuth2 authentication backend. """ + auth_server_hostname = "grid.nga.mil" http_scheme = "https" name = "grid" @@ -19,9 +21,8 @@ class GRiDOAuth2(BaseOAuth2): SCOPE_SEPARATOR = "," - def user_data(self, access_token, *args, **kwargs): - return {"access_token": access_token} + return {"access_token": access_token} def get_user_details(self, response): return { @@ -29,4 +30,4 @@ def get_user_details(self, response): "email": response.get("email", ""), "first_name": response.get("first_name", ""), "last_name": response.get("last_name", ""), - } \ No newline at end of file + } From 3f4faba7779608ca8519d3bb3c04cb6c439775ae Mon Sep 17 00:00:00 2001 From: Jacob Johnson Date: Fri, 31 Jul 2026 16:50:22 -0600 Subject: [PATCH 05/50] * Fixed bug where required apps weren't disabled with unassigned secure map service settings * Updated portal_config.yml file to have a generated SALT_KEY to use for encryption, updated settings.py to use this value * Updated secure map proxy endpoint to always include browser params(params supplied from the browser(bbox, etc)) --- tethys_apps/models.py | 15 +++++++++++++-- tethys_apps/views.py | 10 +++------- tethys_cli/gen_commands.py | 9 ++++++++- tethys_portal/settings.py | 7 ++++--- 4 files changed, 28 insertions(+), 13 deletions(-) diff --git a/tethys_apps/models.py b/tethys_apps/models.py index 351c1906b4..7cd5ecda15 100644 --- a/tethys_apps/models.py +++ b/tethys_apps/models.py @@ -1247,6 +1247,17 @@ def get_value( param_overrides=None, request_user=None, ): + secure_map_service = None + if self.secure_map_service: + secure_map_service = self.secure_map_service + + if secure_map_service is None: + if self.required: + raise TethysAppSettingNotAssigned( + f'The required setting "{self.name}" for app "{self.tethys_app.package}":' + f"has not been assigned." + ) + if as_endpoint: return self.generate_request(param_overrides=param_overrides) elif as_layer: @@ -1257,8 +1268,8 @@ def get_value( return self.fetch_response( param_overrides=param_overrides, request_user=request_user ) - else: - return self.secure_map_service + + return secure_map_service def update_params(self, new_params): if not self.secure_map_service: diff --git a/tethys_apps/views.py b/tethys_apps/views.py index 7acefff70d..bd3ab5e5af 100644 --- a/tethys_apps/views.py +++ b/tethys_apps/views.py @@ -165,13 +165,9 @@ def secure_map_proxy(request, setting_id): except SecureMapService.DoesNotExist: return HttpResponse("Service setting not found.", status=404) - resolved_service_params = service.get_resolved_params() - - if service.service_type == "ImageWMS": - browser_params = {key: value for key, value in request.GET.items()} - params = {**resolved_service_params, **browser_params} - else: - params = resolved_service_params + browser_params = {key: value for key, value in request.GET.items()} + service_params = service.get_resolved_params() + params = {**service_params, **browser_params} headers = {} if service.authentication_method == "oauth": diff --git a/tethys_cli/gen_commands.py b/tethys_cli/gen_commands.py index c8593aece7..bc6541f99d 100644 --- a/tethys_cli/gen_commands.py +++ b/tethys_cli/gen_commands.py @@ -15,6 +15,7 @@ from os import environ from datetime import datetime from pathlib import Path +import secrets from subprocess import call, run from jinja2 import Template @@ -255,6 +256,8 @@ def generate_secret_key(): [random.choice(string.ascii_letters + string.digits) for _ in range(50)] ) +def generate_salt_key(): + return secrets.token_hex(32) def empty_context(args): context = {} @@ -338,7 +341,11 @@ def gen_portal_yaml(args): tethys_portal_settings.setdefault("version", 2.0) tethys_portal_settings.setdefault("name", "") tethys_portal_settings.setdefault("apps", {}) - tethys_portal_settings.setdefault("settings", {"SECRET_KEY": generate_secret_key()}) + tethys_portal_settings.setdefault( + "settings", { + "SECRET_KEY": generate_secret_key(), + "SALT_KEY": generate_salt_key(), + }) tethys_portal_settings.setdefault( "site_settings", {category: {} for category in SITE_SETTING_CATEGORIES} ) diff --git a/tethys_portal/settings.py b/tethys_portal/settings.py index c38bca7c2b..be1d8b296b 100644 --- a/tethys_portal/settings.py +++ b/tethys_portal/settings.py @@ -36,7 +36,7 @@ from tethys_apps.utilities import relative_to_tethys_home from tethys_utils import deprecation_warning -from tethys_cli.gen_commands import generate_secret_key +from tethys_cli.gen_commands import generate_secret_key, generate_salt_key from tethys_portal.optional_dependencies import optional_import, has_module # optional imports @@ -67,6 +67,9 @@ # SECURITY WARNING: keep the secret key used in production secret! SECRET_KEY = portal_config_settings.pop("SECRET_KEY", generate_secret_key()) +# SECURITY WARNING: keep the salt key used in production secret! +SALT_KEY = portal_config_settings.pop("SALT_KEY", generate_salt_key()) + # SECURITY WARNING: don't run with debug turned on in production! DEBUG = portal_config_settings.pop("DEBUG", True) @@ -710,5 +713,3 @@ def get__all__(mod): for setting, value in portal_config_settings.items(): setattr(this_module, setting, value) -# Encryption keys -FERNET_KEYS = [os.environ.get("FERNET_KEY", None)] From a5579dd3e837185c5fc2f6f9c9e468db825c9c3b Mon Sep 17 00:00:00 2001 From: Jacob Johnson Date: Fri, 31 Jul 2026 16:52:34 -0600 Subject: [PATCH 06/50] formatting fixes --- tethys_apps/models.py | 2 +- tethys_cli/gen_commands.py | 8 ++++++-- tethys_portal/settings.py | 1 - 3 files changed, 7 insertions(+), 4 deletions(-) diff --git a/tethys_apps/models.py b/tethys_apps/models.py index 7cd5ecda15..7be70a01de 100644 --- a/tethys_apps/models.py +++ b/tethys_apps/models.py @@ -1268,7 +1268,7 @@ def get_value( return self.fetch_response( param_overrides=param_overrides, request_user=request_user ) - + return secure_map_service def update_params(self, new_params): diff --git a/tethys_cli/gen_commands.py b/tethys_cli/gen_commands.py index bc6541f99d..da86a12d33 100644 --- a/tethys_cli/gen_commands.py +++ b/tethys_cli/gen_commands.py @@ -256,9 +256,11 @@ def generate_secret_key(): [random.choice(string.ascii_letters + string.digits) for _ in range(50)] ) + def generate_salt_key(): return secrets.token_hex(32) + def empty_context(args): context = {} return context @@ -342,10 +344,12 @@ def gen_portal_yaml(args): tethys_portal_settings.setdefault("name", "") tethys_portal_settings.setdefault("apps", {}) tethys_portal_settings.setdefault( - "settings", { + "settings", + { "SECRET_KEY": generate_secret_key(), "SALT_KEY": generate_salt_key(), - }) + }, + ) tethys_portal_settings.setdefault( "site_settings", {category: {} for category in SITE_SETTING_CATEGORIES} ) diff --git a/tethys_portal/settings.py b/tethys_portal/settings.py index be1d8b296b..2779b1fb48 100644 --- a/tethys_portal/settings.py +++ b/tethys_portal/settings.py @@ -712,4 +712,3 @@ def get__all__(mod): # Add any additional specified settings to module for setting, value in portal_config_settings.items(): setattr(this_module, setting, value) - From 410f916014c898a3412b65a4f5b8e69111966935 Mon Sep 17 00:00:00 2001 From: Jacob Johnson Date: Mon, 3 Aug 2026 13:06:59 -0600 Subject: [PATCH 07/50] * Updated and renamed json_date_handler to handle secure map service basemaps when using a proxy endpoint * Updated generate_request helper function in SecureMapServiceSetting class to return a lazy request to avoid error when using a secure map service as a basemap with a proxy endpoint * Updated generate_request, build_layer, and fetch_response to be private * Added _resolve_secure_map_service as a lazy target for get_secure_map_service --- .../test_templatetags/test_tethys_gizmos.py | 8 +- tethys_apps/base/app_base.py | 73 ++++++++++++++++--- tethys_apps/models.py | 33 +++++---- tethys_gizmos/templatetags/tethys_gizmos.py | 9 ++- 4 files changed, 94 insertions(+), 29 deletions(-) diff --git a/tests/unit_tests/test_tethys_gizmos/test_templatetags/test_tethys_gizmos.py b/tests/unit_tests/test_tethys_gizmos/test_templatetags/test_tethys_gizmos.py index 2d847478fd..de4d834ad6 100644 --- a/tests/unit_tests/test_tethys_gizmos/test_templatetags/test_tethys_gizmos.py +++ b/tests/unit_tests/test_tethys_gizmos/test_templatetags/test_tethys_gizmos.py @@ -134,15 +134,15 @@ def test_return_item_none(self): # Check Result self.assertFalse(result) - def test_json_date_handler(self): - result = gizmos_templatetags.json_date_handler(datetime(2018, 1, 1)) + def test_json_data_handler(self): + result = gizmos_templatetags.json_data_handler(datetime(2018, 1, 1)) # Timestamp should be 1514764800 expected = 1514790000000.0 if sys.platform == "win32" else 1514764800000.0 self.assertEqual(expected, result) - def test_json_date_handler_no_datetime(self): - result = gizmos_templatetags.json_date_handler("2018") + def test_json_data_handler_no_datetime(self): + result = gizmos_templatetags.json_data_handler("2018") # Check Result self.assertEqual("2018", result) diff --git a/tethys_apps/base/app_base.py b/tethys_apps/base/app_base.py index 150a8d3525..cce25406e7 100644 --- a/tethys_apps/base/app_base.py +++ b/tethys_apps/base/app_base.py @@ -14,7 +14,7 @@ from django.db.utils import ProgrammingError from django.core.exceptions import ValidationError, ObjectDoesNotExist from django.urls import re_path -from django.utils.functional import classproperty +from django.utils.functional import classproperty, lazy from django.shortcuts import render, redirect, reverse from django.template.loader import render_to_string @@ -1907,11 +1907,64 @@ def get_secure_map_service( Args: name(str): name of the SecureMapServiceSetting as defined in the app.py. - + as_endpoint(bool): Returns endpoint url string if True, Defaults to False. + as_layer(bool): Returns GeoServerLayer object if True, Defaults to False. + as_response(bool): Returns requests.Response object if True, Defaults to False. + param_overrides(dict): Dictionary of parameters to override for the map service request. Defaults to None. + request_user(User): Django User object to use for the request. Defaults to None. + Returns: SecureMapService: SecureMapService assigned to setting. + + **NOTE:** When ``as_endpoint`` is True a lazy string is returned. This ensures the + endpoint reflects the current state of the map service's settings every time it is used. + """ + if as_endpoint: + return lazy(cls._resolve_secure_map_service, str)( + name, + as_endpoint=True, + param_overrides=param_overrides, + ) + + return cls._resolve_secure_map_service( + name, + as_layer=as_layer, + as_response=as_response, + param_overrides=param_overrides, + request_user=request_user, + ) + @classmethod + def _resolve_secure_map_service( + cls, + name, + as_endpoint=False, + as_layer=False, + as_response=False, + param_overrides=None, + request_user=None, + ): + """ + Resolve the named SecureMapServiceSetting. + + This function is kept seperate from ``get_secure_map_service`` to allow for lazy evaluation of the endpoint url. + + Args: + name(str): name of the SecureMapServiceSetting as defined in the app.py. + as_endpoint(bool): Returns endpoint url string if True, Defaults to False. + as_layer(bool): Returns GeoServerLayer object if True, Defaults to False. + as_response(bool): Returns requests.Response object if True, Defaults to False. + param_overrides(dict): Dictionary of parameters to override for the map service request. Defaults to None. + request_user(User): Django User object to use for the request. Defaults to None. + + Returns: + SecureMapService: when no 'as' option is specified + str: lazy endpoint url when ``as_endpoint`` is True + MVLayer: map layer when ``as_layer`` is True + requests.Response: response object when ``as_response`` is True + + """ from tethys_apps.models import TethysApp db_app = TethysApp.objects.get(package=cls.package) @@ -1919,18 +1972,20 @@ def get_secure_map_service( try: secure_map_service_setting = secure_map_service_settings.get(name=name) - return secure_map_service_setting.get_value( - as_endpoint=as_endpoint, - as_layer=as_layer, - as_response=as_response, - param_overrides=param_overrides, - request_user=request_user, - ) + except ObjectDoesNotExist: raise TethysAppSettingDoesNotExist( "SecureMapServiceSetting", name, cls.name ) + return secure_map_service_setting.get_value( + as_endpoint=as_endpoint, + as_layer=as_layer, + as_response=as_response, + param_overrides=param_overrides, + request_user=request_user, + ) + @classmethod def update_secure_map_service_setting_params(cls, name, params): """ diff --git a/tethys_apps/models.py b/tethys_apps/models.py index 7be70a01de..b75590ba7f 100644 --- a/tethys_apps/models.py +++ b/tethys_apps/models.py @@ -20,7 +20,7 @@ from django.contrib.auth.models import Permission from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.fields import GenericForeignKey -from django.shortcuts import reverse +from django.urls import reverse_lazy from model_utils.managers import InheritanceManager from tethys_apps.exceptions import ( TethysAppSettingNotAssigned, @@ -1158,7 +1158,7 @@ def clean(self): if not self.secure_map_service and self.required: raise ValidationError("Required.") - def generate_request(self, param_overrides=None): + def _generate_request(self, param_overrides=None): """ Generate a request to the secure map service, including any necessary authentication headers or parameters. """ @@ -1168,17 +1168,22 @@ def generate_request(self, param_overrides=None): f'"{self.name}" for app "{self.tethys_app.package}": ' f"no SecureMapService assigned." ) + service = self.secure_map_service + if service.use_proxy: + # Use lazy URL resolution so the proxy endpoint is resolved at + # runtime. This allows the url to be referenced before apps are + # fully loaded and the urls are registered. This helps in cases + # like using a map service for a MapLayout basemap + endpoint = reverse_lazy( + "secure_map_proxy", kwargs={"setting_id": service.pk} + ) + return endpoint endpoint = service.endpoint - params = service.params or {} + params = service.get_resolved_params() if param_overrides: params.update(param_overrides) - if service.use_proxy: - endpoint = reverse("secure_map_proxy", kwargs={"setting_id": service.pk}) - return endpoint - - params = service.get_resolved_params() # If the API key is not already included in the params, add it # This allows for the API key to be included in the params with a placeholder (e.g. ${api_key}) # or to be assigned to a different parameter name if the service expects it that way @@ -1192,8 +1197,8 @@ def generate_request(self, param_overrides=None): url = f"{endpoint}?{query_string}" if query_string else endpoint return url - def build_layer(self, param_overrides=None, request_user=None): - endpoint = self.generate_request(param_overrides=param_overrides) + def _build_layer(self, param_overrides=None, request_user=None): + endpoint = self._generate_request(param_overrides=param_overrides) service = self.secure_map_service options = {"url": endpoint} if not service.use_proxy and service.authentication_method == "oauth": @@ -1206,7 +1211,7 @@ def build_layer(self, param_overrides=None, request_user=None): data={"show_legend": True, "layer_id": service.pk}, ) - def fetch_response(self, param_overrides=None, request_user=None): + def _fetch_response(self, param_overrides=None, request_user=None): if not self.secure_map_service: raise TethysAppSettingNotAssigned( f"Cannot fetch response for SecureMapServiceSetting " @@ -1259,13 +1264,13 @@ def get_value( ) if as_endpoint: - return self.generate_request(param_overrides=param_overrides) + return self._generate_request(param_overrides=param_overrides) elif as_layer: - return self.build_layer( + return self._build_layer( param_overrides=param_overrides, request_user=request_user ) elif as_response: - return self.fetch_response( + return self._fetch_response( param_overrides=param_overrides, request_user=request_user ) diff --git a/tethys_gizmos/templatetags/tethys_gizmos.py b/tethys_gizmos/templatetags/tethys_gizmos.py index f43c12e575..1ff3b7166c 100644 --- a/tethys_gizmos/templatetags/tethys_gizmos.py +++ b/tethys_gizmos/templatetags/tethys_gizmos.py @@ -19,6 +19,8 @@ from django.template import TemplateSyntaxError from django.templatetags.static import static from django.core.serializers.json import DjangoJSONEncoder +from django.utils.encoding import force_str +from django.utils.functional import Promise from tethys_apps.harvester import SingletonHarvester @@ -147,9 +149,12 @@ def return_item(container, i): return None -def json_date_handler(obj): +def json_data_handler(obj): if isinstance(obj, datetime): return time.mktime(obj.timetuple()) * 1000 + elif isinstance(obj, Promise): + # Resolve lazy objects like lazy urls + return force_str(obj) else: return obj @@ -159,7 +164,7 @@ def jsonify(data): """ Convert python data structures into a JSON string """ - return json.dumps(data, default=json_date_handler) + return json.dumps(data, default=json_data_handler) @register.filter From f86f130d7c5b87df59093a96071ec31e27ab129c Mon Sep 17 00:00:00 2001 From: Jacob Johnson Date: Mon, 3 Aug 2026 13:25:21 -0600 Subject: [PATCH 08/50] Moved proxy endpoint url path to be portal wide --- tethys_apps/urls.py | 7 +------ tethys_portal/urls.py | 5 +++++ 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/tethys_apps/urls.py b/tethys_apps/urls.py index ca575c26da..6c315e00bf 100644 --- a/tethys_apps/urls.py +++ b/tethys_apps/urls.py @@ -12,7 +12,7 @@ from django.urls import include, re_path from channels.routing import URLRouter from tethys_apps.harvester import SingletonHarvester -from tethys_apps.views import library, send_beta_feedback_email, secure_map_proxy +from tethys_apps.views import library, send_beta_feedback_email from tethys_apps.utilities import get_configured_standalone_app from django.conf import settings from django.views.generic.base import RedirectView @@ -24,11 +24,6 @@ re_path( r"^send-beta-feedback/$", send_beta_feedback_email, name="send_beta_feedback" ), - re_path( - r"^secure-map-proxy/(?P\d+)/$", - secure_map_proxy, - name="secure_map_proxy", - ), ] url_namespaces = None diff --git a/tethys_portal/urls.py b/tethys_portal/urls.py index b48a574a54..088c9163f0 100644 --- a/tethys_portal/urls.py +++ b/tethys_portal/urls.py @@ -243,6 +243,11 @@ name="update_dask_job_status", ), re_path(r"^api/", include((api_urls, "api"), namespace="api")), + re_path( + r"^secure-map-proxy/(?P\d+)/$", + tethys_apps_views.secure_map_proxy, + name="secure_map_proxy", + ), *static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT), ] From f66f796472b060d867adeb8a8ae8fb49a384d0f4 Mon Sep 17 00:00:00 2001 From: Jacob Johnson Date: Mon, 3 Aug 2026 13:26:21 -0600 Subject: [PATCH 09/50] Formatting fixes --- tethys_apps/base/app_base.py | 16 ++++++++-------- tethys_apps/models.py | 8 ++++---- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/tethys_apps/base/app_base.py b/tethys_apps/base/app_base.py index cce25406e7..633509c2d7 100644 --- a/tethys_apps/base/app_base.py +++ b/tethys_apps/base/app_base.py @@ -1912,13 +1912,13 @@ def get_secure_map_service( as_response(bool): Returns requests.Response object if True, Defaults to False. param_overrides(dict): Dictionary of parameters to override for the map service request. Defaults to None. request_user(User): Django User object to use for the request. Defaults to None. - + Returns: SecureMapService: SecureMapService assigned to setting. - **NOTE:** When ``as_endpoint`` is True a lazy string is returned. This ensures the + **NOTE:** When ``as_endpoint`` is True a lazy string is returned. This ensures the endpoint reflects the current state of the map service's settings every time it is used. - + """ if as_endpoint: return lazy(cls._resolve_secure_map_service, str)( @@ -1949,7 +1949,7 @@ def _resolve_secure_map_service( Resolve the named SecureMapServiceSetting. This function is kept seperate from ``get_secure_map_service`` to allow for lazy evaluation of the endpoint url. - + Args: name(str): name of the SecureMapServiceSetting as defined in the app.py. as_endpoint(bool): Returns endpoint url string if True, Defaults to False. @@ -1957,13 +1957,13 @@ def _resolve_secure_map_service( as_response(bool): Returns requests.Response object if True, Defaults to False. param_overrides(dict): Dictionary of parameters to override for the map service request. Defaults to None. request_user(User): Django User object to use for the request. Defaults to None. - + Returns: SecureMapService: when no 'as' option is specified str: lazy endpoint url when ``as_endpoint`` is True MVLayer: map layer when ``as_layer`` is True requests.Response: response object when ``as_response`` is True - + """ from tethys_apps.models import TethysApp @@ -1972,7 +1972,7 @@ def _resolve_secure_map_service( try: secure_map_service_setting = secure_map_service_settings.get(name=name) - + except ObjectDoesNotExist: raise TethysAppSettingDoesNotExist( "SecureMapServiceSetting", name, cls.name @@ -1984,7 +1984,7 @@ def _resolve_secure_map_service( as_response=as_response, param_overrides=param_overrides, request_user=request_user, - ) + ) @classmethod def update_secure_map_service_setting_params(cls, name, params): diff --git a/tethys_apps/models.py b/tethys_apps/models.py index b75590ba7f..a1916c8503 100644 --- a/tethys_apps/models.py +++ b/tethys_apps/models.py @@ -1168,12 +1168,12 @@ def _generate_request(self, param_overrides=None): f'"{self.name}" for app "{self.tethys_app.package}": ' f"no SecureMapService assigned." ) - + service = self.secure_map_service if service.use_proxy: - # Use lazy URL resolution so the proxy endpoint is resolved at - # runtime. This allows the url to be referenced before apps are - # fully loaded and the urls are registered. This helps in cases + # Use lazy URL resolution so the proxy endpoint is resolved at + # runtime. This allows the url to be referenced before apps are + # fully loaded and the urls are registered. This helps in cases # like using a map service for a MapLayout basemap endpoint = reverse_lazy( "secure_map_proxy", kwargs={"setting_id": service.pk} From f7a6b05a6f4655952962df84b6dc714395e1e0b0 Mon Sep 17 00:00:00 2001 From: Jacob Johnson Date: Mon, 3 Aug 2026 14:11:32 -0600 Subject: [PATCH 10/50] Formatting fixes --- docs/conf.py | 4 +-- .../unit_tests/test_tethys_apps/test_admin.py | 22 ++++--------- .../test_tethys_cli/test_gen_commands.py | 32 +++++-------------- tethys_apps/admin.py | 14 +++----- tethys_apps/base/page_handler.py | 10 ++---- tethys_apps/db_handlers.py | 4 +-- tethys_portal/settings.py | 1 - 7 files changed, 22 insertions(+), 65 deletions(-) diff --git a/docs/conf.py b/docs/conf.py index 5076571673..e711cdce9d 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -199,9 +199,7 @@ def __getattr__(cls, name): rst_epilog = """ .. |branch| replace:: {branch} -""".format( - branch=branch -) +""".format(branch=branch) # List of patterns, relative to source directory, that match files and # directories to ignore when looking for source files. diff --git a/tests/unit_tests/test_tethys_apps/test_admin.py b/tests/unit_tests/test_tethys_apps/test_admin.py index 58c5de6941..c9fd281f47 100644 --- a/tests/unit_tests/test_tethys_apps/test_admin.py +++ b/tests/unit_tests/test_tethys_apps/test_admin.py @@ -548,16 +548,12 @@ def test_TethysAppAdmin_manage_app_storage(self, mock_convert, mock_get_quota): mock_get_quota.return_value = {"quota": None} url = reverse("admin:clear_workspace", kwargs={"app_id": app.id}) - expected_html = format_html( - """ + expected_html = format_html(""" {} of {} Clear Workspace - """.format( - "0 bytes", "∞", url=url - ) - ) + """.format("0 bytes", "∞", url=url)) actual_html = ret.manage_app_storage(app) self.assertEqual(expected_html.replace(" ", ""), actual_html.replace(" ", "")) @@ -566,16 +562,12 @@ def test_TethysAppAdmin_manage_app_storage(self, mock_convert, mock_get_quota): mock_get_quota.return_value = {"quota": 5, "units": "gb"} url = reverse("admin:clear_workspace", kwargs={"app_id": app.id}) - expected_html = format_html( - """ + expected_html = format_html(""" {} of {} Clear Workspace - """.format( - "0 bytes", "0 bytes", url=url - ) - ) + """.format("0 bytes", "0 bytes", url=url)) actual_html = ret.manage_app_storage(app) self.assertEqual(expected_html.replace(" ", ""), actual_html.replace(" ", "")) @@ -586,16 +578,14 @@ def test_TethysAppAdmin_remove_app(self): app = mock.MagicMock() app.id = 1 - expected_html = format_html( - """ + expected_html = format_html(""" Remove App - """ - ) + """) actual_html = ret.remove_app(app) self.assertEqual(expected_html.replace(" ", ""), actual_html.replace(" ", "")) diff --git a/tests/unit_tests/test_tethys_cli/test_gen_commands.py b/tests/unit_tests/test_tethys_cli/test_gen_commands.py index eaa902828e..3a53205465 100644 --- a/tests/unit_tests/test_tethys_cli/test_gen_commands.py +++ b/tests/unit_tests/test_tethys_cli/test_gen_commands.py @@ -961,9 +961,7 @@ def test_parse_setup_py(): import textwrap # Write a fake setup.py into the temp folder - setup_path.write_text( - textwrap.dedent( - """ + setup_path.write_text(textwrap.dedent(""" app_package = 'test_app' from setuptools import setup @@ -975,9 +973,7 @@ def test_parse_setup_py(): keywords=['alpha', 'beta'], license='MIT', ) - """ - ) - ) + """)) metadata = parse_setup_py(setup_path) @@ -1014,9 +1010,7 @@ def test_parse_setup_py_invalid_package_name(mock_exit, mock_write_warning): import textwrap # Write a fake setup.py into the temp folder - setup_path.write_text( - textwrap.dedent( - """ + setup_path.write_text(textwrap.dedent(""" app_package = fake_function() from setuptools import setup @@ -1028,9 +1022,7 @@ def test_parse_setup_py_invalid_package_name(mock_exit, mock_write_warning): keywords=['alpha', 'beta'], license='MIT', ) - """ - ) - ) + """)) with pytest.raises(SystemExit): parse_setup_py(setup_path) @@ -1053,9 +1045,7 @@ def test_parse_setup_py_no_app_package(mock_exit, mock_write_warning): import textwrap # Write a fake setup.py into the temp folder - setup_path.write_text( - textwrap.dedent( - """ + setup_path.write_text(textwrap.dedent(""" from setuptools import setup setup( @@ -1065,9 +1055,7 @@ def test_parse_setup_py_no_app_package(mock_exit, mock_write_warning): keywords=['alpha', 'beta'], license='MIT', ) - """ - ) - ) + """)) with pytest.raises(SystemExit): parse_setup_py(setup_path) @@ -1088,9 +1076,7 @@ def test_parse_setup_py_invalid_setup_attr(mock_exit, mock_write_warning): import textwrap # Write a fake setup.py into the temp folder - setup_path.write_text( - textwrap.dedent( - """ + setup_path.write_text(textwrap.dedent(""" from setuptools import setup app_package = 'test_app' @@ -1102,9 +1088,7 @@ def test_parse_setup_py_invalid_setup_attr(mock_exit, mock_write_warning): keywords=['alpha', 'beta'], license='MIT', ) - """ - ) - ) + """)) with pytest.raises(SystemExit): parse_setup_py(setup_path) diff --git a/tethys_apps/admin.py b/tethys_apps/admin.py index a5d3f3bc97..bbf93387c8 100644 --- a/tethys_apps/admin.py +++ b/tethys_apps/admin.py @@ -360,29 +360,23 @@ def manage_app_storage(self, app): url = reverse("admin:clear_workspace", kwargs={"app_id": app.id}) - return format_html( - """ + return format_html(""" {} of {} Clear Workspace - """.format( - current_use, quota, url=url - ) - ) + """.format(current_use, quota, url=url)) def remove_app(self, app): url = reverse("admin:remove_app", kwargs={"app_id": app.id}) - return format_html( - f""" + return format_html(f""" Remove App - """ - ) + """) class TethysExtensionAdmin(GuardedModelAdmin): diff --git a/tethys_apps/base/page_handler.py b/tethys_apps/base/page_handler.py index 9d87df0a6a..4be549a133 100644 --- a/tethys_apps/base/page_handler.py +++ b/tethys_apps/base/page_handler.py @@ -77,20 +77,14 @@ def page_component_wrapper(app, user, layout, page_func, extras=None): page_obj = lib.html.div( lib.html.script(key=str(uuid4()), type="importmap")(lib.get_importmap()), page_obj, - ( - lib.html.script( - """ + (lib.html.script(""" setTimeout(() => { const loadingRoot = document.getElementById("loading-root"); if (loadingRoot) { loadingRoot.style.display = "none"; } }, 1000); - """ - ) - if hide_loading - else None - ), + """) if hide_loading else None), ) return page_obj diff --git a/tethys_apps/db_handlers.py b/tethys_apps/db_handlers.py index b99ffbeb31..9fff022933 100644 --- a/tethys_apps/db_handlers.py +++ b/tethys_apps/db_handlers.py @@ -69,9 +69,7 @@ def drop_database(self, model): FROM pg_stat_activity WHERE pg_stat_activity.datname = '{0}' AND pg_stat_activity.pid <> pg_backend_pid(); - """.format( - namespaced_ps_name - ) + """.format(namespaced_ps_name) if drop_connection: drop_connection.execute(disconnect_sessions_statement) diff --git a/tethys_portal/settings.py b/tethys_portal/settings.py index 2779b1fb48..f9c67f638f 100644 --- a/tethys_portal/settings.py +++ b/tethys_portal/settings.py @@ -21,7 +21,6 @@ """ # Build paths inside the project like this: BASE_DIR / '...' -import os import sys import yaml import logging From 96f0ada4b078b66fde7cb2ba325df668ed790a27 Mon Sep 17 00:00:00 2001 From: Jacob Johnson Date: Tue, 4 Aug 2026 15:59:19 -0600 Subject: [PATCH 11/50] Added tests for coverage --- .../tethysapp/test_app/app.py | 15 + .../test_base/test_app_base.py | 78 +++ .../test_SecureMapServiceSetting.py | 513 ++++++++++++++++++ tethys_apps/base/app_base.py | 5 +- tethys_apps/models.py | 12 + 5 files changed, 621 insertions(+), 2 deletions(-) create mode 100644 tests/unit_tests/test_tethys_apps/test_models/test_SecureMapServiceSetting.py diff --git a/tests/apps/tethysapp-test_app/tethysapp/test_app/app.py b/tests/apps/tethysapp-test_app/tethysapp/test_app/app.py index 04bde99726..84588c18da 100644 --- a/tests/apps/tethysapp-test_app/tethysapp/test_app/app.py +++ b/tests/apps/tethysapp-test_app/tethysapp/test_app/app.py @@ -7,6 +7,7 @@ PersistentStoreConnectionSetting, DatasetServiceSetting, SpatialDatasetServiceSetting, + SecureMapServiceSetting, WebProcessingServiceSetting, SchedulerSetting, ) @@ -178,6 +179,20 @@ def spatial_dataset_service_settings(self): return sds_settings + def secure_map_service_settings(self): + """ + Example secure_map_service_settings method. + """ + secure_map_service_settings = ( + SecureMapServiceSetting( + name="secure_map_service", + description="Secure map service for app to use", + required=True, + ), + ) + + return secure_map_service_settings + def scheduler_settings(self): """ Example scheduler_settings method. diff --git a/tests/unit_tests/test_tethys_apps/test_base/test_app_base.py b/tests/unit_tests/test_tethys_apps/test_base/test_app_base.py index 037cdbdd21..1b8abef687 100644 --- a/tests/unit_tests/test_tethys_apps/test_base/test_app_base.py +++ b/tests/unit_tests/test_tethys_apps/test_base/test_app_base.py @@ -1424,6 +1424,84 @@ def test_persistent_store_exists_object_does_not_exist(self, mock_ta, mock_ite): # Check if result False self.assertFalse(result) + @mock.patch("tethys_apps.base.app_base.TethysAppBase._resolve_secure_map_service") + def test_get_secure_map_service(self, mock_rms): + result = TethysAppChild.get_secure_map_service(name=self.fake_name) + mock_rms.assert_called_once_with( + self.fake_name, + as_layer=False, + as_response=False, + param_overrides=None, + request_user=None + ) + self.assertEqual(mock_rms(), result) + + @mock.patch("tethys_apps.base.app_base.TethysAppBase._resolve_secure_map_service") + def test_get_secure_map_service_as_endpoint(self, mock_rms): + mock_rms.return_value = "/apps/test-app/map-service/fake_name/" + result = TethysAppChild.get_secure_map_service( + name=self.fake_name, as_endpoint=True + ) + mock_rms.assert_not_called() + self.assertEqual("/apps/test-app/map-service/fake_name/", str(result)) + mock_rms.assert_called_once_with( + self.fake_name, + as_endpoint=True, + param_overrides=None, + ) + + @mock.patch("tethys_apps.models.TethysApp") + def test__resolve_secure_map_service(self, mock_ta): + mock_get_value = mock_ta.objects.get().secure_map_service_settings.get().get_value + mock_get_value.return_value = "test_secure_map_service" + + result = TethysAppChild._resolve_secure_map_service(name=self.fake_name) + mock_ta.objects.get.assert_called_with(package=TethysAppChild.package) + + mock_ta.objects.get().secure_map_service_settings.get.assert_called_with( + name=self.fake_name + ) + self.assertEqual("test_secure_map_service", result) + + @mock.patch("tethys_apps.models.TethysApp") + def test__resolve_secure_map_service_object_does_not_exist(self, mock_ta): + mock_get = mock_ta.objects.get().secure_map_service_settings.get + mock_get.side_effect = ObjectDoesNotExist + + self.assertRaises( + TethysAppSettingDoesNotExist, + TethysAppChild._resolve_secure_map_service, + name=self.fake_name, + ) + + @mock.patch("tethys_apps.models.SecureMapServiceSetting.update_params") + @mock.patch("tethys_apps.models.TethysApp") + def test_update_secure_map_service_setting_params(self, mock_ta, mock_update_params): + mock_setting = mock_ta.objects.get().secure_map_service_settings.get.return_value + + fake_params = {"param1": "value1"} + TethysAppChild.update_secure_map_service_setting_params( + name=self.fake_name, params=fake_params + ) + mock_ta.objects.get.assert_called_with(package=TethysAppChild.package) + mock_ta.objects.get().secure_map_service_settings.get.assert_called_with( + name=self.fake_name + ) + mock_setting.update_params.assert_called_with(fake_params) + + @mock.patch("tethys_apps.models.TethysApp") + def test_update_secure_map_service_setting_params_object_does_not_exist(self, mock_ta): + mock_get = mock_ta.objects.get().secure_map_service_settings.get + mock_get.side_effect = ObjectDoesNotExist + + + self.assertRaises( + TethysAppSettingDoesNotExist, + TethysAppChild.update_secure_map_service_setting_params, + name=self.fake_name, + params={"param1": "value1"}, + ) + @mock.patch("tethys_apps.base.app_base.files") @mock.patch("tethys_apps.base.app_base.TethysPath") @mock.patch("tethys_apps.base.app_base.sync_cookies_from_yaml") diff --git a/tests/unit_tests/test_tethys_apps/test_models/test_SecureMapServiceSetting.py b/tests/unit_tests/test_tethys_apps/test_models/test_SecureMapServiceSetting.py new file mode 100644 index 0000000000..23b8ce361a --- /dev/null +++ b/tests/unit_tests/test_tethys_apps/test_models/test_SecureMapServiceSetting.py @@ -0,0 +1,513 @@ +import pytest +import requests +from tethys_sdk.testing import TethysTestCase +from tethys_apps.models import TethysApp, SecureMapServiceSetting +from tethys_sdk.gizmos import MVLayer +from tethys_services.models import SecureMapService +from django.core.exceptions import ValidationError +from tethys_apps.exceptions import TethysAppSettingNotAssigned +from django.utils.functional import Promise +from django.utils.encoding import force_str +from unittest import mock + + +class SecureMapServiceSettingTests(TethysTestCase): + def set_up(self): + self.test_app = TethysApp.objects.get(package="test_app") + self.test_user = self.create_test_user(username="testuser", password="testpassword") + + self.params_with_api_key = {"param1": "value1", "api_key": "${api_key}"} + self.params_without_api_key = {"param1": "value1"} + + self.map_service_with_api_key_no_proxy = SecureMapService( + name="map_service_with_api_key_no_proxy", + legend_title="Map Service with API Token No Proxy", + endpoint="https://example.com/map_service", + authentication_method="api_key", + api_key="test_api_key", + service_type="WMS", + params=self.params_with_api_key, + use_proxy=False + ) + self.map_service_with_api_key_no_proxy.save() + + self.map_service_without_api_key_param_no_proxy = SecureMapService( + name="map_service_without_api_key_no_proxy", + legend_title="Map Service without API Token No Proxy", + endpoint="https://example.com/map_service", + authentication_method="api_key", + api_key="test_api_key", + service_type="WMS", + params=self.params_without_api_key, + use_proxy=False + ) + self.map_service_without_api_key_param_no_proxy.save() + + self.map_service_with_api_key_with_proxy = SecureMapService( + name="map_service_with_api_key_with_proxy", + legend_title="Map Service with API Token With Proxy", + endpoint="https://example.com/map_service", + authentication_method="api_key", + api_key="test_api_key", + service_type="GML", + params=self.params_with_api_key, + use_proxy=True + ) + self.map_service_with_api_key_with_proxy.save() + + self.map_service_with_oauth_no_proxy = SecureMapService( + name="map_service_with_oauth_no_proxy", + legend_title="Map Service with OAuth No Proxy", + endpoint="https://example.com/map_service", + authentication_method="oauth", + oauth_provider="test_oauth_provider", + service_type="geojson", + params=self.params_without_api_key, + use_proxy=False + ) + self.map_service_with_oauth_no_proxy.save() + + self.map_service_with_oauth_with_proxy = SecureMapService( + name="map_service_with_oauth_with_proxy", + legend_title="Map Service with OAuth With Proxy", + endpoint="https://example.com/map_service", + authentication_method="oauth", + oauth_provider="test_oauth_provider", + service_type="WMS", + params=self.params_without_api_key, + use_proxy=True + ) + self.map_service_with_oauth_with_proxy.save() + + def tear_down(self): + self.map_service_with_api_key_no_proxy.delete() + self.map_service_with_api_key_with_proxy.delete() + self.map_service_without_api_key_param_no_proxy.delete() + self.map_service_with_oauth_no_proxy.delete() + self.map_service_with_oauth_with_proxy.delete() + + def test_clean_validation_error(self): + setting = self.test_app.settings_set.select_subclasses().get(name="secure_map_service") + setting.secure_map_service = None + setting.save() + self.assertRaises( + ValidationError, self.test_app.settings_set.select_subclasses().get(name="secure_map_service").clean, + ) + + def test__generate_request_none(self): + setting = self.test_app.settings_set.select_subclasses().get(name="secure_map_service") + setting.secure_map_service = None + setting.save() + + self.assertRaises( + TethysAppSettingNotAssigned, + SecureMapServiceSetting.objects.get(name="secure_map_service")._generate_request, + ) + + def test__generate_request_with_proxy(self): + setting = self.test_app.settings_set.select_subclasses().get(name="secure_map_service") + setting.secure_map_service = self.map_service_with_api_key_with_proxy + setting.save() + + url = SecureMapServiceSetting.objects.get(name="secure_map_service")._generate_request() + + self.assertIsInstance(url, Promise) + self.assertEqual(f"/secure-map-proxy/{setting.secure_map_service.pk}/", force_str(url)) + + def test__generate_request_without_proxy_with_api_key(self): + setting = self.test_app.settings_set.select_subclasses().get(name="secure_map_service") + setting.secure_map_service = self.map_service_with_api_key_no_proxy + setting.save() + + url = SecureMapServiceSetting.objects.get(name="secure_map_service")._generate_request() + + self.assertEqual( + url, + "https://example.com/map_service?param1=value1&api_key=test_api_key" + ) + + def test__generate_request_without_proxy_without_api_key_param(self): + setting = self.test_app.settings_set.select_subclasses().get(name="secure_map_service") + setting.secure_map_service = self.map_service_without_api_key_param_no_proxy + setting.save() + + url = SecureMapServiceSetting.objects.get(name="secure_map_service")._generate_request() + + self.assertEqual( + url, + "https://example.com/map_service?param1=value1&api_key=test_api_key" + ) + + def test__generate_request_param_overrides(self): + setting = self.test_app.settings_set.select_subclasses().get(name="secure_map_service") + setting.secure_map_service = self.map_service_with_api_key_no_proxy + setting.save() + + url = SecureMapServiceSetting.objects.get(name="secure_map_service")._generate_request( + param_overrides={"param1": "overridden_value", "param2": "value2"} + ) + + self.assertEqual( + url, + "https://example.com/map_service?param1=overridden_value&api_key=test_api_key¶m2=value2" + ) + + def test__build_layer_none(self): + setting = self.test_app.settings_set.select_subclasses().get(name="secure_map_service") + setting.secure_map_service = None + setting.save() + + self.assertRaises( + TethysAppSettingNotAssigned, + SecureMapServiceSetting.objects.get(name="secure_map_service")._build_layer, + ) + + def test__build_layer_with_api_key(self): + setting = self.test_app.settings_set.select_subclasses().get(name="secure_map_service") + setting.secure_map_service = self.map_service_with_api_key_no_proxy + setting.save() + + layer = SecureMapServiceSetting.objects.get(name="secure_map_service")._build_layer() + + self.assertEqual(layer["source"], setting.secure_map_service.service_type) + + self.assertEqual( + layer["options"]["url"], + "https://example.com/map_service?param1=value1&api_key=test_api_key" + ) + + def test__build_layer_without_api_key_param(self): + setting = self.test_app.settings_set.select_subclasses().get(name="secure_map_service") + setting.secure_map_service = self.map_service_without_api_key_param_no_proxy + setting.save() + + layer = SecureMapServiceSetting.objects.get(name="secure_map_service")._build_layer() + + self.assertEqual(layer["source"], setting.secure_map_service.service_type) + self.assertEqual( + layer["options"]["url"], + "https://example.com/map_service?param1=value1&api_key=test_api_key" + ) + + def test__build_layer_with_proxy(self): + setting = self.test_app.settings_set.select_subclasses().get(name="secure_map_service") + setting.secure_map_service = self.map_service_with_api_key_with_proxy + setting.save() + + layer = SecureMapServiceSetting.objects.get(name="secure_map_service")._build_layer() + + self.assertEqual(layer["source"], setting.secure_map_service.service_type) + self.assertIsInstance(layer["options"]["url"], Promise) + self.assertEqual( + force_str(layer["options"]["url"]), + f"/secure-map-proxy/{setting.secure_map_service.pk}/" + ) + + def test__build_layer_param_overrides(self): + setting = self.test_app.settings_set.select_subclasses().get(name="secure_map_service") + setting.secure_map_service = self.map_service_with_api_key_no_proxy + setting.save() + + layer = SecureMapServiceSetting.objects.get(name="secure_map_service")._build_layer( + param_overrides={"param1": "overridden_value", "param2": "value2"} + ) + + self.assertEqual(layer["source"], setting.secure_map_service.service_type) + self.assertEqual( + layer["options"]["url"], + "https://example.com/map_service?param1=overridden_value&api_key=test_api_key¶m2=value2" + ) + + @mock.patch("tethys_services.models.SecureMapService.get_oauth_token", return_value="test_oauth_token") + def test__build_Layer_oauth(self, mock_got): + setting = self.test_app.settings_set.select_subclasses().get(name="secure_map_service") + setting.secure_map_service = self.map_service_with_oauth_no_proxy + setting.save() + + layer = SecureMapServiceSetting.objects.get(name="secure_map_service")._build_layer(request_user=self.test_user) + + self.assertEqual(layer["source"], setting.secure_map_service.service_type) + self.assertEqual( + layer["options"]["url"], + "https://example.com/map_service?param1=value1" + ) + self.assertEqual( + layer["options"]["token"], "test_oauth_token" + ) + + def test__build_Layer_oauth_no_request_user(self): + setting = self.test_app.settings_set.select_subclasses().get(name="secure_map_service") + setting.secure_map_service = self.map_service_with_oauth_no_proxy + setting.save() + + self.assertRaises( + ValueError, + SecureMapServiceSetting.objects.get(name="secure_map_service")._build_layer, + request_user=None + ) + + def test__fetch_response_none(self): + setting = self.test_app.settings_set.select_subclasses().get(name="secure_map_service") + setting.secure_map_service = None + setting.save() + + self.assertRaises( + TethysAppSettingNotAssigned, + SecureMapServiceSetting.objects.get(name="secure_map_service")._fetch_response, + ) + + def test__fetch_response_no_request_user(self): + setting = self.test_app.settings_set.select_subclasses().get(name="secure_map_service") + setting.secure_map_service = self.map_service_with_oauth_no_proxy + setting.save() + + self.assertRaises( + ValueError, + SecureMapServiceSetting.objects.get(name="secure_map_service")._fetch_response, + request_user=None + ) + + @mock.patch("tethys_apps.models.requests.get") + def test__fetch_response_with_api_key(self, mock_get): + setting = self.test_app.settings_set.select_subclasses().get(name="secure_map_service") + setting.secure_map_service = self.map_service_with_api_key_no_proxy + setting.save() + + mock_response = mock.MagicMock( + status_code=200, + ok=True, + url="https://example.com/map_service?param1=value1&api_key=test_api_key", + ) + mock_get.return_value = mock_response + + response = SecureMapServiceSetting.objects.get(name="secure_map_service")._fetch_response() + + self.assertEqual( + response.url, + "https://example.com/map_service?param1=value1&api_key=test_api_key" + ) + self.assertEqual(response.status_code, 200) + + @mock.patch("tethys_apps.models.SecureMapService.get_oauth_token", return_value="test_oauth_token") + @mock.patch("tethys_apps.models.requests.get") + def test__fetch_response_with_oauth_header(self, mock_get, mock_got): + setting = self.test_app.settings_set.select_subclasses().get(name="secure_map_service") + setting.secure_map_service = self.map_service_with_oauth_no_proxy + setting.save() + + mock_response = mock.MagicMock( + status_code=200, + ok=True, + url="https://example.com/map_service?param1=value1", + ) + mock_get.return_value = mock_response + + response = SecureMapServiceSetting.objects.get(name="secure_map_service")._fetch_response(request_user=self.test_user) + + mock_got.assert_called_once_with(self.test_user) + + self.assertEqual( + response.url, + "https://example.com/map_service?param1=value1" + ) + self.assertEqual(response.status_code, 200) + + @mock.patch("tethys_apps.models.log") + @mock.patch("tethys_apps.models.SecureMapService.get_oauth_token", return_value="test_oauth_token") + @mock.patch("tethys_apps.models.requests.get") + def test__fetch_response_with_oauth_response_not_ok(self, mock_get, mock_got, mock_log): + setting = self.test_app.settings_set.select_subclasses().get(name="secure_map_service") + setting.secure_map_service = self.map_service_with_oauth_no_proxy + setting.save() + + mock_response = mock.MagicMock( + status_code=403, + ok=False, + url="https://example.com/map_service?param1=value1", + text="Forbidden access message", + raise_for_status=mock.MagicMock( + side_effect=requests.HTTPError("403 Forbidden") + ), + ) + mock_get.return_value = mock_response + + with self.assertRaises(requests.HTTPError): + SecureMapServiceSetting.objects.get(name="secure_map_service")._fetch_response(request_user=self.test_user) + + mock_got.assert_called_once_with(self.test_user) + mock_log.error.assert_called_once() + logged = mock_log.error.call_args.args[0] + self.assertIn(f"SecureMapService with name {setting.secure_map_service.name} request failed", logged) + self.assertIn("status_code: 403", logged) + self.assertIn("Forbidden access message", logged) + + @mock.patch("tethys_apps.models.SecureMapService.get_oauth_token", return_value="test_oauth_token") + @mock.patch("tethys_apps.models.requests.get") + def test__fetch_response_with_oauth_good_response(self, mock_get, mock_got): + setting = self.test_app.settings_set.select_subclasses().get(name="secure_map_service") + setting.secure_map_service = self.map_service_with_oauth_no_proxy + setting.save() + + mock_response = mock.MagicMock( + status_code=200, + ok=True, + url="https://example.com/map_service?param1=value1", + ) + mock_get.return_value = mock_response + + response = SecureMapServiceSetting.objects.get(name="secure_map_service")._fetch_response(request_user=self.test_user) + + mock_got.assert_called_once_with(self.test_user) + + self.assertEqual( + response, mock_response + ) + self.assertEqual(response.status_code, 200) + + @mock.patch("tethys_apps.models.requests.get") + def test__fetch_response_with_api_key_with_overrides(self, mock_get): + setting = self.test_app.settings_set.select_subclasses().get(name="secure_map_service") + setting.secure_map_service = self.map_service_with_api_key_no_proxy + setting.save() + + mock_response = mock.MagicMock( + status_code=200, + ok=True, + url="https://example.com/map_service?param1=overridden_value&api_key=test_api_key¶m2=value2", + ) + mock_get.return_value = mock_response + + response = SecureMapServiceSetting.objects.get(name="secure_map_service")._fetch_response( + param_overrides={"param1": "overridden_value", "param2": "value2"} + ) + + self.assertEqual( + response.url, + "https://example.com/map_service?param1=overridden_value&api_key=test_api_key¶m2=value2" + ) + self.assertEqual(response.status_code, 200) + + def test_get_value_none(self): + setting = self.test_app.settings_set.select_subclasses().get(name="secure_map_service") + setting.secure_map_service = None + setting.save() + + self.assertRaises( + TethysAppSettingNotAssigned, + SecureMapServiceSetting.objects.get(name="secure_map_service").get_value, + ) + + + def test_get_value_as_endpoint(self): + setting = self.test_app.settings_set.select_subclasses().get(name="secure_map_service") + setting.secure_map_service = self.map_service_with_api_key_no_proxy + setting.save() + + endpoint = SecureMapServiceSetting.objects.get(name="secure_map_service").get_value(as_endpoint=True) + + self.assertEqual( + endpoint, + "https://example.com/map_service?param1=value1&api_key=test_api_key" + ) + + def test_get_value_as_endpoint_with_overrides(self): + setting = self.test_app.settings_set.select_subclasses().get(name="secure_map_service") + setting.secure_map_service = self.map_service_with_api_key_no_proxy + setting.save() + + endpoint = SecureMapServiceSetting.objects.get(name="secure_map_service").get_value( + as_endpoint=True, + param_overrides={"param1": "overridden_value", "param2": "value2"} + ) + + self.assertEqual( + endpoint, + "https://example.com/map_service?param1=overridden_value&api_key=test_api_key¶m2=value2" + ) + + def test_get_value_as_layer(self): + setting = self.test_app.settings_set.select_subclasses().get(name="secure_map_service") + setting.secure_map_service = self.map_service_with_api_key_no_proxy + setting.save() + + layer = SecureMapServiceSetting.objects.get(name="secure_map_service").get_value(as_layer=True) + + self.assertIsInstance(layer, MVLayer) + self.assertEqual(layer["source"], setting.secure_map_service.service_type) + self.assertEqual( + layer["options"]["url"], + "https://example.com/map_service?param1=value1&api_key=test_api_key" + ) + + def test_get_value_as_layer_with_overrides(self): + setting = self.test_app.settings_set.select_subclasses().get(name="secure_map_service") + setting.secure_map_service = self.map_service_with_api_key_no_proxy + setting.save() + + layer = SecureMapServiceSetting.objects.get(name="secure_map_service").get_value( + as_layer=True, + param_overrides={"param1": "overridden_value", "param2": "value2"} + ) + + self.assertIsInstance(layer, MVLayer) + self.assertEqual(layer["source"], setting.secure_map_service.service_type) + self.assertEqual( + layer["options"]["url"], + "https://example.com/map_service?param1=overridden_value&api_key=test_api_key¶m2=value2" + ) + + @mock.patch("tethys_apps.models.requests.get") + def test_get_value_as_response(self, mock_get): + setting = self.test_app.settings_set.select_subclasses().get(name="secure_map_service") + setting.secure_map_service = self.map_service_with_api_key_no_proxy + setting.save() + + mock_response = mock.MagicMock( + status_code=200, + ok=True, + url="https://example.com/map_service?param1=value1&api_key=test_api_key", + ) + mock_get.return_value = mock_response + + response = SecureMapServiceSetting.objects.get(name="secure_map_service").get_value(as_response=True) + + self.assertEqual( + response.url, + "https://example.com/map_service?param1=value1&api_key=test_api_key" + ) + self.assertEqual(response.status_code, 200) + + @mock.patch("tethys_apps.models.requests.get") + def test_get_value_as_response_with_overrides(self, mock_get): + setting = self.test_app.settings_set.select_subclasses().get(name="secure_map_service") + setting.secure_map_service = self.map_service_with_api_key_no_proxy + setting.save() + + mock_response = mock.MagicMock( + status_code=200, + ok=True, + url="https://example.com/map_service?param1=overridden_value&api_key=test_api_key¶m2=value2", + ) + mock_get.return_value = mock_response + + response = SecureMapServiceSetting.objects.get(name="secure_map_service").get_value( + as_response=True, + param_overrides={"param1": "overridden_value", "param2": "value2"} + ) + + self.assertEqual( + response.url, + "https://example.com/map_service?param1=overridden_value&api_key=test_api_key¶m2=value2" + ) + self.assertEqual(response.status_code, 200) + + def test_get_value_get_service(self): + setting = self.test_app.settings_set.select_subclasses().get(name="secure_map_service") + setting.secure_map_service = self.map_service_with_api_key_no_proxy + setting.save() + + service = SecureMapServiceSetting.objects.get(name="secure_map_service").get_value() + + self.assertEqual(service, setting.secure_map_service) + + \ No newline at end of file diff --git a/tethys_apps/base/app_base.py b/tethys_apps/base/app_base.py index 633509c2d7..a5a7055dd1 100644 --- a/tethys_apps/base/app_base.py +++ b/tethys_apps/base/app_base.py @@ -1995,7 +1995,6 @@ def update_secure_map_service_setting_params(cls, name, params): name(str): name of the SecureMapServiceSetting as defined in the app.py. params(dict): dictionary of params to update for the setting. """ - from tethys_apps.models import TethysApp db_app = TethysApp.objects.get(package=cls.package) @@ -2003,12 +2002,14 @@ def update_secure_map_service_setting_params(cls, name, params): try: secure_map_service_setting = secure_map_service_settings.get(name=name) - secure_map_service_setting.update_params(params) + except ObjectDoesNotExist: raise TethysAppSettingDoesNotExist( "SecureMapServiceSetting", name, cls.name ) + secure_map_service_setting.update_params(params) + def sync_all_settings(self, db_app): # custom settings db_app.sync_settings(self.custom_settings(), db_app.custom_settings) diff --git a/tethys_apps/models.py b/tethys_apps/models.py index a1916c8503..5a2b5c92ff 100644 --- a/tethys_apps/models.py +++ b/tethys_apps/models.py @@ -1198,10 +1198,22 @@ def _generate_request(self, param_overrides=None): return url def _build_layer(self, param_overrides=None, request_user=None): + if not self.secure_map_service: + raise TethysAppSettingNotAssigned( + f"Cannot build layer for SecureMapServiceSetting " + f'"{self.name}" for app "{self.tethys_app.package}": ' + f"no SecureMapService assigned." + ) + endpoint = self._generate_request(param_overrides=param_overrides) service = self.secure_map_service options = {"url": endpoint} if not service.use_proxy and service.authentication_method == "oauth": + if not request_user: + raise ValueError( + "Request user must be provided to build layer for OAuth authenticated service." + ) + options["token"] = service.get_oauth_token(request_user) return MVLayer( source=service.service_type, From 2862e56dbbc8d97eb19497d36818d84ddce24946 Mon Sep 17 00:00:00 2001 From: Jacob Johnson Date: Tue, 4 Aug 2026 16:14:01 -0600 Subject: [PATCH 12/50] Fixed db error in test setup --- .../test_models/test_SecureMapServiceSetting.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/tests/unit_tests/test_tethys_apps/test_models/test_SecureMapServiceSetting.py b/tests/unit_tests/test_tethys_apps/test_models/test_SecureMapServiceSetting.py index 23b8ce361a..53d9d709fb 100644 --- a/tests/unit_tests/test_tethys_apps/test_models/test_SecureMapServiceSetting.py +++ b/tests/unit_tests/test_tethys_apps/test_models/test_SecureMapServiceSetting.py @@ -20,7 +20,7 @@ def set_up(self): self.params_without_api_key = {"param1": "value1"} self.map_service_with_api_key_no_proxy = SecureMapService( - name="map_service_with_api_key_no_proxy", + name="api_key_no_proxy", legend_title="Map Service with API Token No Proxy", endpoint="https://example.com/map_service", authentication_method="api_key", @@ -32,8 +32,8 @@ def set_up(self): self.map_service_with_api_key_no_proxy.save() self.map_service_without_api_key_param_no_proxy = SecureMapService( - name="map_service_without_api_key_no_proxy", - legend_title="Map Service without API Token No Proxy", + name="no_api_key_params_no_proxy", + legend_title="Map Service without API Token Param No Proxy", endpoint="https://example.com/map_service", authentication_method="api_key", api_key="test_api_key", @@ -44,7 +44,7 @@ def set_up(self): self.map_service_without_api_key_param_no_proxy.save() self.map_service_with_api_key_with_proxy = SecureMapService( - name="map_service_with_api_key_with_proxy", + name="api_key_with_proxy", legend_title="Map Service with API Token With Proxy", endpoint="https://example.com/map_service", authentication_method="api_key", @@ -56,7 +56,7 @@ def set_up(self): self.map_service_with_api_key_with_proxy.save() self.map_service_with_oauth_no_proxy = SecureMapService( - name="map_service_with_oauth_no_proxy", + name="oauth_no_proxy", legend_title="Map Service with OAuth No Proxy", endpoint="https://example.com/map_service", authentication_method="oauth", @@ -68,7 +68,7 @@ def set_up(self): self.map_service_with_oauth_no_proxy.save() self.map_service_with_oauth_with_proxy = SecureMapService( - name="map_service_with_oauth_with_proxy", + name="oauth_with_proxy", legend_title="Map Service with OAuth With Proxy", endpoint="https://example.com/map_service", authentication_method="oauth", From b83dccbb8f8635d2205bf07f27c61e0b4fa83ef3 Mon Sep 17 00:00:00 2001 From: Jacob Johnson Date: Tue, 4 Aug 2026 16:54:48 -0600 Subject: [PATCH 13/50] Fixed tests --- .../test_models/test_TethysApp.py | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/tests/unit_tests/test_tethys_apps/test_models/test_TethysApp.py b/tests/unit_tests/test_tethys_apps/test_models/test_TethysApp.py index 034d5bac94..8a02a8b93a 100644 --- a/tests/unit_tests/test_tethys_apps/test_models/test_TethysApp.py +++ b/tests/unit_tests/test_tethys_apps/test_models/test_TethysApp.py @@ -19,6 +19,7 @@ SpatialDatasetService, DatasetService, WebProcessingService, + SecureMapService, ) @@ -66,6 +67,15 @@ def set_up(self): ) self.ss.save() + self.ms = SecureMapService( + name="test_ms", + legend_title="Test Map Service", + endpoint="https://example.com/map_service", + authentication_method="api_key", + api_key="test_api_key", + ) + self.ms.save() + def tear_down(self): self.wps.delete() self.ps.delete() @@ -156,7 +166,7 @@ def test_sync_settings_remove_only_setting(self): def test_settings_prop(self): ret = self.test_app.settings - self.assertEqual(21, len(ret)) + self.assertEqual(22, len(ret)) for r in ret: self.assertIsInstance(r, TethysAppSetting) @@ -352,6 +362,12 @@ def test_configured_prop_required_and_set(self): ps_db_setting.persistent_store_service = self.ps ps_db_setting.save() + secure_map_setting = self.test_app.settings_set.select_subclasses().get( + name="secure_map_service" + ) + secure_map_setting.secure_map_service = self.ms + secure_map_setting.save() + ret = self.test_app.configured self.assertTrue(ret) From d24c6d94afd021d87f0895a50f967ef62e5e24d4 Mon Sep 17 00:00:00 2001 From: Jacob Johnson Date: Wed, 5 Aug 2026 09:52:47 -0600 Subject: [PATCH 14/50] Complete coverage for tethys_apps/models.py --- .../test_SecureMapServiceSetting.py | 23 ++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/tests/unit_tests/test_tethys_apps/test_models/test_SecureMapServiceSetting.py b/tests/unit_tests/test_tethys_apps/test_models/test_SecureMapServiceSetting.py index 53d9d709fb..cc3b5320c9 100644 --- a/tests/unit_tests/test_tethys_apps/test_models/test_SecureMapServiceSetting.py +++ b/tests/unit_tests/test_tethys_apps/test_models/test_SecureMapServiceSetting.py @@ -510,4 +510,25 @@ def test_get_value_get_service(self): self.assertEqual(service, setting.secure_map_service) - \ No newline at end of file + def test_update_params_none(self): + setting = self.test_app.settings_set.select_subclasses().get(name="secure_map_service") + setting.secure_map_service = None + setting.save() + + self.assertRaises( + TethysAppSettingNotAssigned, + SecureMapServiceSetting.objects.get(name="secure_map_service").update_params, + {"param1": "new_value"} + ) + + def test_update_params(self): + setting = self.test_app.settings_set.select_subclasses().get(name="secure_map_service") + setting.secure_map_service = self.map_service_with_api_key_no_proxy + setting.save() + + new_params = {"param1": "new_value", "param3": "value3"} + SecureMapServiceSetting.objects.get(name="secure_map_service").update_params(new_params) + + updated_service = SecureMapService.objects.get(pk=setting.secure_map_service.pk) + expected_params = {"param1": "new_value", "api_key": "${api_key}", "param3": "value3"} + self.assertEqual(updated_service.params, expected_params) \ No newline at end of file From 6c66c4794c71607a3c7def3ef0ecf28ebb18c9eb Mon Sep 17 00:00:00 2001 From: Jacob Johnson Date: Wed, 5 Aug 2026 09:53:48 -0600 Subject: [PATCH 15/50] Added support for tokens for GML layers --- .../tethys_gizmos/js/tethys_map_view.js | 50 ++++++++++++------- 1 file changed, 32 insertions(+), 18 deletions(-) diff --git a/tethys_gizmos/static/tethys_gizmos/js/tethys_map_view.js b/tethys_gizmos/static/tethys_gizmos/js/tethys_map_view.js index 6506b9f9c3..e92567c7f7 100644 --- a/tethys_gizmos/static/tethys_gizmos/js/tethys_map_view.js +++ b/tethys_gizmos/static/tethys_gizmos/js/tethys_map_view.js @@ -1033,37 +1033,51 @@ ol_layers_init = function() }); if (current_layer.options.hasOwnProperty('url')) { - let url = current_layer.options.url; - let gml_source = new ol.source.Vector(); + let baseUrl = current_layer.options.url; + let token = current_layer.options.token; + + let gmlSource = new ol.source.Vector({ + format: gmlFormat, + strategy: ol.loadingstrategy.bbox, + loader: function(extent, resolution, projection, success, failer) { + let sep = baseUrl.indexOf('?') === -1 ? '?' : '&'; + let url = baseUrl + sep + 'bbox=' + extent.join(',') + ',EPSG:3857'; + + let headers = {}; + if (token) { + headers['Authorization'] = 'Bearer ' + token; + } - let headers = {}; - if (current_layer.options.token) { - headers['Authorization'] = 'Bearer ' + current_layer.options.token; - } - fetch(url, {credentials: 'same-origin', headers: headers}) + fetch(url, {credentials: 'same-origin', headers: headers}) .then(r => r.text()) .then(text => { - let features = gmlFormat.readFeatures(text, { - dataProjection: 'EPSG:4326', - featureProjection: DEFAULT_PROJECTION, - }); - console.log('GML loaded:', features.length, 'features'); - gml_source.addFeatures(features); + let features = gmlFormat.readFeatures(text, { + dataProjection: 'EPSG:4326', + featureProjection: DEFAULT_PROJECTION, + }); + gmlSource.addFeatures(features); + success(features); }) - .catch(err => console.error('GML load failed:', err)); + .catch(err => { + console.error('GML load failed: ', err); + gmlSource.removeLoadedExtent(extent); + failure(); + }) + } + }) - current_layer_layer_options['source'] = gml_source; - layer = new ol.layer.Vector(current_layer_layer_options); + current_layer_layer_options['source'] = gmlSource; + layer = new ol.layer.Vector(current_layer_layer_options); } else if (current_layer.options.hasOwnProperty('gml')) { - let gml_source = new ol.source.Vector({ + let gmlSource = new ol.source.Vector({ features: gmlFormat.readFeatures(current_layer.options.gml, { dataProjection: 'EPSG:4326', featureProjection: DEFAULT_PROJECTION, }), }); - current_layer_layer_options['source'] = gml_source; + current_layer_layer_options['source'] = gmlSource; layer = new ol.layer.Vector(current_layer_layer_options); } } From 365f12fe2e05458bbd66bcea801f95375b4e3b91 Mon Sep 17 00:00:00 2001 From: Jacob Johnson Date: Wed, 5 Aug 2026 10:19:52 -0600 Subject: [PATCH 16/50] Improved secure map proxy view to support caching and handle 304 responses --- tethys_apps/views.py | 36 +++++++++++++++++++++++++++++++----- 1 file changed, 31 insertions(+), 5 deletions(-) diff --git a/tethys_apps/views.py b/tethys_apps/views.py index bd3ab5e5af..c675ed01b2 100644 --- a/tethys_apps/views.py +++ b/tethys_apps/views.py @@ -24,6 +24,16 @@ logger = logging.getLogger("tethys." + __name__) +# Forwarded so the browser's cached-copy check reaches the service and it can +# reply 304 instead of resending the whole body. +PROXY_FORWARDED_REQUEST_HEADERS = ("If-None-Match", "If-Modified-Since") + +# Forwarded so the browser can cache proxied responses instead of making a new request +# on every pan and zoom. +PROXY_FORWARDED_RESPONSE_HEADERS = ( + "Cache-Control", "ETag", "Expires", "Last-Modified", "Vary", "Age", +) + @login_required() def library(request): @@ -179,6 +189,11 @@ def secure_map_proxy(request, setting_id): if request.content_type: headers["Content-Type"] = request.content_type + for header in PROXY_FORWARDED_REQUEST_HEADERS: + value = request.headers.get(header) + if value: + headers[header] = value + resp = requests.request( method=request.method, url=service.endpoint, @@ -188,8 +203,19 @@ def secure_map_proxy(request, setting_id): stream=True, ) - return StreamingHttpResponse( - resp.iter_content(chunk_size=8192), - status=resp.status_code, - content_type=resp.headers.get("Content-Type", "application/octet-stream"), - ) + # A 304 has no body, so return it directly rather than returning an empty response + if resp.status_code == 304: + proxy_response = HttpResponse(status=304) + else: + proxy_response = StreamingHttpResponse( + resp.iter_content(chunk_size=8192), + status=resp.status_code, + content_type=resp.headers.get("Content-Type", "application/octet-stream"), + ) + + for header in PROXY_FORWARDED_RESPONSE_HEADERS: + value = resp.headers.get(header) + if value: + proxy_response[header] = value + + return proxy_response From 629de194b8e998eafe8dafc19f11115dfb00fdd8 Mon Sep 17 00:00:00 2001 From: Jacob Johnson Date: Wed, 5 Aug 2026 10:20:44 -0600 Subject: [PATCH 17/50] Formatting fixes --- .../test_base/test_app_base.py | 21 +- .../test_SecureMapServiceSetting.py | 357 ++++++++++++------ .../test_models/test_TethysApp.py | 2 +- tethys_apps/models.py | 4 +- tethys_apps/views.py | 9 +- 5 files changed, 267 insertions(+), 126 deletions(-) diff --git a/tests/unit_tests/test_tethys_apps/test_base/test_app_base.py b/tests/unit_tests/test_tethys_apps/test_base/test_app_base.py index 1b8abef687..1f70f647b5 100644 --- a/tests/unit_tests/test_tethys_apps/test_base/test_app_base.py +++ b/tests/unit_tests/test_tethys_apps/test_base/test_app_base.py @@ -1432,7 +1432,7 @@ def test_get_secure_map_service(self, mock_rms): as_layer=False, as_response=False, param_overrides=None, - request_user=None + request_user=None, ) self.assertEqual(mock_rms(), result) @@ -1452,7 +1452,9 @@ def test_get_secure_map_service_as_endpoint(self, mock_rms): @mock.patch("tethys_apps.models.TethysApp") def test__resolve_secure_map_service(self, mock_ta): - mock_get_value = mock_ta.objects.get().secure_map_service_settings.get().get_value + mock_get_value = ( + mock_ta.objects.get().secure_map_service_settings.get().get_value + ) mock_get_value.return_value = "test_secure_map_service" result = TethysAppChild._resolve_secure_map_service(name=self.fake_name) @@ -1476,9 +1478,13 @@ def test__resolve_secure_map_service_object_does_not_exist(self, mock_ta): @mock.patch("tethys_apps.models.SecureMapServiceSetting.update_params") @mock.patch("tethys_apps.models.TethysApp") - def test_update_secure_map_service_setting_params(self, mock_ta, mock_update_params): - mock_setting = mock_ta.objects.get().secure_map_service_settings.get.return_value - + def test_update_secure_map_service_setting_params( + self, mock_ta, mock_update_params + ): + mock_setting = ( + mock_ta.objects.get().secure_map_service_settings.get.return_value + ) + fake_params = {"param1": "value1"} TethysAppChild.update_secure_map_service_setting_params( name=self.fake_name, params=fake_params @@ -1490,11 +1496,12 @@ def test_update_secure_map_service_setting_params(self, mock_ta, mock_update_par mock_setting.update_params.assert_called_with(fake_params) @mock.patch("tethys_apps.models.TethysApp") - def test_update_secure_map_service_setting_params_object_does_not_exist(self, mock_ta): + def test_update_secure_map_service_setting_params_object_does_not_exist( + self, mock_ta + ): mock_get = mock_ta.objects.get().secure_map_service_settings.get mock_get.side_effect = ObjectDoesNotExist - self.assertRaises( TethysAppSettingDoesNotExist, TethysAppChild.update_secure_map_service_setting_params, diff --git a/tests/unit_tests/test_tethys_apps/test_models/test_SecureMapServiceSetting.py b/tests/unit_tests/test_tethys_apps/test_models/test_SecureMapServiceSetting.py index cc3b5320c9..d4c6b56949 100644 --- a/tests/unit_tests/test_tethys_apps/test_models/test_SecureMapServiceSetting.py +++ b/tests/unit_tests/test_tethys_apps/test_models/test_SecureMapServiceSetting.py @@ -14,7 +14,9 @@ class SecureMapServiceSettingTests(TethysTestCase): def set_up(self): self.test_app = TethysApp.objects.get(package="test_app") - self.test_user = self.create_test_user(username="testuser", password="testpassword") + self.test_user = self.create_test_user( + username="testuser", password="testpassword" + ) self.params_with_api_key = {"param1": "value1", "api_key": "${api_key}"} self.params_without_api_key = {"param1": "value1"} @@ -27,7 +29,7 @@ def set_up(self): api_key="test_api_key", service_type="WMS", params=self.params_with_api_key, - use_proxy=False + use_proxy=False, ) self.map_service_with_api_key_no_proxy.save() @@ -39,7 +41,7 @@ def set_up(self): api_key="test_api_key", service_type="WMS", params=self.params_without_api_key, - use_proxy=False + use_proxy=False, ) self.map_service_without_api_key_param_no_proxy.save() @@ -51,7 +53,7 @@ def set_up(self): api_key="test_api_key", service_type="GML", params=self.params_with_api_key, - use_proxy=True + use_proxy=True, ) self.map_service_with_api_key_with_proxy.save() @@ -63,7 +65,7 @@ def set_up(self): oauth_provider="test_oauth_provider", service_type="geojson", params=self.params_without_api_key, - use_proxy=False + use_proxy=False, ) self.map_service_with_oauth_no_proxy.save() @@ -75,7 +77,7 @@ def set_up(self): oauth_provider="test_oauth_provider", service_type="WMS", params=self.params_without_api_key, - use_proxy=True + use_proxy=True, ) self.map_service_with_oauth_with_proxy.save() @@ -87,73 +89,100 @@ def tear_down(self): self.map_service_with_oauth_with_proxy.delete() def test_clean_validation_error(self): - setting = self.test_app.settings_set.select_subclasses().get(name="secure_map_service") + setting = self.test_app.settings_set.select_subclasses().get( + name="secure_map_service" + ) setting.secure_map_service = None setting.save() self.assertRaises( - ValidationError, self.test_app.settings_set.select_subclasses().get(name="secure_map_service").clean, + ValidationError, + self.test_app.settings_set.select_subclasses() + .get(name="secure_map_service") + .clean, ) def test__generate_request_none(self): - setting = self.test_app.settings_set.select_subclasses().get(name="secure_map_service") + setting = self.test_app.settings_set.select_subclasses().get( + name="secure_map_service" + ) setting.secure_map_service = None setting.save() self.assertRaises( TethysAppSettingNotAssigned, - SecureMapServiceSetting.objects.get(name="secure_map_service")._generate_request, + SecureMapServiceSetting.objects.get( + name="secure_map_service" + )._generate_request, ) def test__generate_request_with_proxy(self): - setting = self.test_app.settings_set.select_subclasses().get(name="secure_map_service") + setting = self.test_app.settings_set.select_subclasses().get( + name="secure_map_service" + ) setting.secure_map_service = self.map_service_with_api_key_with_proxy setting.save() - url = SecureMapServiceSetting.objects.get(name="secure_map_service")._generate_request() + url = SecureMapServiceSetting.objects.get( + name="secure_map_service" + )._generate_request() self.assertIsInstance(url, Promise) - self.assertEqual(f"/secure-map-proxy/{setting.secure_map_service.pk}/", force_str(url)) - + self.assertEqual( + f"/secure-map-proxy/{setting.secure_map_service.pk}/", force_str(url) + ) + def test__generate_request_without_proxy_with_api_key(self): - setting = self.test_app.settings_set.select_subclasses().get(name="secure_map_service") + setting = self.test_app.settings_set.select_subclasses().get( + name="secure_map_service" + ) setting.secure_map_service = self.map_service_with_api_key_no_proxy setting.save() - url = SecureMapServiceSetting.objects.get(name="secure_map_service")._generate_request() + url = SecureMapServiceSetting.objects.get( + name="secure_map_service" + )._generate_request() self.assertEqual( - url, - "https://example.com/map_service?param1=value1&api_key=test_api_key" + url, "https://example.com/map_service?param1=value1&api_key=test_api_key" ) def test__generate_request_without_proxy_without_api_key_param(self): - setting = self.test_app.settings_set.select_subclasses().get(name="secure_map_service") + setting = self.test_app.settings_set.select_subclasses().get( + name="secure_map_service" + ) setting.secure_map_service = self.map_service_without_api_key_param_no_proxy setting.save() - url = SecureMapServiceSetting.objects.get(name="secure_map_service")._generate_request() + url = SecureMapServiceSetting.objects.get( + name="secure_map_service" + )._generate_request() self.assertEqual( - url, - "https://example.com/map_service?param1=value1&api_key=test_api_key" + url, "https://example.com/map_service?param1=value1&api_key=test_api_key" ) def test__generate_request_param_overrides(self): - setting = self.test_app.settings_set.select_subclasses().get(name="secure_map_service") + setting = self.test_app.settings_set.select_subclasses().get( + name="secure_map_service" + ) setting.secure_map_service = self.map_service_with_api_key_no_proxy setting.save() - url = SecureMapServiceSetting.objects.get(name="secure_map_service")._generate_request( + url = SecureMapServiceSetting.objects.get( + name="secure_map_service" + )._generate_request( param_overrides={"param1": "overridden_value", "param2": "value2"} ) self.assertEqual( url, - "https://example.com/map_service?param1=overridden_value&api_key=test_api_key¶m2=value2" + "https://example.com/map_service?param1=overridden_value&api_key=test_api_key¶m2=value2", ) def test__build_layer_none(self): - setting = self.test_app.settings_set.select_subclasses().get(name="secure_map_service") + setting = self.test_app.settings_set.select_subclasses().get( + name="secure_map_service" + ) setting.secure_map_service = None setting.save() @@ -163,113 +192,145 @@ def test__build_layer_none(self): ) def test__build_layer_with_api_key(self): - setting = self.test_app.settings_set.select_subclasses().get(name="secure_map_service") + setting = self.test_app.settings_set.select_subclasses().get( + name="secure_map_service" + ) setting.secure_map_service = self.map_service_with_api_key_no_proxy setting.save() - layer = SecureMapServiceSetting.objects.get(name="secure_map_service")._build_layer() + layer = SecureMapServiceSetting.objects.get( + name="secure_map_service" + )._build_layer() self.assertEqual(layer["source"], setting.secure_map_service.service_type) self.assertEqual( layer["options"]["url"], - "https://example.com/map_service?param1=value1&api_key=test_api_key" + "https://example.com/map_service?param1=value1&api_key=test_api_key", ) def test__build_layer_without_api_key_param(self): - setting = self.test_app.settings_set.select_subclasses().get(name="secure_map_service") + setting = self.test_app.settings_set.select_subclasses().get( + name="secure_map_service" + ) setting.secure_map_service = self.map_service_without_api_key_param_no_proxy setting.save() - layer = SecureMapServiceSetting.objects.get(name="secure_map_service")._build_layer() + layer = SecureMapServiceSetting.objects.get( + name="secure_map_service" + )._build_layer() self.assertEqual(layer["source"], setting.secure_map_service.service_type) self.assertEqual( layer["options"]["url"], - "https://example.com/map_service?param1=value1&api_key=test_api_key" + "https://example.com/map_service?param1=value1&api_key=test_api_key", ) def test__build_layer_with_proxy(self): - setting = self.test_app.settings_set.select_subclasses().get(name="secure_map_service") + setting = self.test_app.settings_set.select_subclasses().get( + name="secure_map_service" + ) setting.secure_map_service = self.map_service_with_api_key_with_proxy setting.save() - layer = SecureMapServiceSetting.objects.get(name="secure_map_service")._build_layer() + layer = SecureMapServiceSetting.objects.get( + name="secure_map_service" + )._build_layer() self.assertEqual(layer["source"], setting.secure_map_service.service_type) self.assertIsInstance(layer["options"]["url"], Promise) self.assertEqual( force_str(layer["options"]["url"]), - f"/secure-map-proxy/{setting.secure_map_service.pk}/" + f"/secure-map-proxy/{setting.secure_map_service.pk}/", ) def test__build_layer_param_overrides(self): - setting = self.test_app.settings_set.select_subclasses().get(name="secure_map_service") + setting = self.test_app.settings_set.select_subclasses().get( + name="secure_map_service" + ) setting.secure_map_service = self.map_service_with_api_key_no_proxy setting.save() - layer = SecureMapServiceSetting.objects.get(name="secure_map_service")._build_layer( + layer = SecureMapServiceSetting.objects.get( + name="secure_map_service" + )._build_layer( param_overrides={"param1": "overridden_value", "param2": "value2"} ) self.assertEqual(layer["source"], setting.secure_map_service.service_type) self.assertEqual( layer["options"]["url"], - "https://example.com/map_service?param1=overridden_value&api_key=test_api_key¶m2=value2" + "https://example.com/map_service?param1=overridden_value&api_key=test_api_key¶m2=value2", ) - @mock.patch("tethys_services.models.SecureMapService.get_oauth_token", return_value="test_oauth_token") + @mock.patch( + "tethys_services.models.SecureMapService.get_oauth_token", + return_value="test_oauth_token", + ) def test__build_Layer_oauth(self, mock_got): - setting = self.test_app.settings_set.select_subclasses().get(name="secure_map_service") + setting = self.test_app.settings_set.select_subclasses().get( + name="secure_map_service" + ) setting.secure_map_service = self.map_service_with_oauth_no_proxy setting.save() - layer = SecureMapServiceSetting.objects.get(name="secure_map_service")._build_layer(request_user=self.test_user) + layer = SecureMapServiceSetting.objects.get( + name="secure_map_service" + )._build_layer(request_user=self.test_user) self.assertEqual(layer["source"], setting.secure_map_service.service_type) self.assertEqual( - layer["options"]["url"], - "https://example.com/map_service?param1=value1" - ) - self.assertEqual( - layer["options"]["token"], "test_oauth_token" + layer["options"]["url"], "https://example.com/map_service?param1=value1" ) + self.assertEqual(layer["options"]["token"], "test_oauth_token") def test__build_Layer_oauth_no_request_user(self): - setting = self.test_app.settings_set.select_subclasses().get(name="secure_map_service") + setting = self.test_app.settings_set.select_subclasses().get( + name="secure_map_service" + ) setting.secure_map_service = self.map_service_with_oauth_no_proxy setting.save() self.assertRaises( ValueError, SecureMapServiceSetting.objects.get(name="secure_map_service")._build_layer, - request_user=None + request_user=None, ) def test__fetch_response_none(self): - setting = self.test_app.settings_set.select_subclasses().get(name="secure_map_service") + setting = self.test_app.settings_set.select_subclasses().get( + name="secure_map_service" + ) setting.secure_map_service = None setting.save() self.assertRaises( TethysAppSettingNotAssigned, - SecureMapServiceSetting.objects.get(name="secure_map_service")._fetch_response, + SecureMapServiceSetting.objects.get( + name="secure_map_service" + )._fetch_response, ) def test__fetch_response_no_request_user(self): - setting = self.test_app.settings_set.select_subclasses().get(name="secure_map_service") + setting = self.test_app.settings_set.select_subclasses().get( + name="secure_map_service" + ) setting.secure_map_service = self.map_service_with_oauth_no_proxy setting.save() self.assertRaises( ValueError, - SecureMapServiceSetting.objects.get(name="secure_map_service")._fetch_response, - request_user=None + SecureMapServiceSetting.objects.get( + name="secure_map_service" + )._fetch_response, + request_user=None, ) @mock.patch("tethys_apps.models.requests.get") def test__fetch_response_with_api_key(self, mock_get): - setting = self.test_app.settings_set.select_subclasses().get(name="secure_map_service") + setting = self.test_app.settings_set.select_subclasses().get( + name="secure_map_service" + ) setting.secure_map_service = self.map_service_with_api_key_no_proxy setting.save() @@ -280,18 +341,25 @@ def test__fetch_response_with_api_key(self, mock_get): ) mock_get.return_value = mock_response - response = SecureMapServiceSetting.objects.get(name="secure_map_service")._fetch_response() - + response = SecureMapServiceSetting.objects.get( + name="secure_map_service" + )._fetch_response() + self.assertEqual( response.url, - "https://example.com/map_service?param1=value1&api_key=test_api_key" + "https://example.com/map_service?param1=value1&api_key=test_api_key", ) self.assertEqual(response.status_code, 200) - @mock.patch("tethys_apps.models.SecureMapService.get_oauth_token", return_value="test_oauth_token") + @mock.patch( + "tethys_apps.models.SecureMapService.get_oauth_token", + return_value="test_oauth_token", + ) @mock.patch("tethys_apps.models.requests.get") def test__fetch_response_with_oauth_header(self, mock_get, mock_got): - setting = self.test_app.settings_set.select_subclasses().get(name="secure_map_service") + setting = self.test_app.settings_set.select_subclasses().get( + name="secure_map_service" + ) setting.secure_map_service = self.map_service_with_oauth_no_proxy setting.save() @@ -302,21 +370,27 @@ def test__fetch_response_with_oauth_header(self, mock_get, mock_got): ) mock_get.return_value = mock_response - response = SecureMapServiceSetting.objects.get(name="secure_map_service")._fetch_response(request_user=self.test_user) + response = SecureMapServiceSetting.objects.get( + name="secure_map_service" + )._fetch_response(request_user=self.test_user) mock_got.assert_called_once_with(self.test_user) - self.assertEqual( - response.url, - "https://example.com/map_service?param1=value1" - ) + self.assertEqual(response.url, "https://example.com/map_service?param1=value1") self.assertEqual(response.status_code, 200) @mock.patch("tethys_apps.models.log") - @mock.patch("tethys_apps.models.SecureMapService.get_oauth_token", return_value="test_oauth_token") + @mock.patch( + "tethys_apps.models.SecureMapService.get_oauth_token", + return_value="test_oauth_token", + ) @mock.patch("tethys_apps.models.requests.get") - def test__fetch_response_with_oauth_response_not_ok(self, mock_get, mock_got, mock_log): - setting = self.test_app.settings_set.select_subclasses().get(name="secure_map_service") + def test__fetch_response_with_oauth_response_not_ok( + self, mock_get, mock_got, mock_log + ): + setting = self.test_app.settings_set.select_subclasses().get( + name="secure_map_service" + ) setting.secure_map_service = self.map_service_with_oauth_no_proxy setting.save() @@ -330,21 +404,31 @@ def test__fetch_response_with_oauth_response_not_ok(self, mock_get, mock_got, mo ), ) mock_get.return_value = mock_response - + with self.assertRaises(requests.HTTPError): - SecureMapServiceSetting.objects.get(name="secure_map_service")._fetch_response(request_user=self.test_user) - + SecureMapServiceSetting.objects.get( + name="secure_map_service" + )._fetch_response(request_user=self.test_user) + mock_got.assert_called_once_with(self.test_user) mock_log.error.assert_called_once() logged = mock_log.error.call_args.args[0] - self.assertIn(f"SecureMapService with name {setting.secure_map_service.name} request failed", logged) + self.assertIn( + f"SecureMapService with name {setting.secure_map_service.name} request failed", + logged, + ) self.assertIn("status_code: 403", logged) self.assertIn("Forbidden access message", logged) - @mock.patch("tethys_apps.models.SecureMapService.get_oauth_token", return_value="test_oauth_token") + @mock.patch( + "tethys_apps.models.SecureMapService.get_oauth_token", + return_value="test_oauth_token", + ) @mock.patch("tethys_apps.models.requests.get") def test__fetch_response_with_oauth_good_response(self, mock_get, mock_got): - setting = self.test_app.settings_set.select_subclasses().get(name="secure_map_service") + setting = self.test_app.settings_set.select_subclasses().get( + name="secure_map_service" + ) setting.secure_map_service = self.map_service_with_oauth_no_proxy setting.save() @@ -355,18 +439,20 @@ def test__fetch_response_with_oauth_good_response(self, mock_get, mock_got): ) mock_get.return_value = mock_response - response = SecureMapServiceSetting.objects.get(name="secure_map_service")._fetch_response(request_user=self.test_user) + response = SecureMapServiceSetting.objects.get( + name="secure_map_service" + )._fetch_response(request_user=self.test_user) mock_got.assert_called_once_with(self.test_user) - self.assertEqual( - response, mock_response - ) + self.assertEqual(response, mock_response) self.assertEqual(response.status_code, 200) @mock.patch("tethys_apps.models.requests.get") def test__fetch_response_with_api_key_with_overrides(self, mock_get): - setting = self.test_app.settings_set.select_subclasses().get(name="secure_map_service") + setting = self.test_app.settings_set.select_subclasses().get( + name="secure_map_service" + ) setting.secure_map_service = self.map_service_with_api_key_no_proxy setting.save() @@ -377,18 +463,22 @@ def test__fetch_response_with_api_key_with_overrides(self, mock_get): ) mock_get.return_value = mock_response - response = SecureMapServiceSetting.objects.get(name="secure_map_service")._fetch_response( + response = SecureMapServiceSetting.objects.get( + name="secure_map_service" + )._fetch_response( param_overrides={"param1": "overridden_value", "param2": "value2"} ) self.assertEqual( response.url, - "https://example.com/map_service?param1=overridden_value&api_key=test_api_key¶m2=value2" + "https://example.com/map_service?param1=overridden_value&api_key=test_api_key¶m2=value2", ) self.assertEqual(response.status_code, 200) def test_get_value_none(self): - setting = self.test_app.settings_set.select_subclasses().get(name="secure_map_service") + setting = self.test_app.settings_set.select_subclasses().get( + name="secure_map_service" + ) setting.secure_map_service = None setting.save() @@ -397,71 +487,88 @@ def test_get_value_none(self): SecureMapServiceSetting.objects.get(name="secure_map_service").get_value, ) - def test_get_value_as_endpoint(self): - setting = self.test_app.settings_set.select_subclasses().get(name="secure_map_service") + setting = self.test_app.settings_set.select_subclasses().get( + name="secure_map_service" + ) setting.secure_map_service = self.map_service_with_api_key_no_proxy setting.save() - endpoint = SecureMapServiceSetting.objects.get(name="secure_map_service").get_value(as_endpoint=True) + endpoint = SecureMapServiceSetting.objects.get( + name="secure_map_service" + ).get_value(as_endpoint=True) self.assertEqual( endpoint, - "https://example.com/map_service?param1=value1&api_key=test_api_key" + "https://example.com/map_service?param1=value1&api_key=test_api_key", ) def test_get_value_as_endpoint_with_overrides(self): - setting = self.test_app.settings_set.select_subclasses().get(name="secure_map_service") + setting = self.test_app.settings_set.select_subclasses().get( + name="secure_map_service" + ) setting.secure_map_service = self.map_service_with_api_key_no_proxy setting.save() - endpoint = SecureMapServiceSetting.objects.get(name="secure_map_service").get_value( + endpoint = SecureMapServiceSetting.objects.get( + name="secure_map_service" + ).get_value( as_endpoint=True, - param_overrides={"param1": "overridden_value", "param2": "value2"} + param_overrides={"param1": "overridden_value", "param2": "value2"}, ) self.assertEqual( endpoint, - "https://example.com/map_service?param1=overridden_value&api_key=test_api_key¶m2=value2" + "https://example.com/map_service?param1=overridden_value&api_key=test_api_key¶m2=value2", ) def test_get_value_as_layer(self): - setting = self.test_app.settings_set.select_subclasses().get(name="secure_map_service") + setting = self.test_app.settings_set.select_subclasses().get( + name="secure_map_service" + ) setting.secure_map_service = self.map_service_with_api_key_no_proxy setting.save() - layer = SecureMapServiceSetting.objects.get(name="secure_map_service").get_value(as_layer=True) + layer = SecureMapServiceSetting.objects.get( + name="secure_map_service" + ).get_value(as_layer=True) self.assertIsInstance(layer, MVLayer) self.assertEqual(layer["source"], setting.secure_map_service.service_type) self.assertEqual( layer["options"]["url"], - "https://example.com/map_service?param1=value1&api_key=test_api_key" + "https://example.com/map_service?param1=value1&api_key=test_api_key", ) def test_get_value_as_layer_with_overrides(self): - setting = self.test_app.settings_set.select_subclasses().get(name="secure_map_service") + setting = self.test_app.settings_set.select_subclasses().get( + name="secure_map_service" + ) setting.secure_map_service = self.map_service_with_api_key_no_proxy setting.save() - layer = SecureMapServiceSetting.objects.get(name="secure_map_service").get_value( + layer = SecureMapServiceSetting.objects.get( + name="secure_map_service" + ).get_value( as_layer=True, - param_overrides={"param1": "overridden_value", "param2": "value2"} + param_overrides={"param1": "overridden_value", "param2": "value2"}, ) self.assertIsInstance(layer, MVLayer) self.assertEqual(layer["source"], setting.secure_map_service.service_type) self.assertEqual( layer["options"]["url"], - "https://example.com/map_service?param1=overridden_value&api_key=test_api_key¶m2=value2" + "https://example.com/map_service?param1=overridden_value&api_key=test_api_key¶m2=value2", ) @mock.patch("tethys_apps.models.requests.get") def test_get_value_as_response(self, mock_get): - setting = self.test_app.settings_set.select_subclasses().get(name="secure_map_service") + setting = self.test_app.settings_set.select_subclasses().get( + name="secure_map_service" + ) setting.secure_map_service = self.map_service_with_api_key_no_proxy setting.save() - + mock_response = mock.MagicMock( status_code=200, ok=True, @@ -469,20 +576,24 @@ def test_get_value_as_response(self, mock_get): ) mock_get.return_value = mock_response - response = SecureMapServiceSetting.objects.get(name="secure_map_service").get_value(as_response=True) + response = SecureMapServiceSetting.objects.get( + name="secure_map_service" + ).get_value(as_response=True) self.assertEqual( response.url, - "https://example.com/map_service?param1=value1&api_key=test_api_key" + "https://example.com/map_service?param1=value1&api_key=test_api_key", ) self.assertEqual(response.status_code, 200) @mock.patch("tethys_apps.models.requests.get") def test_get_value_as_response_with_overrides(self, mock_get): - setting = self.test_app.settings_set.select_subclasses().get(name="secure_map_service") + setting = self.test_app.settings_set.select_subclasses().get( + name="secure_map_service" + ) setting.secure_map_service = self.map_service_with_api_key_no_proxy setting.save() - + mock_response = mock.MagicMock( status_code=200, ok=True, @@ -490,45 +601,63 @@ def test_get_value_as_response_with_overrides(self, mock_get): ) mock_get.return_value = mock_response - response = SecureMapServiceSetting.objects.get(name="secure_map_service").get_value( + response = SecureMapServiceSetting.objects.get( + name="secure_map_service" + ).get_value( as_response=True, - param_overrides={"param1": "overridden_value", "param2": "value2"} + param_overrides={"param1": "overridden_value", "param2": "value2"}, ) self.assertEqual( response.url, - "https://example.com/map_service?param1=overridden_value&api_key=test_api_key¶m2=value2" + "https://example.com/map_service?param1=overridden_value&api_key=test_api_key¶m2=value2", ) self.assertEqual(response.status_code, 200) def test_get_value_get_service(self): - setting = self.test_app.settings_set.select_subclasses().get(name="secure_map_service") + setting = self.test_app.settings_set.select_subclasses().get( + name="secure_map_service" + ) setting.secure_map_service = self.map_service_with_api_key_no_proxy setting.save() - service = SecureMapServiceSetting.objects.get(name="secure_map_service").get_value() + service = SecureMapServiceSetting.objects.get( + name="secure_map_service" + ).get_value() self.assertEqual(service, setting.secure_map_service) def test_update_params_none(self): - setting = self.test_app.settings_set.select_subclasses().get(name="secure_map_service") + setting = self.test_app.settings_set.select_subclasses().get( + name="secure_map_service" + ) setting.secure_map_service = None setting.save() self.assertRaises( TethysAppSettingNotAssigned, - SecureMapServiceSetting.objects.get(name="secure_map_service").update_params, - {"param1": "new_value"} + SecureMapServiceSetting.objects.get( + name="secure_map_service" + ).update_params, + {"param1": "new_value"}, ) def test_update_params(self): - setting = self.test_app.settings_set.select_subclasses().get(name="secure_map_service") + setting = self.test_app.settings_set.select_subclasses().get( + name="secure_map_service" + ) setting.secure_map_service = self.map_service_with_api_key_no_proxy setting.save() new_params = {"param1": "new_value", "param3": "value3"} - SecureMapServiceSetting.objects.get(name="secure_map_service").update_params(new_params) + SecureMapServiceSetting.objects.get(name="secure_map_service").update_params( + new_params + ) updated_service = SecureMapService.objects.get(pk=setting.secure_map_service.pk) - expected_params = {"param1": "new_value", "api_key": "${api_key}", "param3": "value3"} - self.assertEqual(updated_service.params, expected_params) \ No newline at end of file + expected_params = { + "param1": "new_value", + "api_key": "${api_key}", + "param3": "value3", + } + self.assertEqual(updated_service.params, expected_params) diff --git a/tests/unit_tests/test_tethys_apps/test_models/test_TethysApp.py b/tests/unit_tests/test_tethys_apps/test_models/test_TethysApp.py index 8a02a8b93a..7b446d01d7 100644 --- a/tests/unit_tests/test_tethys_apps/test_models/test_TethysApp.py +++ b/tests/unit_tests/test_tethys_apps/test_models/test_TethysApp.py @@ -367,7 +367,7 @@ def test_configured_prop_required_and_set(self): ) secure_map_setting.secure_map_service = self.ms secure_map_setting.save() - + ret = self.test_app.configured self.assertTrue(ret) diff --git a/tethys_apps/models.py b/tethys_apps/models.py index 5a2b5c92ff..37eca96fbc 100644 --- a/tethys_apps/models.py +++ b/tethys_apps/models.py @@ -1204,7 +1204,7 @@ def _build_layer(self, param_overrides=None, request_user=None): f'"{self.name}" for app "{self.tethys_app.package}": ' f"no SecureMapService assigned." ) - + endpoint = self._generate_request(param_overrides=param_overrides) service = self.secure_map_service options = {"url": endpoint} @@ -1213,7 +1213,7 @@ def _build_layer(self, param_overrides=None, request_user=None): raise ValueError( "Request user must be provided to build layer for OAuth authenticated service." ) - + options["token"] = service.get_oauth_token(request_user) return MVLayer( source=service.service_type, diff --git a/tethys_apps/views.py b/tethys_apps/views.py index c675ed01b2..45f1c20fd7 100644 --- a/tethys_apps/views.py +++ b/tethys_apps/views.py @@ -29,9 +29,14 @@ PROXY_FORWARDED_REQUEST_HEADERS = ("If-None-Match", "If-Modified-Since") # Forwarded so the browser can cache proxied responses instead of making a new request -# on every pan and zoom. +# on every pan and zoom. PROXY_FORWARDED_RESPONSE_HEADERS = ( - "Cache-Control", "ETag", "Expires", "Last-Modified", "Vary", "Age", + "Cache-Control", + "ETag", + "Expires", + "Last-Modified", + "Vary", + "Age", ) From 8ace493111784fa5a2916d5bbdddcac11a1ecfd7 Mon Sep 17 00:00:00 2001 From: Jacob Johnson Date: Wed, 5 Aug 2026 19:02:39 -0600 Subject: [PATCH 18/50] Added tests for new secure map proxy endpoint --- .../test_SecureMapServiceSetting.py | 1 - .../unit_tests/test_tethys_apps/test_views.py | 152 ++++++++++++++++++ .../test_tethys_portal/test_urls.py | 8 + 3 files changed, 160 insertions(+), 1 deletion(-) diff --git a/tests/unit_tests/test_tethys_apps/test_models/test_SecureMapServiceSetting.py b/tests/unit_tests/test_tethys_apps/test_models/test_SecureMapServiceSetting.py index d4c6b56949..d4e5eec73b 100644 --- a/tests/unit_tests/test_tethys_apps/test_models/test_SecureMapServiceSetting.py +++ b/tests/unit_tests/test_tethys_apps/test_models/test_SecureMapServiceSetting.py @@ -1,4 +1,3 @@ -import pytest import requests from tethys_sdk.testing import TethysTestCase from tethys_apps.models import TethysApp, SecureMapServiceSetting diff --git a/tests/unit_tests/test_tethys_apps/test_views.py b/tests/unit_tests/test_tethys_apps/test_views.py index a15be38317..a8d1d879aa 100644 --- a/tests/unit_tests/test_tethys_apps/test_views.py +++ b/tests/unit_tests/test_tethys_apps/test_views.py @@ -8,6 +8,7 @@ handoff_capabilities, handoff, send_beta_feedback_email, + secure_map_proxy ) @@ -291,3 +292,154 @@ def test_send_beta_feedback_email_send_mail_exception( mock_json_response.assert_called_once_with( {"success": False, "error": "Failed to send email: foo_error"} ) + def test_secure_map_proxy_noneexistent_service(self): + mock_request = mock.MagicMock() + mock_setting_id = 9999 # Assuming this ID does not exist in the database + ret = secure_map_proxy(mock_request, mock_setting_id) + + assert ret.status_code == 404 + assert ret.content == b"Service setting not found." + + @mock.patch("tethys_services.models.SecureMapService.objects.get") + def test_secure_map_proxy_no_auth_token(self, mock_get): + mock_request = mock.MagicMock() + mock_setting_id = 1 + mock_service = mock.MagicMock() + mock_service.authentication_method = "oauth" + mock_service.get_oauth_token.return_value = None + mock_get.return_value = mock_service + + ret = secure_map_proxy(mock_request, mock_setting_id) + + assert ret.status_code == 500 + assert ret.content == b"Failed to retrieve OAuth token." + + @mock.patch("tethys_apps.models.requests.request") + @mock.patch("tethys_services.models.SecureMapService.objects.get") + def test_secure_map_proxy_empty_304(self, mock_get, mock_request_func): + mock_request = mock.MagicMock() + mock_setting_id = 1 + mock_service = mock.MagicMock() + mock_service.authentication_method = "oauth" + mock_service.get_oauth_token.return_value = "test_oauth_token123" + mock_get.return_value = mock_service + + mock_request_func.return_value = mock.MagicMock(status_code=304, content=b"") + + + ret = secure_map_proxy(mock_request, mock_setting_id) + + assert ret.status_code == 304 + + @mock.patch("tethys_apps.models.requests.request") + @mock.patch("tethys_services.models.SecureMapService.objects.get") + def test_secure_map_proxy_oauth_no_extra_headers(self, mock_get, mock_request_func): + mock_request = mock.MagicMock(method="POST", body=b"test_body") + mock_setting_id = 1 + mock_service = mock.MagicMock() + mock_service.authentication_method = "oauth" + mock_service.endpoint = "http://example.com/service" + mock_service.get_oauth_token.return_value = "test_oauth_token123" + mock_get.return_value = mock_service + + mock_response = mock.MagicMock(status_code=200) + mock_response.iter_content.return_value = [b"response_content"] + mock_request_func.return_value = mock_response + + ret = secure_map_proxy(mock_request, mock_setting_id) + + kwargs = mock_request_func.call_args.kwargs + assert kwargs["method"] == mock_request.method + assert kwargs["url"] == mock_service.endpoint + assert kwargs["headers"]["Authorization"] == "Bearer test_oauth_token123" + assert kwargs["data"] == mock_request.body + + assert ret.status_code == 200 + assert b"".join(ret.streaming_content) == b"response_content" + + @mock.patch("tethys_apps.models.requests.request") + @mock.patch("tethys_services.models.SecureMapService.objects.get") + def test_secure_map_proxy_api_key_no_extra_headers(self, mock_get, mock_request_func): + mock_request = mock.MagicMock(method="GET", body=None) + mock_setting_id = 1 + mock_service = mock.MagicMock() + mock_service.authentication_method = "api_key" + mock_service.endpoint = "http://example.com/service" + mock_service.get_resolved_params.return_value = {"api_key": "test_api_key"} + mock_get.return_value = mock_service + + mock_response = mock.MagicMock(status_code=200) + mock_response.iter_content.return_value = [b"response_content"] + mock_request_func.return_value = mock_response + + ret = secure_map_proxy(mock_request, mock_setting_id) + + kwargs = mock_request_func.call_args.kwargs + assert kwargs["method"] == mock_request.method + assert kwargs["url"] == mock_service.endpoint + assert kwargs["params"]["api_key"] == "test_api_key" + assert kwargs["data"] is None + + assert ret.status_code == 200 + assert b"".join(ret.streaming_content) == b"response_content" + + @mock.patch("tethys_apps.models.requests.request") + @mock.patch("tethys_services.models.SecureMapService.objects.get") + def test_secure_map_proxy_oauth_with_extra_headers(self, mock_get, mock_request_func): + mock_request = mock.MagicMock(method="GET", body=None) + mock_request.headers = {"If-None-Match": "test_value", "If-Modified-Since": "test_date"} + mock_setting_id = 1 + mock_service = mock.MagicMock() + mock_service.authentication_method = "oauth" + mock_service.endpoint = "http://example.com/service" + mock_service.get_oauth_token.return_value = "test_oauth_token123" + mock_get.return_value = mock_service + + mock_response = mock.MagicMock(status_code=200) + mock_response.iter_content.return_value = [b"response_content"] + mock_response.headers = {"Cache-Control": "max-age=3600", "Expires": "test_expire_date"} + mock_request_func.return_value = mock_response + + ret = secure_map_proxy(mock_request, mock_setting_id) + + kwargs = mock_request_func.call_args.kwargs + assert kwargs["method"] == mock_request.method + assert kwargs["url"] == mock_service.endpoint + assert kwargs["headers"]["Authorization"] == "Bearer test_oauth_token123" + assert kwargs["headers"]["If-None-Match"] == "test_value" + assert kwargs["headers"]["If-Modified-Since"] == "test_date" + assert kwargs["data"] is None + + assert ret.status_code == 200 + assert b"".join(ret.streaming_content) == b"response_content" + assert ret.headers['Cache-Control'] == "max-age=3600" + assert ret.headers['Expires'] == "test_expire_date" + + + @mock.patch("tethys_apps.models.requests.request") + @mock.patch("tethys_services.models.SecureMapService.objects.get") + def test_secure_map_proxy_api_key_content_type(self, mock_get, mock_request_func): + mock_request = mock.MagicMock(method="POST", body=b"test_body") + mock_request.content_type = "application/json" + mock_setting_id = 1 + mock_service = mock.MagicMock() + mock_service.authentication_method = "api_key" + mock_service.endpoint = "http://example.com/service" + mock_service.get_resolved_params.return_value = {"api_key": "test_api_key"} + mock_get.return_value = mock_service + + mock_response = mock.MagicMock(status_code=200) + mock_response.iter_content.return_value = [b"response_content"] + mock_request_func.return_value = mock_response + + ret = secure_map_proxy(mock_request, mock_setting_id) + + kwargs = mock_request_func.call_args.kwargs + assert kwargs["method"] == mock_request.method + assert kwargs["url"] == mock_service.endpoint + assert kwargs["params"]["api_key"] == "test_api_key" + assert kwargs["headers"]["Content-Type"] == "application/json" + assert kwargs["data"] == b"test_body" + + assert ret.status_code == 200 + assert b"".join(ret.streaming_content) == b"response_content" \ No newline at end of file diff --git a/tests/unit_tests/test_tethys_portal/test_urls.py b/tests/unit_tests/test_tethys_portal/test_urls.py index c7a252987c..05d6fe7ef4 100644 --- a/tests/unit_tests/test_tethys_portal/test_urls.py +++ b/tests/unit_tests/test_tethys_portal/test_urls.py @@ -164,6 +164,14 @@ def test_urlpatterns_update_dask_job_status(self): resolver._func_path, ) + def test_urlpatterns_secure_map_proxy(self): + url = reverse("secure_map_proxy", kwargs={"setting_id": "15"}) + resolver = resolve(url) + self.assertEqual("/secure-map-proxy/15/", url) + self.assertEqual( + "tethys_apps.views.secure_map_proxy", resolver._func_path + ) + @override_settings(REGISTER_CONTROLLER="test") @mock.patch("django.urls.re_path") @mock.patch("tethys_apps.base.function_extractor.TethysFunctionExtractor") From a9cd49a449d2b41014590961cafb993c28668a09 Mon Sep 17 00:00:00 2001 From: Jacob Johnson Date: Wed, 5 Aug 2026 19:14:50 -0600 Subject: [PATCH 19/50] Added tests for changes to json_data_handler template tag --- .../test_templatetags/test_tethys_gizmos.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/tests/unit_tests/test_tethys_gizmos/test_templatetags/test_tethys_gizmos.py b/tests/unit_tests/test_tethys_gizmos/test_templatetags/test_tethys_gizmos.py index de4d834ad6..4eb5fdd05c 100644 --- a/tests/unit_tests/test_tethys_gizmos/test_templatetags/test_tethys_gizmos.py +++ b/tests/unit_tests/test_tethys_gizmos/test_templatetags/test_tethys_gizmos.py @@ -6,6 +6,8 @@ from django.template import base from django.template import TemplateSyntaxError from django.template import Context +from django.utils.functional import Promise +from django.utils.translation import gettext_lazy from importlib import reload from pathlib import Path import sys @@ -147,6 +149,14 @@ def test_json_data_handler_no_datetime(self): # Check Result self.assertEqual("2018", result) + def test_json_data_handler_promise(self): + promise = gettext_lazy("test") + result = gizmos_templatetags.json_data_handler(promise) + + self.assertNotIsInstance(result, Promise) + self.assertIsInstance(result, str) + self.assertEqual(result, "test") + def test_jsonify(self): data = ["foo", {"bar": ("baz", None, 1.0, 2)}] From dd9a8f65cab6d575a1c29a7ec6129cc64b782ece Mon Sep 17 00:00:00 2001 From: Jacob Johnson Date: Wed, 5 Aug 2026 19:34:25 -0600 Subject: [PATCH 20/50] Added testing for build_gml_layer method --- .../test_mixins/test_map_layout.py | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/tests/unit_tests/test_tethys_layouts/test_mixins/test_map_layout.py b/tests/unit_tests/test_tethys_layouts/test_mixins/test_map_layout.py index 47998e75ab..9132bbcfac 100644 --- a/tests/unit_tests/test_tethys_layouts/test_mixins/test_map_layout.py +++ b/tests/unit_tests/test_tethys_layouts/test_mixins/test_map_layout.py @@ -1271,6 +1271,40 @@ class CustomMapLayoutThing(MapLayoutMixin): }, ) + def test_build_gml_layer_default(self): + class CustomMapLayoutThing(MapLayoutMixin): + map_extent = [-65.69, 23.81, -129.17, 49.38] + + ret = CustomMapLayoutThing.build_gml_layer( + endpoint="http://example.com/geoserver/wfs", + layer_name="foo:bar", + layer_title="Foo Bar", + layer_variable="baz", + ) + + self.assertIsInstance(ret, MVLayer) + self.assertEqual(ret.source, "GML") + self.assertEqual(ret.legend_title, "Foo Bar") + self.assertDictEqual( + ret.layer_options, + {"visible": True, "show_download": False}, + ) + self.assertDictEqual( + ret.data, + { + "layer_id": "foo:bar", + "layer_name": "foo:bar", + "popup_title": "Foo Bar", + "layer_variable": "baz", + "toggle_status": True, + "excluded_properties": ["id", "type", "layer_name"], + "removable": False, + "renamable": False, + "show_legend": True, + "legend_url": None, + }, + ) + def test_build_custom_layer_geoserver_wms(self): class CustomMapLayoutThing(MapLayoutMixin): map_extent = [-65.69, 23.81, -129.17, 49.38] From 49ee737ea4b09885cac3f52ae5a9c865dfbfce2e Mon Sep 17 00:00:00 2001 From: Jacob Johnson Date: Thu, 6 Aug 2026 10:25:48 -0600 Subject: [PATCH 21/50] Added tests for Oauth middleware --- .../unit_tests/test_tethys_apps/test_views.py | 1 + .../test_tethys_portal/test_middleware.py | 73 +++++++++++++++++++ 2 files changed, 74 insertions(+) diff --git a/tests/unit_tests/test_tethys_apps/test_views.py b/tests/unit_tests/test_tethys_apps/test_views.py index a8d1d879aa..b17a9ee44c 100644 --- a/tests/unit_tests/test_tethys_apps/test_views.py +++ b/tests/unit_tests/test_tethys_apps/test_views.py @@ -292,6 +292,7 @@ def test_send_beta_feedback_email_send_mail_exception( mock_json_response.assert_called_once_with( {"success": False, "error": "Failed to send email: foo_error"} ) + def test_secure_map_proxy_noneexistent_service(self): mock_request = mock.MagicMock() mock_setting_id = 9999 # Assuming this ID does not exist in the database diff --git a/tests/unit_tests/test_tethys_portal/test_middleware.py b/tests/unit_tests/test_tethys_portal/test_middleware.py index f99fbbdc37..6f64000812 100644 --- a/tests/unit_tests/test_tethys_portal/test_middleware.py +++ b/tests/unit_tests/test_tethys_portal/test_middleware.py @@ -1,10 +1,12 @@ import unittest from unittest import mock from rest_framework.exceptions import AuthenticationFailed +from tethys_portal.views.user import settings from tethys_portal.middleware import ( TethysSocialAuthExceptionMiddleware, TethysAppAccessMiddleware, TethysMfaRequiredMiddleware, + TethysOauthRequiredMiddleware ) from django.core.exceptions import PermissionDenied @@ -843,3 +845,74 @@ def test_mfa_required_all_true__invalid_token__staff_user( # required for all users mock_redirect.assert_called_once_with("mfa_home") + + @mock.patch("tethys_portal.middleware.settings") + def test_oauth_required_init(self, mock_settings): + mock_get_response = mock.MagicMock() + mock_settings.OAUTH_REQUIREMENTS = {"test_key": "test_value"} + obj = TethysOauthRequiredMiddleware(mock_get_response) + self.assertEqual(obj.get_response, mock_get_response) + self.assertEqual(obj.requirements, mock_settings.OAUTH_REQUIREMENTS) + + @mock.patch("tethys_portal.middleware.settings") + def test_oauth_required_no_requirements(self, mock_settings): + mock_get_response = mock.MagicMock() + mock_settings.OAUTH_REQUIREMENTS = {} + mock_request = mock.MagicMock() + TethysOauthRequiredMiddleware(mock_get_response)(mock_request) + mock_get_response.assert_called_once_with(mock_request) + + @mock.patch("tethys_portal.middleware.get_active_app") + @mock.patch("tethys_portal.middleware.settings") + def test_oauth_required_no_active_app(self, mock_settings, mock_gap): + mock_get_response = mock.MagicMock() + mock_settings.OAUTH_REQUIREMENTS = {"test_key": "test_value"} + mock_gap.return_value = None + mock_request = mock.MagicMock() + TethysOauthRequiredMiddleware(mock_get_response)(mock_request) + mock_get_response.assert_called_once_with(mock_request) + + @mock.patch("tethys_portal.middleware.get_active_app") + @mock.patch("tethys_portal.middleware.settings") + def test_oauth_required_no_requirements_for_app(self, mock_settings, mock_gap): + mock_get_response = mock.MagicMock() + mock_settings.OAUTH_REQUIREMENTS = {"test_key": "test_value"} + mock_gap.return_value = mock.MagicMock(package="test_package") + mock_request = mock.MagicMock() + TethysOauthRequiredMiddleware(mock_get_response)(mock_request) + mock_get_response.assert_called_once_with(mock_request) + + @mock.patch("tethys_portal.middleware.get_active_app") + @mock.patch("tethys_portal.middleware.settings") + def test_oauth_required_has_provider(self, mock_settings, mock_gap): + mock_get_response = mock.MagicMock() + mock_settings.OAUTH_REQUIREMENTS = {"test_package": "test_value"} + mock_gap.return_value = mock.MagicMock(package="test_package") + mock_request = mock.MagicMock() + mock_request.user.social_auth.filter.return_value.exists.return_value = True + + TethysOauthRequiredMiddleware(mock_get_response)(mock_request) + mock_get_response.assert_called_once_with(mock_request) + mock_request.user.social_auth.filter.assert_called_once_with(provider="test_value") + + @mock.patch("tethys_portal.middleware.urlencode") + @mock.patch("tethys_portal.middleware.reverse") + @mock.patch("tethys_portal.middleware.messages.info") + @mock.patch("tethys_portal.middleware.redirect") + @mock.patch("tethys_portal.middleware.get_active_app") + @mock.patch("tethys_portal.middleware.settings") + def test_oauth_required_no_provider(self, mock_settings, mock_gap, mock_redirect, mock_messages, mock_reverse, mock_urlencode): + mock_get_response = mock.MagicMock() + mock_settings.OAUTH_REQUIREMENTS = {"test_package": "test_provider"} + mock_gap.return_value = mock.MagicMock(package="test_package") + mock_request = mock.MagicMock() + mock_request.get_full_path.return_value = "/apps/test_package/test_path" + mock_request.user.social_auth.filter.return_value.exists.return_value = False + mock_reverse.return_value="/user/settings/" + mock_urlencode.return_value="next=/apps/test_package/test_path" + TethysOauthRequiredMiddleware(mock_get_response)(mock_request) + mock_messages.assert_called_once_with( + mock_request, + "This application requires authenticating with test_provider. Please link your test_provider account.") + mock_reverse.assert_called_once_with("user:settings") + mock_redirect.assert_called_once_with(f"{mock_reverse.return_value}?next=/apps/test_package/test_path") \ No newline at end of file From 1285412603753c97a107eaa1c5d0b80f7e2df6ac Mon Sep 17 00:00:00 2001 From: Jacob Johnson Date: Fri, 7 Aug 2026 10:51:04 -0600 Subject: [PATCH 22/50] Added tests for SecureMapService form --- .../test_tethys_services/test_admin.py | 45 +++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/tests/unit_tests/test_tethys_services/test_admin.py b/tests/unit_tests/test_tethys_services/test_admin.py index 6ed0e78491..3027bf0c66 100644 --- a/tests/unit_tests/test_tethys_services/test_admin.py +++ b/tests/unit_tests/test_tethys_services/test_admin.py @@ -1,12 +1,14 @@ import unittest from unittest import mock +from django.test.utils import override_settings from django.utils.translation import gettext_lazy as _ from tethys_services.models import ( DatasetService, SpatialDatasetService, WebProcessingService, PostgresPersistentStoreService, + SecureMapService, ) from tethys_services.admin import ( DatasetServiceForm, @@ -17,6 +19,7 @@ SpatialDatasetServiceAdmin, WebProcessingServiceAdmin, PostgresPersistentStoreServiceAdmin, + SecureMapServiceForm, ) @@ -88,6 +91,48 @@ def test_PostgresPersistentStoreServiceForm(self): self.assertEqual(expected_fields, ret.Meta.fields) self.assertTrue("password" in ret.Meta.widgets) + @override_settings(AUTHENTICATION_BACKENDS=[]) + def test_SecureMapServiceForm_no_authentication_backends(self): + mock_args = mock.MagicMock() + + ret = SecureMapServiceForm(mock_args) + self.assertEqual(SecureMapService, ret.Meta.model) + self.assertEqual("__all__", ret.Meta.fields) + self.assertTrue("api_key" in ret.Meta.widgets) + + oauth_provider_field = ret.fields.get("oauth_provider") + self.assertEqual([], oauth_provider_field.choices) + + @override_settings(AUTHENTICATION_BACKENDS=["this_is_a_backend"]) + @mock.patch("tethys_services.admin.import_string") + def test_SecureMapServiceForm_with_authentication_backends_no_name(self, mock_is): + mock_args = mock.MagicMock() + # Mock an object withou a name attribute + mock_is.return_value = object() + + ret = SecureMapServiceForm(mock_args) + self.assertEqual(SecureMapService, ret.Meta.model) + self.assertEqual("__all__", ret.Meta.fields) + self.assertTrue("api_key" in ret.Meta.widgets) + + oauth_provider_field = ret.fields.get("oauth_provider") + self.assertEqual([], oauth_provider_field.choices) + + @override_settings(AUTHENTICATION_BACKENDS=["this_is_a_backend"]) + @mock.patch("tethys_services.admin.import_string") + def test_SecureMapServiceForm_with_authentication_backends(self, mock_is): + mock_args = mock.MagicMock() + mock_is.return_value = mock.MagicMock() + mock_is.return_value.name = "fake_backend_name" + + ret = SecureMapServiceForm(mock_args) + self.assertEqual(SecureMapService, ret.Meta.model) + self.assertEqual("__all__", ret.Meta.fields) + self.assertTrue("api_key" in ret.Meta.widgets) + + oauth_provider_field = ret.fields.get("oauth_provider") + self.assertEqual([("fake_backend_name", "fake_backend_name")], oauth_provider_field.choices) + def test_DatasetServiceAdmin(self): mock_args = mock.MagicMock() expected_fields = ( From ec60524208bd8d7021f361a7b3eaa94d0ad24735 Mon Sep 17 00:00:00 2001 From: Jacob Johnson Date: Fri, 7 Aug 2026 17:07:44 -0600 Subject: [PATCH 23/50] Removed GeoJSON option in service_types on SecureMapService --- .../test_SecureMapServiceSetting.py | 2 +- ...004_alter_securemapservice_service_type.py | 22 +++++++++++++++++++ tethys_services/models.py | 2 +- 3 files changed, 24 insertions(+), 2 deletions(-) create mode 100644 tethys_services/migrations/0004_alter_securemapservice_service_type.py diff --git a/tests/unit_tests/test_tethys_apps/test_models/test_SecureMapServiceSetting.py b/tests/unit_tests/test_tethys_apps/test_models/test_SecureMapServiceSetting.py index d4e5eec73b..37d7c41f70 100644 --- a/tests/unit_tests/test_tethys_apps/test_models/test_SecureMapServiceSetting.py +++ b/tests/unit_tests/test_tethys_apps/test_models/test_SecureMapServiceSetting.py @@ -62,7 +62,7 @@ def set_up(self): endpoint="https://example.com/map_service", authentication_method="oauth", oauth_provider="test_oauth_provider", - service_type="geojson", + service_type="GML", params=self.params_without_api_key, use_proxy=False, ) diff --git a/tethys_services/migrations/0004_alter_securemapservice_service_type.py b/tethys_services/migrations/0004_alter_securemapservice_service_type.py new file mode 100644 index 0000000000..02914c7484 --- /dev/null +++ b/tethys_services/migrations/0004_alter_securemapservice_service_type.py @@ -0,0 +1,22 @@ +# Generated by Django 5.2.9 on 2026-08-07 22:18 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("tethys_services", "0003_securemapservice_and_more"), + ] + + operations = [ + migrations.AlterField( + model_name="securemapservice", + name="service_type", + field=models.CharField( + choices=[("ImageWMS", "WMS"), ("GML", "GML")], + default="ImageWMS", + max_length=50, + ), + ), + ] diff --git a/tethys_services/models.py b/tethys_services/models.py index ace7e1a5fd..08bcd11a76 100644 --- a/tethys_services/models.py +++ b/tethys_services/models.py @@ -421,7 +421,7 @@ class SecureMapService(models.Model): oauth_provider = models.CharField(max_length=100, blank=True) service_type = models.CharField( max_length=50, - choices=[("ImageWMS", "WMS"), ("GML", "GML"), ("geojson", "GeoJSON")], + choices=[("ImageWMS", "WMS"), ("GML", "GML")], default="ImageWMS", ) params = models.JSONField(blank=True, null=True, default=dict) From ed13424841d0cffe65b3ce395cd0bb70b868bfaa Mon Sep 17 00:00:00 2001 From: Jacob Johnson Date: Fri, 7 Aug 2026 17:54:38 -0600 Subject: [PATCH 24/50] Added testsf or SecureMapService --- .../test_models/test_SecureMapService.py | 154 ++++++++++++++++++ 1 file changed, 154 insertions(+) create mode 100644 tests/unit_tests/test_tethys_services/test_models/test_SecureMapService.py diff --git a/tests/unit_tests/test_tethys_services/test_models/test_SecureMapService.py b/tests/unit_tests/test_tethys_services/test_models/test_SecureMapService.py new file mode 100644 index 0000000000..7b5e55f625 --- /dev/null +++ b/tests/unit_tests/test_tethys_services/test_models/test_SecureMapService.py @@ -0,0 +1,154 @@ +from tethys_sdk.testing import TethysTestCase +from tethys_services.models import SecureMapService +from unittest import mock +from django.core.exceptions import ObjectDoesNotExist + + +class SecureMapServiceTests(TethysTestCase): + def test_str(self): + secure_map_service = SecureMapService( + name="test_secure_map_service", endpoint="http://example.com" + ) + self.assertEqual("test_secure_map_service", str(secure_map_service)) + + def test_get_authentication_method_options(self): + secure_map_service = SecureMapService( + name="test_secure_map_service", endpoint="http://example.com" + ) + + expected_options = [ + "api_key", + "oauth" + ] + + actual_options = secure_map_service.get_authentication_method_options() + for option in expected_options: + self.assertIn(option, actual_options) + + def test_get_oauth_token_api_key(self): + secure_map_service = SecureMapService( + name="test_secure_map_service", + endpoint="http://example.com", + authentication_method="api_key" + ) + + with self.assertRaises(ValueError) as context: + secure_map_service.get_oauth_token(user=None) + self.assertEqual( + str(context.exception), + "Authentication method must be 'oauth' to retrieve an OAuth token." + ) + + def test_get_oauth_token_no_provider(self): + secure_map_service = SecureMapService( + name="test_secure_map_service", + endpoint="http://example.com", + authentication_method="oauth" + ) + + with self.assertRaises(ValueError) as context: + secure_map_service.get_oauth_token(user=None) + self.assertEqual( + str(context.exception), + "OAuth provider must be specified to retrieve an OAuth token." + ) + + def test_get_oauth_token_user_not_linked(self): + secure_map_service = SecureMapService( + name="test_secure_map_service", + endpoint="http://example.com", + authentication_method="oauth", + oauth_provider="test_provider" + ) + + mock_user = mock.MagicMock() + mock_user.social_auth.get.side_effect = ObjectDoesNotExist + + with self.assertRaises(ValueError) as context: + secure_map_service.get_oauth_token(user=mock_user) + self.assertEqual( + str(context.exception), + "User not linked to test_provider." + ) + + def test_get_oauth_token_no_token(self): + secure_map_service = SecureMapService( + name="test_secure_map_service", + endpoint="http://example.com", + authentication_method="oauth", + oauth_provider="test_provider" + ) + + mock_user = mock.MagicMock() + mock_user.social_auth.get.return_value.extra_data = {} + + with self.assertRaises(ValueError) as context: + secure_map_service.get_oauth_token(user=mock_user) + self.assertEqual( + str(context.exception), + "No access token found for user." + ) + + def test_get_oauth_token_success(self): + secure_map_service = SecureMapService( + name="test_secure_map_service", + endpoint="http://example.com", + authentication_method="oauth", + oauth_provider="test_provider" + ) + + mock_user = mock.MagicMock() + mock_user.social_auth.get.return_value.extra_data = { + "access_token": "access_token12345" + } + + token = secure_map_service.get_oauth_token(user=mock_user) + self.assertEqual(token, "access_token12345") + + def test_get_resolved_params(self): + secure_map_service = SecureMapService( + name="test_secure_map_service", + endpoint="http://example.com", + authentication_method="api_key", + params=None + ) + + result = secure_map_service.get_resolved_params() + self.assertEqual(result, {}) + + + def test_get_resolved_params_with_api_key(self): + secure_map_service = SecureMapService( + name="test_secure_map_service", + endpoint="http://example.com", + authentication_method="api_key", + params={"param1": "value1", "param2": "value2", "test_api_key": "${api_key}"}, + api_key="api_key_12345" + ) + + resolved_params = secure_map_service.get_resolved_params() + self.assertEqual(resolved_params, {"param1": "value1", "param2": "value2", "test_api_key": "api_key_12345"}) + + def test_get_resolved_params_with_missing_api_key(self): + secure_map_service = SecureMapService( + name="test_secure_map_service", + endpoint="http://example.com", + authentication_method="api_key", + params={"param1": "value1", "param2": "value2", "test_api_key": "${api_key}"}, + api_key=None + ) + + resolved_params = secure_map_service.get_resolved_params() + self.assertEqual(resolved_params, {"param1": "value1", "param2": "value2", "test_api_key": ""}) + + def test_get_resolved_params_with_multiple_params(self): + secure_map_service = SecureMapService( + name="test_secure_map_service", + endpoint="http://example.com", + authentication_method="api_key", + params={"param1": "value1", "name": "${name}", "test_api_key": "${api_key}"}, + api_key="api_key_12345" + ) + + resolved_params = secure_map_service.get_resolved_params() + self.assertEqual(resolved_params, {"param1": "value1", "name": "test_secure_map_service", "test_api_key": "api_key_12345"}) \ No newline at end of file From 9d0aa9225c2d053a9c651ade5091019c21e71143 Mon Sep 17 00:00:00 2001 From: Jacob Johnson Date: Sat, 8 Aug 2026 13:29:38 -0600 Subject: [PATCH 25/50] Fixed failing test --- tests/unit_tests/test_tethys_apps/test_views.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/tests/unit_tests/test_tethys_apps/test_views.py b/tests/unit_tests/test_tethys_apps/test_views.py index b17a9ee44c..8b93345f24 100644 --- a/tests/unit_tests/test_tethys_apps/test_views.py +++ b/tests/unit_tests/test_tethys_apps/test_views.py @@ -3,6 +3,7 @@ from unittest import mock from tethys_apps.models import ProxyApp, TethysApp +from tethys_services.models import SecureMapService from tethys_apps.views import ( library, handoff_capabilities, @@ -293,13 +294,16 @@ def test_send_beta_feedback_email_send_mail_exception( {"success": False, "error": "Failed to send email: foo_error"} ) - def test_secure_map_proxy_noneexistent_service(self): + @mock.patch("tethys_services.models.SecureMapService.objects.get") + def test_secure_map_proxy_noneexistent_service(self, mock_get): mock_request = mock.MagicMock() - mock_setting_id = 9999 # Assuming this ID does not exist in the database + mock_setting_id = 9999 + mock_get.side_effect = SecureMapService.DoesNotExist + ret = secure_map_proxy(mock_request, mock_setting_id) - assert ret.status_code == 404 - assert ret.content == b"Service setting not found." + self.assertEqual(404, ret.status_code) + self.assertEqual(b"Service setting not found.", ret.content) @mock.patch("tethys_services.models.SecureMapService.objects.get") def test_secure_map_proxy_no_auth_token(self, mock_get): From ab74379b482b73c1670eb36361ef9bb3cee2faa3 Mon Sep 17 00:00:00 2001 From: Jacob Johnson Date: Sat, 8 Aug 2026 13:36:06 -0600 Subject: [PATCH 26/50] Formatting fixes --- .../unit_tests/test_tethys_apps/test_views.py | 30 +++--- .../test_tethys_portal/test_middleware.py | 30 ++++-- .../test_tethys_portal/test_urls.py | 4 +- .../test_tethys_services/test_admin.py | 6 +- .../test_models/test_SecureMapService.py | 99 +++++++++++-------- tethys_apps/models.py | 1 + 6 files changed, 103 insertions(+), 67 deletions(-) diff --git a/tests/unit_tests/test_tethys_apps/test_views.py b/tests/unit_tests/test_tethys_apps/test_views.py index 8b93345f24..47d8e705b9 100644 --- a/tests/unit_tests/test_tethys_apps/test_views.py +++ b/tests/unit_tests/test_tethys_apps/test_views.py @@ -9,7 +9,7 @@ handoff_capabilities, handoff, send_beta_feedback_email, - secure_map_proxy + secure_map_proxy, ) @@ -313,7 +313,7 @@ def test_secure_map_proxy_no_auth_token(self, mock_get): mock_service.authentication_method = "oauth" mock_service.get_oauth_token.return_value = None mock_get.return_value = mock_service - + ret = secure_map_proxy(mock_request, mock_setting_id) assert ret.status_code == 500 @@ -331,7 +331,6 @@ def test_secure_map_proxy_empty_304(self, mock_get, mock_request_func): mock_request_func.return_value = mock.MagicMock(status_code=304, content=b"") - ret = secure_map_proxy(mock_request, mock_setting_id) assert ret.status_code == 304 @@ -364,7 +363,9 @@ def test_secure_map_proxy_oauth_no_extra_headers(self, mock_get, mock_request_fu @mock.patch("tethys_apps.models.requests.request") @mock.patch("tethys_services.models.SecureMapService.objects.get") - def test_secure_map_proxy_api_key_no_extra_headers(self, mock_get, mock_request_func): + def test_secure_map_proxy_api_key_no_extra_headers( + self, mock_get, mock_request_func + ): mock_request = mock.MagicMock(method="GET", body=None) mock_setting_id = 1 mock_service = mock.MagicMock() @@ -390,9 +391,14 @@ def test_secure_map_proxy_api_key_no_extra_headers(self, mock_get, mock_request_ @mock.patch("tethys_apps.models.requests.request") @mock.patch("tethys_services.models.SecureMapService.objects.get") - def test_secure_map_proxy_oauth_with_extra_headers(self, mock_get, mock_request_func): + def test_secure_map_proxy_oauth_with_extra_headers( + self, mock_get, mock_request_func + ): mock_request = mock.MagicMock(method="GET", body=None) - mock_request.headers = {"If-None-Match": "test_value", "If-Modified-Since": "test_date"} + mock_request.headers = { + "If-None-Match": "test_value", + "If-Modified-Since": "test_date", + } mock_setting_id = 1 mock_service = mock.MagicMock() mock_service.authentication_method = "oauth" @@ -402,7 +408,10 @@ def test_secure_map_proxy_oauth_with_extra_headers(self, mock_get, mock_request_ mock_response = mock.MagicMock(status_code=200) mock_response.iter_content.return_value = [b"response_content"] - mock_response.headers = {"Cache-Control": "max-age=3600", "Expires": "test_expire_date"} + mock_response.headers = { + "Cache-Control": "max-age=3600", + "Expires": "test_expire_date", + } mock_request_func.return_value = mock_response ret = secure_map_proxy(mock_request, mock_setting_id) @@ -417,9 +426,8 @@ def test_secure_map_proxy_oauth_with_extra_headers(self, mock_get, mock_request_ assert ret.status_code == 200 assert b"".join(ret.streaming_content) == b"response_content" - assert ret.headers['Cache-Control'] == "max-age=3600" - assert ret.headers['Expires'] == "test_expire_date" - + assert ret.headers["Cache-Control"] == "max-age=3600" + assert ret.headers["Expires"] == "test_expire_date" @mock.patch("tethys_apps.models.requests.request") @mock.patch("tethys_services.models.SecureMapService.objects.get") @@ -447,4 +455,4 @@ def test_secure_map_proxy_api_key_content_type(self, mock_get, mock_request_func assert kwargs["data"] == b"test_body" assert ret.status_code == 200 - assert b"".join(ret.streaming_content) == b"response_content" \ No newline at end of file + assert b"".join(ret.streaming_content) == b"response_content" diff --git a/tests/unit_tests/test_tethys_portal/test_middleware.py b/tests/unit_tests/test_tethys_portal/test_middleware.py index 6f64000812..5eb826f454 100644 --- a/tests/unit_tests/test_tethys_portal/test_middleware.py +++ b/tests/unit_tests/test_tethys_portal/test_middleware.py @@ -1,12 +1,11 @@ import unittest from unittest import mock from rest_framework.exceptions import AuthenticationFailed -from tethys_portal.views.user import settings from tethys_portal.middleware import ( TethysSocialAuthExceptionMiddleware, TethysAppAccessMiddleware, TethysMfaRequiredMiddleware, - TethysOauthRequiredMiddleware + TethysOauthRequiredMiddleware, ) from django.core.exceptions import PermissionDenied @@ -893,7 +892,9 @@ def test_oauth_required_has_provider(self, mock_settings, mock_gap): TethysOauthRequiredMiddleware(mock_get_response)(mock_request) mock_get_response.assert_called_once_with(mock_request) - mock_request.user.social_auth.filter.assert_called_once_with(provider="test_value") + mock_request.user.social_auth.filter.assert_called_once_with( + provider="test_value" + ) @mock.patch("tethys_portal.middleware.urlencode") @mock.patch("tethys_portal.middleware.reverse") @@ -901,18 +902,29 @@ def test_oauth_required_has_provider(self, mock_settings, mock_gap): @mock.patch("tethys_portal.middleware.redirect") @mock.patch("tethys_portal.middleware.get_active_app") @mock.patch("tethys_portal.middleware.settings") - def test_oauth_required_no_provider(self, mock_settings, mock_gap, mock_redirect, mock_messages, mock_reverse, mock_urlencode): + def test_oauth_required_no_provider( + self, + mock_settings, + mock_gap, + mock_redirect, + mock_messages, + mock_reverse, + mock_urlencode, + ): mock_get_response = mock.MagicMock() mock_settings.OAUTH_REQUIREMENTS = {"test_package": "test_provider"} mock_gap.return_value = mock.MagicMock(package="test_package") mock_request = mock.MagicMock() mock_request.get_full_path.return_value = "/apps/test_package/test_path" mock_request.user.social_auth.filter.return_value.exists.return_value = False - mock_reverse.return_value="/user/settings/" - mock_urlencode.return_value="next=/apps/test_package/test_path" + mock_reverse.return_value = "/user/settings/" + mock_urlencode.return_value = "next=/apps/test_package/test_path" TethysOauthRequiredMiddleware(mock_get_response)(mock_request) mock_messages.assert_called_once_with( - mock_request, - "This application requires authenticating with test_provider. Please link your test_provider account.") + mock_request, + "This application requires authenticating with test_provider. Please link your test_provider account.", + ) mock_reverse.assert_called_once_with("user:settings") - mock_redirect.assert_called_once_with(f"{mock_reverse.return_value}?next=/apps/test_package/test_path") \ No newline at end of file + mock_redirect.assert_called_once_with( + f"{mock_reverse.return_value}?next=/apps/test_package/test_path" + ) diff --git a/tests/unit_tests/test_tethys_portal/test_urls.py b/tests/unit_tests/test_tethys_portal/test_urls.py index 05d6fe7ef4..cfacdd0794 100644 --- a/tests/unit_tests/test_tethys_portal/test_urls.py +++ b/tests/unit_tests/test_tethys_portal/test_urls.py @@ -168,9 +168,7 @@ def test_urlpatterns_secure_map_proxy(self): url = reverse("secure_map_proxy", kwargs={"setting_id": "15"}) resolver = resolve(url) self.assertEqual("/secure-map-proxy/15/", url) - self.assertEqual( - "tethys_apps.views.secure_map_proxy", resolver._func_path - ) + self.assertEqual("tethys_apps.views.secure_map_proxy", resolver._func_path) @override_settings(REGISTER_CONTROLLER="test") @mock.patch("django.urls.re_path") diff --git a/tests/unit_tests/test_tethys_services/test_admin.py b/tests/unit_tests/test_tethys_services/test_admin.py index 3027bf0c66..607c10b18a 100644 --- a/tests/unit_tests/test_tethys_services/test_admin.py +++ b/tests/unit_tests/test_tethys_services/test_admin.py @@ -94,7 +94,7 @@ def test_PostgresPersistentStoreServiceForm(self): @override_settings(AUTHENTICATION_BACKENDS=[]) def test_SecureMapServiceForm_no_authentication_backends(self): mock_args = mock.MagicMock() - + ret = SecureMapServiceForm(mock_args) self.assertEqual(SecureMapService, ret.Meta.model) self.assertEqual("__all__", ret.Meta.fields) @@ -131,7 +131,9 @@ def test_SecureMapServiceForm_with_authentication_backends(self, mock_is): self.assertTrue("api_key" in ret.Meta.widgets) oauth_provider_field = ret.fields.get("oauth_provider") - self.assertEqual([("fake_backend_name", "fake_backend_name")], oauth_provider_field.choices) + self.assertEqual( + [("fake_backend_name", "fake_backend_name")], oauth_provider_field.choices + ) def test_DatasetServiceAdmin(self): mock_args = mock.MagicMock() diff --git a/tests/unit_tests/test_tethys_services/test_models/test_SecureMapService.py b/tests/unit_tests/test_tethys_services/test_models/test_SecureMapService.py index 7b5e55f625..f8ada47c19 100644 --- a/tests/unit_tests/test_tethys_services/test_models/test_SecureMapService.py +++ b/tests/unit_tests/test_tethys_services/test_models/test_SecureMapService.py @@ -16,10 +16,7 @@ def test_get_authentication_method_options(self): name="test_secure_map_service", endpoint="http://example.com" ) - expected_options = [ - "api_key", - "oauth" - ] + expected_options = ["api_key", "oauth"] actual_options = secure_map_service.get_authentication_method_options() for option in expected_options: @@ -27,38 +24,38 @@ def test_get_authentication_method_options(self): def test_get_oauth_token_api_key(self): secure_map_service = SecureMapService( - name="test_secure_map_service", + name="test_secure_map_service", endpoint="http://example.com", - authentication_method="api_key" + authentication_method="api_key", ) with self.assertRaises(ValueError) as context: secure_map_service.get_oauth_token(user=None) self.assertEqual( - str(context.exception), - "Authentication method must be 'oauth' to retrieve an OAuth token." + str(context.exception), + "Authentication method must be 'oauth' to retrieve an OAuth token.", ) def test_get_oauth_token_no_provider(self): secure_map_service = SecureMapService( - name="test_secure_map_service", + name="test_secure_map_service", endpoint="http://example.com", - authentication_method="oauth" + authentication_method="oauth", ) with self.assertRaises(ValueError) as context: secure_map_service.get_oauth_token(user=None) self.assertEqual( - str(context.exception), - "OAuth provider must be specified to retrieve an OAuth token." + str(context.exception), + "OAuth provider must be specified to retrieve an OAuth token.", ) def test_get_oauth_token_user_not_linked(self): secure_map_service = SecureMapService( - name="test_secure_map_service", + name="test_secure_map_service", endpoint="http://example.com", authentication_method="oauth", - oauth_provider="test_provider" + oauth_provider="test_provider", ) mock_user = mock.MagicMock() @@ -66,17 +63,14 @@ def test_get_oauth_token_user_not_linked(self): with self.assertRaises(ValueError) as context: secure_map_service.get_oauth_token(user=mock_user) - self.assertEqual( - str(context.exception), - "User not linked to test_provider." - ) + self.assertEqual(str(context.exception), "User not linked to test_provider.") def test_get_oauth_token_no_token(self): secure_map_service = SecureMapService( - name="test_secure_map_service", + name="test_secure_map_service", endpoint="http://example.com", authentication_method="oauth", - oauth_provider="test_provider" + oauth_provider="test_provider", ) mock_user = mock.MagicMock() @@ -84,17 +78,14 @@ def test_get_oauth_token_no_token(self): with self.assertRaises(ValueError) as context: secure_map_service.get_oauth_token(user=mock_user) - self.assertEqual( - str(context.exception), - "No access token found for user." - ) + self.assertEqual(str(context.exception), "No access token found for user.") def test_get_oauth_token_success(self): secure_map_service = SecureMapService( - name="test_secure_map_service", + name="test_secure_map_service", endpoint="http://example.com", authentication_method="oauth", - oauth_provider="test_provider" + oauth_provider="test_provider", ) mock_user = mock.MagicMock() @@ -107,48 +98,72 @@ def test_get_oauth_token_success(self): def test_get_resolved_params(self): secure_map_service = SecureMapService( - name="test_secure_map_service", + name="test_secure_map_service", endpoint="http://example.com", authentication_method="api_key", - params=None + params=None, ) result = secure_map_service.get_resolved_params() self.assertEqual(result, {}) - def test_get_resolved_params_with_api_key(self): secure_map_service = SecureMapService( - name="test_secure_map_service", + name="test_secure_map_service", endpoint="http://example.com", authentication_method="api_key", - params={"param1": "value1", "param2": "value2", "test_api_key": "${api_key}"}, - api_key="api_key_12345" + params={ + "param1": "value1", + "param2": "value2", + "test_api_key": "${api_key}", + }, + api_key="api_key_12345", ) resolved_params = secure_map_service.get_resolved_params() - self.assertEqual(resolved_params, {"param1": "value1", "param2": "value2", "test_api_key": "api_key_12345"}) + self.assertEqual( + resolved_params, + {"param1": "value1", "param2": "value2", "test_api_key": "api_key_12345"}, + ) def test_get_resolved_params_with_missing_api_key(self): secure_map_service = SecureMapService( - name="test_secure_map_service", + name="test_secure_map_service", endpoint="http://example.com", authentication_method="api_key", - params={"param1": "value1", "param2": "value2", "test_api_key": "${api_key}"}, - api_key=None + params={ + "param1": "value1", + "param2": "value2", + "test_api_key": "${api_key}", + }, + api_key=None, ) resolved_params = secure_map_service.get_resolved_params() - self.assertEqual(resolved_params, {"param1": "value1", "param2": "value2", "test_api_key": ""}) + self.assertEqual( + resolved_params, + {"param1": "value1", "param2": "value2", "test_api_key": ""}, + ) def test_get_resolved_params_with_multiple_params(self): secure_map_service = SecureMapService( - name="test_secure_map_service", + name="test_secure_map_service", endpoint="http://example.com", authentication_method="api_key", - params={"param1": "value1", "name": "${name}", "test_api_key": "${api_key}"}, - api_key="api_key_12345" + params={ + "param1": "value1", + "name": "${name}", + "test_api_key": "${api_key}", + }, + api_key="api_key_12345", ) - + resolved_params = secure_map_service.get_resolved_params() - self.assertEqual(resolved_params, {"param1": "value1", "name": "test_secure_map_service", "test_api_key": "api_key_12345"}) \ No newline at end of file + self.assertEqual( + resolved_params, + { + "param1": "value1", + "name": "test_secure_map_service", + "test_api_key": "api_key_12345", + }, + ) diff --git a/tethys_apps/models.py b/tethys_apps/models.py index 37eca96fbc..a128903a36 100644 --- a/tethys_apps/models.py +++ b/tethys_apps/models.py @@ -1215,6 +1215,7 @@ def _build_layer(self, param_overrides=None, request_user=None): ) options["token"] = service.get_oauth_token(request_user) + return MVLayer( source=service.service_type, layer_options={"visible": True}, From 94cee61e70f907116c1ce3265d3f9a0f903b5d6c Mon Sep 17 00:00:00 2001 From: Jacob Johnson Date: Mon, 10 Aug 2026 10:53:58 -0600 Subject: [PATCH 27/50] Added command to add a salt_key to portal settings --- tethys_cli/settings_commands.py | 50 +++++++++++++++++++++++++++++++-- 1 file changed, 48 insertions(+), 2 deletions(-) diff --git a/tethys_cli/settings_commands.py b/tethys_cli/settings_commands.py index a743167449..c5ccb81d63 100644 --- a/tethys_cli/settings_commands.py +++ b/tethys_cli/settings_commands.py @@ -14,8 +14,13 @@ import yaml -from .gen_commands import generate_command -from tethys_cli.cli_colors import write_info, write_warning, write_error +from .gen_commands import generate_command, generate_salt_key +from tethys_cli.cli_colors import ( + write_info, + write_warning, + write_error, + write_success, +) from tethys_apps.utilities import get_tethys_home_dir from django.conf import settings @@ -54,6 +59,18 @@ def add_settings_parser(subparsers): "dot notation. (e.g. DATABASES.default.NAME)", nargs=1, ) + settings_parser.add_argument( + "--generate-salt-key", + dest="generate_salt_key", + action="store_true", + help="Generate a new SALT_KEY and write it to the settings in the portal_config.yml file.", + ) + settings_parser.add_argument( + "--overwrite", + dest="overwrite", + action="store_true", + help="Overwrite an existing SALT_KEY without prompting. Use with --generate-salt-key.", + ) settings_parser.set_defaults( func=settings_command, ) @@ -119,6 +136,33 @@ def get_setting(tethys_settings, key): write_info(f"{key}: {pformat(d[k])}") +def generate_salt_key_setting(tethys_settings, overwrite=False): + # Rotating the SALT_KEY makes values encrypted with the previous key + # unrecoverable, so confirm before replacing + if tethys_settings.get("SALT_KEY"): + if not overwrite: + valid_inputs = ("y", "n", "yes", "no") + no_inputs = ("n", "no") + + write_warning( + "WARNING: A SALT_KEY already exists in the portal_config.yml file. " + "Replacing it will make any values encrypted with the existing key " + "unrecoverable." + ) + overwrite_input = input("Overwrite? (y/n): ").lower() + + while overwrite_input not in valid_inputs: + overwrite_input = input("Invalid option. Overwrite? (y/n): ").lower() + + if overwrite_input in no_inputs: + write_warning("Generation of SALT_KEY cancelled.") + return + + tethys_settings["SALT_KEY"] = generate_salt_key() + write_settings(tethys_settings) + write_success("Successfully generated a new SALT_KEY.") + + def remove_setting(tethys_settings, key): result = _get_dict_key_handle(tethys_settings, key) if result is not None: @@ -162,3 +206,5 @@ def settings_command(args): get_setting(tethys_settings, args.get_key) elif args.rm_key: remove_setting(tethys_settings, args.rm_key[0]) + elif args.generate_salt_key: + generate_salt_key_setting(tethys_settings, args.overwrite) From fa6aac7ee5e576a018a5f414ef6bca1f418e5805 Mon Sep 17 00:00:00 2001 From: Jacob Johnson Date: Mon, 10 Aug 2026 15:59:36 -0600 Subject: [PATCH 28/50] Fixed testing issue with pinned pytest-django version --- .github/workflows/tethys.yml | 4 +++- pyproject.toml | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/.github/workflows/tethys.yml b/.github/workflows/tethys.yml index b11b5c9968..a21e5a9414 100644 --- a/.github/workflows/tethys.yml +++ b/.github/workflows/tethys.yml @@ -189,7 +189,9 @@ jobs: conda activate tethys conda list tethys db start - pip install coveralls reactpy_django pytest pytest-django pytest-cov + # pytest-django 4.13 dropped Django 4.2 support. + # Remove this pin when we drop support for Django 4.2 in Tethys. + pip install coveralls reactpy_django pytest pytest-django<4.13 pytest-cov # Test Tethys - name: Test Tethys run: | diff --git a/pyproject.toml b/pyproject.toml index 0b901ad19a..571a711aad 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -47,7 +47,9 @@ dependencies = [ ] [project.optional-dependencies] -test = ["pytest", "pytest-django", "pytest-cov", "requests_mock"] +# pytest-django 4.13 dropped Django 4.2 support. +# Unpin once we drop support for Django 4.2 in Tethys. +test = ["pytest", "pytest-django<4.13", "pytest-cov", "requests_mock"] lint = ["flake8", "black"] [project.urls] From 507049d70a6a30469838cd6c1f0e92d0fa6956dd Mon Sep 17 00:00:00 2001 From: Jacob Johnson Date: Mon, 10 Aug 2026 16:19:31 -0600 Subject: [PATCH 29/50] Fixed testing run errors --- .github/workflows/tethys.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/tethys.yml b/.github/workflows/tethys.yml index a21e5a9414..0899fda398 100644 --- a/.github/workflows/tethys.yml +++ b/.github/workflows/tethys.yml @@ -189,9 +189,9 @@ jobs: conda activate tethys conda list tethys db start - # pytest-django 4.13 dropped Django 4.2 support. + # pytest-django 4.13 dropped Django 4.2 support. # Remove this pin when we drop support for Django 4.2 in Tethys. - pip install coveralls reactpy_django pytest pytest-django<4.13 pytest-cov + pip install coveralls reactpy_django pytest "pytest-django<4.13" pytest-cov # Test Tethys - name: Test Tethys run: | From 4fec38663def4bdecd9eb40b90a8a0317bfb073f Mon Sep 17 00:00:00 2001 From: Jacob Johnson Date: Wed, 12 Aug 2026 14:17:27 -0600 Subject: [PATCH 30/50] Fix for unauthenticated users reaching an error page when trying to access apps that have a required OAuth2 provider --- tethys_portal/middleware.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/tethys_portal/middleware.py b/tethys_portal/middleware.py index 53efae685e..9aed72bfee 100644 --- a/tethys_portal/middleware.py +++ b/tethys_portal/middleware.py @@ -176,7 +176,12 @@ def __call__(self, request): required_provider = self.requirements.get(app_name) if not required_provider: return self.get_response(request) - + # If the user is trying to access an app and there is a required OAuth provider for that app, check if the user is authenticated. + if not request.user.is_authenticated: + next_param = urlencode({"next": request.get_full_path()}) + login_url = reverse("accounts:login") + return redirect(f"{login_url}?{next_param}") + if request.user.social_auth.filter(provider=required_provider).exists(): return self.get_response(request) From b5b36732445990d3b3207b001f52c358a24351c6 Mon Sep 17 00:00:00 2001 From: Jacob Johnson Date: Wed, 12 Aug 2026 16:40:11 -0600 Subject: [PATCH 31/50] Added a SecureMapService tutorial --- docs/conf.py | 2 + .../secure-map-service-initial-map.png | 3 + docs/tutorials.rst | 1 + docs/tutorials/secure_map_services.rst | 817 ++++++++++++++++++ 4 files changed, 823 insertions(+) create mode 100644 docs/images/tutorial/secure_map_services/secure-map-service-initial-map.png create mode 100644 docs/tutorials/secure_map_services.rst diff --git a/docs/conf.py b/docs/conf.py index e711cdce9d..079d909d1b 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -70,6 +70,8 @@ "docker", "docker.types", "docker.errors", + "encrypted_fields", + "encrypted_fields.fields", "guardian", "guardian.admin", "guardian.models", diff --git a/docs/images/tutorial/secure_map_services/secure-map-service-initial-map.png b/docs/images/tutorial/secure_map_services/secure-map-service-initial-map.png new file mode 100644 index 0000000000..e870700ced --- /dev/null +++ b/docs/images/tutorial/secure_map_services/secure-map-service-initial-map.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6e3c569706c139843ff47205a521037114e10e4cb9e4a6c60d1dd2dd62411a01 +size 1036797 diff --git a/docs/tutorials.rst b/docs/tutorials.rst index 6e4f008798..01cec8d091 100644 --- a/docs/tutorials.rst +++ b/docs/tutorials.rst @@ -65,3 +65,4 @@ Complete these tutorials to learn about other Tethys Platform features. tutorials/websockets tutorials/bokeh tutorials/quotas + tutorials/secure_map_services diff --git a/docs/tutorials/secure_map_services.rst b/docs/tutorials/secure_map_services.rst new file mode 100644 index 0000000000..d380f1a5fe --- /dev/null +++ b/docs/tutorials/secure_map_services.rst @@ -0,0 +1,817 @@ +.. _tutorial_secure_map_services: + +******************* +Secure Map Services +******************* + +**Last Updated:** August 2026 + +.. note:: + + **AUTHOR NOTES — REMOVE BEFORE PUBLISHING** + + This draft was written from the ``SecureMapService`` implementation on the ``secure-map-service`` + branch and from the working example app in + :file:`19.b.1/testing_app/tethysapp-test_app`. Items that could not be verified from the source + are called out in ``TODO`` / ``VERIFY`` notes throughout the document. Search for "TODO" and + "VERIFY" to find them all. Outstanding items: + + * **No starting-point / solution repository exists yet.** Every other tutorial clones a + ``tethysapp-*_tutorial`` repo and checks out a per-section solution branch. This tutorial + currently just tells the reader to scaffold an app. Decide on a repo name (e.g. + ``tethysapp-secure_map_services_tutorial``) and add the ``git clone`` / ``git checkout`` + blocks that the other tutorials use. + * **No screenshots.** There is no :file:`docs/tutorials/secure_map_services/resources/` + directory. Admin form screenshots and a finished-app screenshot should be added, along with + the ``.. figure::`` directives (currently omitted so the docs build does not break on + missing images). + * **No SDK reference docs.** ``SecureMapServiceSetting`` is not documented in + :file:`docs/tethys_sdk/app_settings.rst`, and ``SecureMapService`` is not documented in + :file:`docs/tethys_portal/admin_pages/tethys_services.rst`. This tutorial links to those + pages as though the sections exist. Either add those sections or remove the links. + * The public example service used throughout (GEGD / GRiD, ``https://grid.nga.mil/grid``) + requires credentials that most readers will not have. Consider swapping in a service readers + can actually sign up for, or clearly framing these as illustrative. + + +This tutorial demonstrates how to use **Secure Map Services** in a Tethys app. A Secure Map Service +lets app developers store the connection information and credentials for map services that +require authentication — an API key or an OAuth2 access token — and then lets an app consume that +service without ever handling the credentials itself or exposing them to the user/browser. + +The following topics are covered: + +* Registering a Secure Map Service in the Tethys Portal admin +* Declaring ``SecureMapServiceSetting`` in your :term:`app class` +* Adding a secure service to a Map Layout as a layer and as a basemap +* Proxying requests so credentials never reach the browser +* Changing service parameters at runtime +* Requiring users to link an OAuth account before being able to access the app. + +0. Prerequisites +================ + +For this tutorial, we'll be utilizing two services that require authentication to use their data in your app: + +* **GEGD** - a WMS imagery service authenticated with an API key. +* **GRiD** - a REST API authenticated with an OAuth2 access token. + +Before beginning this tutorial, make sure you have created an account with each service and have retreived an API key from the GEGD page. + +1. Scaffold a New App +===================== + +To generate a new app using the scaffold, open a terminal, :ref:`activate_environment`, and execute the following commands: + +.. code-block:: bash + + tethys scaffold secure_map_app + +You will be prompted to enter metadata about your app such as, proper name, version, author, and description. All of these metadata are optional. You can accept the default value that is shown in the square brackets by pressing enter. + +You'll then need to install your app by running these commands: + +.. code-block:: bash + + cd tethysapp-secure_map_app + tethys install -d + + +2. Setup portal_config +====================== + +Next, in order to use the secure encryption features of a SecureMapService, you'll need a generated portal_config file. You can generate it using the following command: + +.. code-block:: bash + + tethys gen portal_config + +If you've already generated a portal_config file, you may still need to generate a salt key in that file. You can do so by running the following command: + +.. code-block:: bash + + tethys settings --generate-salt-key + + +3. Add a MapLayout +================== + +We'll be using a MapLayout for this application, so we'll begin by adding a MapLayout controller to your app. Begin by opening your `controllers.py` file and replacing the contents with the following code: + +.. code-block:: python + + from tethys_sdk.layouts import MapLayout + from tethys_sdk.routing import controller + from .app import App + + @controller(name='home') + class SecureMapServiceMapLayout(MapLayout): + app = App + base_template = f'{App.package}/base.html' + template_name = f'{App.package}/home.html' + map_title = 'Secure Map Services Tutorial' + +Next open the `home.html` in your `templates/secure_map_services_tutorial` directory and replace the contents with the following code: + +.. code-block:: html+django + + {% extends "tethys_layouts/map_layout/map_layout.html" %} + {% load tethys %} + +Now go ahead and open your app at localhost:8000 and you should see a fully interactive map like this one: + +########################### +PUT A SCREENSHOT IMAGE HERE +########################### + +4. Add a Basemap +================ +Now, you'll be setting up your first SecureMapService that you'll be using as a basemap. You'll want to make sure you have created your GEGD account and have your API key ready. + +First, open your `app.py` file and first add the following import to the top of your file: + +.. code-block:: python + + from tethys_sdk.app_settings import SecureMapServiceSetting + + +Then add this to your main App class: + +.. code-block:: python + :emphasize-lines: 16, 18-31 + + class App(TethysAppBase): + """ + Tethys app class for Secure Map App. + """ + name = 'Secure Map App' + description = '' + package = 'secure_map_app' # WARNING: Do not change this value + index = 'home' + icon = f'{package}/images/icon.gif' + root_url = 'secure-map-app' + color = '#5f27cd' + tags = '' + enable_feedback = False + feedback_emails = [] + + GEGD_SECURE_MAP_SERVICE_NAME = "gegd_secure_map_service" + + def secure_map_service_settings(self): + """ + Returns the settings for the secure map service. + """ + + secure_map_service_settings = ( + SecureMapServiceSetting( + name=self.GEGD_SECURE_MAP_SERVICE_NAME, + description="Secure Map Service for the app to use with GEGD", + required=True + ), + ) + + return secure_map_service_settings + +Now, you'll need to open your app and go to the app settings and look for the Secure Map Service Settings section. You should see a setting for the GEGD service. Click on the dropdown and select "Add New Secure Map Service" to add your GEGD service. + +Use the following configurations for your new Secure Map Service: + +- **Name:** GEGD Secure Map Service +- **Endpoint:** https://pro.gegd.com/streaming/v1/ogc/wms +- **Legend Title:** GEGD +- **Authentication Method:** API Key +- **API Key:** [YOUR API KEY] +- **Service Type:** WMS +- **Use Proxy for Requests:** True +- **Parameters:** + +.. code-block:: json + + { + "maxar_api_key": "${api_key}", + "layers": "Maxar:Imagery", + "version": "1.3.0" + } + +Then save your new Secure Map Service and assign it to the GEGD Secure Map Service setting, then save your app settings. + +Now we'll be adding the GEGD service as a basemap to your MapLayout. Open your `controllers.py` and add the following to your MapLayout class: + +.. code-block:: python + :emphasize-lines: 6-11 + + @controller(name='home') + class SecureMapServiceMapLayout(MapLayout): + app = App + base_template = f'{App.package}/base.html' + map_title = 'Secure Map Services Tutorial' + basemaps = [ + {"WMS": { + "url": App.get_secure_map_service(App.GEGD_SECURE_MAP_SERVICE_NAME, as_endpoint=True), + "control_label": "GEGD Map" + }} + ] + +Now reopen your app and you should see the GEGD imagery on your map. + +Notice that if you look at the network traffic in your browser, you will see that the requests to the GEGD service are being proxied through your Tethys Portal and the API key is not visible in the request. + +5. Configure for OAuth2 with GRiD +================================= +Next, we want to add a map layer using the GRiD service. Before we can authenticate with OAuth2 to do that, we need to configure the Tethys Portal to use GRiD as an OAuth2 provider. + +Start by running this command: + +.. code-block:: bash + + tethys settings --set AUTHENTICATION_BACKENDS "['tethys_services.backends.grid.GRiDOAuth2']" + +Then configure your portal to require users to link their GRiD account before being able to access the app by running this command: + +.. code-block:: bash + + tethys settings --set OAUTH_REQUIREMENTS.secure_map_app grid + +The last step required to configure your application to work with GRiD is to register your application with GRiD. You'll need to register your application with GRiD to get a client ID and client secret. You can do this by going to the GRiD developer portal and creating a new application. Use the following settings: +- Go to https://grid.nga.mil/grid/api/application/list +- Click on "Create new application" +- Fill out the form with the following settings: + - **Application Name:** [YOUR APP NAME] + - **Redirect URI:** http://localhost:8000/oauth2/complete/grid/ +- Before submitting the form, make sure you've copied the client ID and client secret that are generated for your application. You'll need to add these to your Tethys Portal settings. +- Add the provided redirect URI to the Redirect uris field, along with `http://localhost:8000/oauth2/complete/grid/`, with each URI separated by a space. You can update this list later when you deploy your app to a production server. + +Once you've registered your application, you'll need to add the client ID and client secret to your Tethys Portal settings. You can do this by running the following commands: + +.. code-block:: bash + + tethys settings --set OAUTH_CONFIG.SOCIAL_AUTH_GRID_KEY [YOUR CLIENT ID] + tethys settings --set OAUTH_CONFIG.SOCIAL_AUTH_GRID_SECRET [YOUR CLIENT SECRET] + + +Now when you try to open your app you will be redirected to your account settings because you haven't linked your account. Scroll down until you find the "Single Sign On" section. Then click on "connect grid". This will redirect you to log in with your GRiD account and bring you back to the account settings. Once you've linked your account, you can go back to the app and you should be able to access it. + +6. Add a Map Layer +================== + +Next, you'll be setting up your second SecureMapService that you'll be using as a map layer. You'll want to make sure you have created your GRiD account since we'll be using that service for this layer. + +Begin by adding a new SecureMapServiceSetting to your app class in `app.py`: + +.. code-block:: python + :emphasize-lines: 17, 30-34 + + class App(TethysAppBase): + """ + Tethys app class for Secure Map App. + """ + name = 'Secure Map App' + description = '' + package = 'secure_map_app' # WARNING: Do not change this value + index = 'home' + icon = f'{package}/images/icon.gif' + root_url = 'secure-map-app' + color = '#5f27cd' + tags = '' + enable_feedback = False + feedback_emails = [] + + GEGD_SECURE_MAP_SERVICE_NAME = "gegd_secure_map_service" + GRID_SECURE_MAP_SERVICE_NAME = 'grid_secure_map_service' + + def secure_map_service_settings(self): + """ + Returns the settings for the secure map service. + """ + + secure_map_service_settings = ( + SecureMapServiceSetting( + name=self.GEGD_SECURE_MAP_SERVICE_NAME, + description="Secure Map Service for the app to use with GEGD", + required=True + ), + SecureMapServiceSetting( + name=self.GRID_SECURE_MAP_SERVICE_NAME, + description='Secure Map Service for app to use with GRiD', + required=True, + ), + ) + + return secure_map_service_settings + +Now let's configure this new Secure Map Service in the Tethys Portal. Open your app and go to the app settings and scroll down to the Secure Map Service Settings section. You should see a setting for the GRiD service. Click on the dropdown and select "Add New Secure Map Service" to add your GRiD service. + +Use the following configurations for your new Secure Map Service: + +- **Name:** GRiD Secure Map Service +- **Endpoint:** https://grid.nga.mil/grid/api/ogcservices +- **Legend Title:** GRiD +- **Authentication Method:** OAuth +- **OAuth Provider:** grid +- **Service Type:** GML +- **Use Proxy for Requests:** True +- **Parameters:** + +.. code-block:: json + + { + "service": "wfs", + "version": "1.1.0", + "request": "getfeature", + "typename": "ms:gridws_raster", + "maxfeatures": "200" + } + +Then save your new Secure Map Service and assign it to the GRiD Secure Map Service setting, then save your app settings. + +Our next step will be to add a new map layer to our MapLayout using the GRiD service. Open your `controllers.py` and add the following to your MapLayout class: + +.. code-block:: python + :emphasize-lines: 5-19 + + @controller(name='home') + class SecureMapServiceMapLayout(MapLayout): + ... + + def compose_layers(self, request, map_view, *args, **kwargs): + grid_layer = App.get_secure_map_service( + App.GRID_SECURE_MAP_SERVICE_NAME, + as_layer=True, + request_user=request.user + ) + + layer_groups = [ + self.build_layer_group( + id='grid_layer_group', + display_name='GRiD Layer Group', + layers=[grid_layer] + ) + ] + return layer_groups + + Now just go ahead and refresh your app and you should see the GRiD layer on your map. You can toggle the visibility of the layer using the layers control in the top right corner of the map. + +7. Update Service Parameters +============================ +Now that you have data from GRiD displaying on your map in the form of a layer, you may want to change the parameters of the service to display different data. You can do this by going into the service settings and manually updating the parameters field. But you can also do this in your app dynamically using the `update_secure_map_service_setting_params()` method in your app code. + +In order to demonstrate how this can be done dynamically in your app, we'll add a form to the app that will allow the user to select which GRiD layer they want to display on the map. We'll then use the `update_secure_map_service_setting_params()` method to update the parameters of the GRiD service based on the user's selection. + +We'll begin by adding a custom map tab to your MapLayout that will contain this form. Open `home.html` and add the following code: + +.. code-block:: html+django + + {% block custom_map_tabs %} + + {% endblock %} + + {% block custom_map_tab_panels %} +
+
+ {% csrf_token %} + + {% gizmo grid_type %} + {% gizmo update_grid_button %} +
+
+ {% endblock %} + +Now we'll need to add the gizmos for the form to your MapLayout class in `controllers.py`. Add the following code: + +.. code-block:: python + + from tethys_sdk.gizmos import Button, SelectInput + ... + + class SecureMapServiceMapLayout(MapLayout): + ... + + def get_context(self, request, context, *args, **kwargs): + context = super().get_context(request, context, *args, **kwargs) + grid_type = SelectInput( + name="grid_type", + display_text="Select GRID Layer", + options=[ + ("Point Cloud", "pointcloud"), + ("Raster", "raster"), + ], + initial="pointcloud", + ) + + update_grid_button = Button( + name="update_grid", + display_text="Update GRID Layer", + submit=True, + ) + + context["grid_type"] = grid_type + context["update_grid_button"] = update_grid_button + + return context + +Now if you refresh your app, you should see a new tab on the left that you can switch to with a select input and a button. Right now if you click the "Update GRID Layer" button, nothing will happen. We'll need to add a `post()` method to your MapLayout class to handle the form submission and update the GRiD service parameters. + +To add that functionality, first add the following imports to the top of `controllers.py`: + +.. code-block:: python + + from django.http import HttpResponse + from django.shortcuts import redirect + +Then add the following `post()` method to your MapLayout class: + +.. code-block:: python + + def post(self, request, *args, **kwargs): + grid_type = request.POST.get("grid_type") + if grid_type not in ["pointcloud", "raster"]: + return HttpResponse("Invalid GRID layer type selected.", status=400) + + App.update_secure_map_service_setting_params( + App.GRID_SECURE_MAP_SERVICE_NAME, + params={ + "typename": f"ms:gridws_{grid_type}", + }, + ) + + return redirect(request.path) + +Now go ahead and try selecting a different GRiD layer from the select input and clicking the "Update GRID Layer" button. The app will refresh and you should see the layer on the map update to reflect your selection. You can even go in and look at the service settings and see that the parameters have been updated to reflect your selection manually. + +8. Using a Secure Map Service as a Response +=========================================== + +You can access a SecureMapService as a response in order to work directly with the data returned from the service in your python. We'll be using this to retreive spatial data from the GRiD API and format it into GeoJSON to display AOIs on the map. + +First, let's add a new SecureMapServiceSetting to your app class in `app.py` for the GRiD AOI service. This service will be used to both dsiplay existing AOIs on the map, and to submit new AOIs to the GRiD service. Add the following code to your app class: + +.. code-block:: python + :emphasize-lines: 18, 36-40 + + class App(TethysAppBase): + """ + Tethys app class for Secure Map App. + """ + name = 'Secure Map App' + description = '' + package = 'secure_map_app' # WARNING: Do not change this value + index = 'home' + icon = f'{package}/images/icon.gif' + root_url = 'secure-map-app' + color = '#5f27cd' + tags = '' + enable_feedback = False + feedback_emails = [] + + GEGD_SECURE_MAP_SERVICE_NAME = "gegd_secure_map_service" + GRID_SECURE_MAP_SERVICE_NAME = 'grid_secure_map_service' + GRID_SECURE_AOI_MAP_SERVICE_NAME = "grid_secure_aoi_map_service" + + def secure_map_service_settings(self): + """ + Returns the settings for the secure map service. + """ + + secure_map_service_settings = ( + SecureMapServiceSetting( + name=self.GEGD_SECURE_MAP_SERVICE_NAME, + description="Secure Map Service for the app to use with GEGD", + required=True + ), + SecureMapServiceSetting( + name=self.GRID_SECURE_MAP_SERVICE_NAME, + description='Secure Map Service for app to use with GRiD', + required=True, + ), + SecureMapServiceSetting( + name=self.GRID_SECURE_AOI_MAP_SERVICE_NAME, + description='Secure Map Service for app to use with GRiD for interacting with AOIs', + required=True + ), + ) + + return secure_map_service_settings + +Use these configurations for the new Secure Map Service: + +- **Name:** GRiD AOI Secure Map Service +- **Endpoint:** https://grid.nga.mil/grid/api/v3/aois +- **Legend Title:** GRiD AOIs +- **Authentication Method:** OAuth +- **OAuth Provider:** grid +- **Service Type:** WMS +- **Use Proxy for Requests:** True +- **Parameters:** + +.. code-block:: json + + { + "intersections": "false", + "intersection_geoms": "false", + "export_full": "false", + "sort": "pk" + } + +Save your new Secure Map Service and assign it to the GRiD AOI Secure Map Service setting, then save your app settings. + +Next, add a new layer to your MapLayout class in `controllers.py` that will display the existing AOIs on the map. + +For that you'll need to first add the following packages to the dependencies of your application. Open your `install.yml` and edit the requirements block like so: + +.. code-block:: yaml + :emphasize-lines: 6, 8 + + requirements: + # Putting in a skip true param will skip the entire section. Ignoring the option will assume it be set to False + skip: false + conda: + channels: + - conda-forge + packages: + - shapely + +Next, add the following imports to `controllers.py`: + +.. code-block:: python + :emphasize-lines: 3, 6-7 + + from tethys_sdk.layouts import MapLayout + from tethys_sdk.routing import controller + from tethys_sdk.gizmos import SelectInput, Button, MVLayer + from django.http import HttpResponse + from django.shortcuts import redirect + from shapely import wkt + from shapely.geometry import mapping + import json + from .app import App + +Now add the following helper function to `controllers.py` This function will help format the AOI data returned from the GRiD service into a GeoJSON format that can be used to create a new MVLayer: + +.. code-block:: python + + def format_aoi_geojson(raw_aoi_data): + features = [] + for aoi in raw_aoi_data.get("aois", []): + wkt_str = aoi.get("geom") + if not wkt_str: + continue + try: + geom = wkt.loads(wkt_str) + except Exception: + continue + features.append( + { + "type": "Feature", + "geometry": mapping(geom), + "properties": { + "pk": aoi.get("pk"), + "name": aoi.get("name"), + "user": aoi.get("user"), + "created_at": aoi.get("created_at"), + "area": aoi.get("area"), + "notes": aoi.get("notes"), + "subscribed": aoi.get("subscribed"), + "export_count": len(aoi.get("exports") or []), + }, + } + ) + + return { + "type": "FeatureCollection", + "crs": {"type": "name", "properties": {"name": "EPSG:4326"}}, + "features": features, + } + +Now update your `compose_layers()` method in your MapLayout class to create a new MVLayer for the AOIs: + +.. code-block:: python + :emphasize-lines: 8-12, 14, 16-34, 40 + + def compose_layers(self, request, map_view, *args, **kwargs): + grid_layer = App.get_secure_map_service( + App.GRID_SECURE_MAP_SERVICE_NAME, + as_layer=True, + request_user=request.user + ) + + grid_aoi_response = App.get_secure_map_service( + App.GRID_SECURE_AOI_MAP_SERVICE_NAME, + as_response=True, + request_user=request.user, + ).json() + + aois_geojson = format_aoi_geojson(grid_aoi_response) + + aoi_layer = MVLayer( + source='GeoJSON', + options=aois_geojson, + legend_title='GRiD AOIs', + layer_options={ + 'style': {'ol.style.Style': { + 'stroke': {'ol.style.Stroke': { + 'color': '#ff7800', + 'width': 2, + }}, + 'fill': {'ol.style.Fill': { + 'color': 'rgba(255,120,0,0.15)', + }}, + }}, + "visible": True, + }, + data={'layer_id': 'grid_aois', 'layer_name': 'grid_aois', "show_legend": True,}, + feature_selection=True, + ) + + layer_groups = [ + self.build_layer_group( + id='grid_layer_group', + display_name='GRiD Layer Group', + layers=[grid_layer, aoi_layer] + ) + ] + return layer_groups + +Go ahead and refresh your app and you should see the AOIs displayed on the map as a new layer. You can toggle the visibility of the AOI layer using the layers control in the top right corner of the map. + +9. Using a Secure Map Service as an Endpoint +============================================ + +The last feature we'll be adding to our app is the ability to use a SecureMapService as an endpoint that can be used to make requests to the service from your app. You'll be using the GRiD service for this example, allowing you to draw AOIs on the map and submitting them to the GRiD service to create new AOIs using the GRiD REST API. + +Now let's look at adding a new AOI to the GRiD service using the GRiD AOI Secure Map Service. We'll be adding a new form to the custom map tab that will allow the user to draw a new AOI on the map and submit it to the GRiD service. + +First, we'll need to make some updates to your `controllers.py` file. + +To start, update your imports: + +.. code-block:: python + :emphasize-lines: 3, 9 + + from tethys_sdk.layouts import MapLayout + from tethys_sdk.routing import controller + from tethys_sdk.gizmos import SelectInput, Button, MVLayer, MVDraw, TextInput + from django.http import HttpResponse + from django.shortcuts import redirect + from shapely import wkt + from shapely.geometry import mapping + import json + import requests + from .app import App + +Next, you need to add drawing capabilities to your map so that users can draw AOIs on the map that they would like to submit to the GRiD service. We'll be using the MVDraw gizmo for this. + +Begin by adding the MVDraw gizmo to your class: + +.. code-block:: python + :emphasize-lines: 6-11 + + @controller(name='home') + class SecureMapServiceMapLayout(MapLayout): + app = App + base_template = f'{App.package}/base.html' + template_name = f'{App.package}/home.html' + map_title = 'Secure Map Services Tutorial' + basemaps = [ + {"WMS": { + "url": App.get_secure_map_service(App.GEGD_SECURE_MAP_SERVICE_NAME, as_endpoint=True), + "control_label": "GEGD Map" + }} + ] + draw = MVDraw( + controls=["Modify", "Delete", "Move", "Polygon", "Box"], + initial="Move", + output_format="WKT", + ) + + +Next, let's add the gizmos you'll need for your new AOI form. Add the following code to your `get_context` method in `controllers.py`: + +.. code-block:: python + :emphasize-lines: 19-23, 25-27, 29-34, 38-39 + + def get_context(self, request, context, *args, **kwargs): + context = super().get_context(request, context, *args, **kwargs) + grid_type = SelectInput( + name="grid_type", + display_text="Select GRID Layer", + options=[ + ("Point Cloud", "pointcloud"), + ("Raster", "raster"), + ], + initial="pointcloud", + ) + + update_grid_button = Button( + name="update_grid", + display_text="Update GRID Layer", + submit=True, + ) + + aoi_name = TextInput( + name="aoi_name", + display_text="AOI Name", + placeholder="Enter AOI Name", + ) + + aoi_proxy_endpoint = App.get_secure_map_service( + App.GRID_SECURE_AOI_MAP_SERVICE_NAME, as_endpoint=True + ) + + create_aoi_button = Button( + name="create_aoi", + display_text="Create AOI", + submit=False, + attributes= {"data-proxy-url": aoi_proxy_endpoint, "id": "create-aoi-button"} + ) + + context["grid_type"] = grid_type + context["update_grid_button"] = update_grid_button + context["aoi_name"] = aoi_name + context["create_aoi_button"] = create_aoi_button + + return context + +Now you need to add the new gizmos to a form in `home.html`. You'll also be making a slight update to the form you added in step 7 to differentiate it from the new AOI form in your app's requests. + +.. code-block:: html+django + :emphasize-lines: 5, 10-15 + {% block custom_map_tab_panels %} +
+
+ {% csrf_token %} + + {% gizmo grid_type %} + {% gizmo update_grid_button %} +
+
+
+ {% csrf_token %} + + {% gizmo aoi_name %} + {% gizmo create_aoi_button %} +
+
+
+ {% endblock %} + +Next, we need to add some custom JavaScript to handle the AOI form submission and send the request + +Open the `public/js` folder and create a new file named `aoi.js` and add the following code: + +.. code-block:: javascript + + $(document).ready(function() { + $("#create-aoi-button").click(function() { + var aoi_name = $("#aoi_name").val(); + var geometry = JSON.parse($("#map_view_geometry").val()).geometries[0].wkt; + + var proxyUrl = $(this).data("proxy-url"); + $.ajax({ + url: proxyUrl, + type: "POST", + data: { + name: aoi_name, + geom: geometry + }, + headers: { + "X-CSRFToken": $("input[name='csrfmiddlewaretoken']").val() + }, + success: function(response) { + console.log("AOI created successfully:", response); + }, + error: function(xhr, status, error) { + console.error("Error creating AOI:", error); + } + }) + }) + }); + +Now just include new JavaScript file in your `home.html` file by adding this: + +.. code-block:: html+django + + {% block scripts %} + {{ block.super }} + + {% endblock %} + +That's it! Now just do a refresh on your page and you should be able to draw an AOI on the map, enter a name for it, and click the "Create AOI" button to submit it to the GRiD service. You can check your GRiD account to see if the new AOI was created successfully, or just refresh the page and the AOI should be there with the other already existing AOIs. + +That's it! You've now successfully created a Tethys app that uses Secure Map Services to display data from GEGD and GRiD, and allows users to create new AOIs on the map using the GRiD service. +For more information + +12. Solution +============ + +.. note:: + + **TODO:** Add the solution repository and clone instructions once the tutorial app repo exists, + matching the pattern used by the other tutorials: + + .. parsed-literal:: + + git clone https://github.com/tethysplatform/tethysapp-secure_map_services_tutorial + cd tethysapp-secure_map_services_tutorial + git checkout -b secure-map-services-solution secure-map-services-solution-|version| From a2c341a31a75e1faf23ca9996eb7b74bf8980010 Mon Sep 17 00:00:00 2001 From: Jacob Johnson Date: Wed, 12 Aug 2026 16:40:42 -0600 Subject: [PATCH 32/50] Documentation --- docs/tutorials/secure_map_services.rst | 108 +++++++++---------------- 1 file changed, 38 insertions(+), 70 deletions(-) diff --git a/docs/tutorials/secure_map_services.rst b/docs/tutorials/secure_map_services.rst index d380f1a5fe..9857d9488d 100644 --- a/docs/tutorials/secure_map_services.rst +++ b/docs/tutorials/secure_map_services.rst @@ -6,34 +6,6 @@ Secure Map Services **Last Updated:** August 2026 -.. note:: - - **AUTHOR NOTES — REMOVE BEFORE PUBLISHING** - - This draft was written from the ``SecureMapService`` implementation on the ``secure-map-service`` - branch and from the working example app in - :file:`19.b.1/testing_app/tethysapp-test_app`. Items that could not be verified from the source - are called out in ``TODO`` / ``VERIFY`` notes throughout the document. Search for "TODO" and - "VERIFY" to find them all. Outstanding items: - - * **No starting-point / solution repository exists yet.** Every other tutorial clones a - ``tethysapp-*_tutorial`` repo and checks out a per-section solution branch. This tutorial - currently just tells the reader to scaffold an app. Decide on a repo name (e.g. - ``tethysapp-secure_map_services_tutorial``) and add the ``git clone`` / ``git checkout`` - blocks that the other tutorials use. - * **No screenshots.** There is no :file:`docs/tutorials/secure_map_services/resources/` - directory. Admin form screenshots and a finished-app screenshot should be added, along with - the ``.. figure::`` directives (currently omitted so the docs build does not break on - missing images). - * **No SDK reference docs.** ``SecureMapServiceSetting`` is not documented in - :file:`docs/tethys_sdk/app_settings.rst`, and ``SecureMapService`` is not documented in - :file:`docs/tethys_portal/admin_pages/tethys_services.rst`. This tutorial links to those - pages as though the sections exist. Either add those sections or remove the links. - * The public example service used throughout (GEGD / GRiD, ``https://grid.nga.mil/grid``) - requires credentials that most readers will not have. Consider swapping in a service readers - can actually sign up for, or clearly framing these as illustrative. - - This tutorial demonstrates how to use **Secure Map Services** in a Tethys app. A Secure Map Service lets app developers store the connection information and credentials for map services that require authentication — an API key or an OAuth2 access token — and then lets an app consume that @@ -47,6 +19,8 @@ The following topics are covered: * Proxying requests so credentials never reach the browser * Changing service parameters at runtime * Requiring users to link an OAuth account before being able to access the app. +* Utilizing a Secure Map Service as a response to retrieve data from a service and format it for use in your app. +* Utilizing a Secure Map Service as an endpoint to make requests to a service from your app using JavaScript. 0. Prerequisites ================ @@ -56,7 +30,7 @@ For this tutorial, we'll be utilizing two services that require authentication t * **GEGD** - a WMS imagery service authenticated with an API key. * **GRiD** - a REST API authenticated with an OAuth2 access token. -Before beginning this tutorial, make sure you have created an account with each service and have retreived an API key from the GEGD page. +Before beginning this tutorial, make sure you have created an account with each service and have retrieved an API key from the GEGD page. 1. Scaffold a New App ===================== @@ -68,7 +42,7 @@ To generate a new app using the scaffold, open a terminal, :ref:`activate_enviro tethys scaffold secure_map_app You will be prompted to enter metadata about your app such as, proper name, version, author, and description. All of these metadata are optional. You can accept the default value that is shown in the square brackets by pressing enter. - + You'll then need to install your app by running these commands: .. code-block:: bash @@ -96,7 +70,7 @@ If you've already generated a portal_config file, you may still need to generate 3. Add a MapLayout ================== -We'll be using a MapLayout for this application, so we'll begin by adding a MapLayout controller to your app. Begin by opening your `controllers.py` file and replacing the contents with the following code: +We'll be using a MapLayout for this application, so we'll begin by adding a MapLayout controller to your app. Begin by opening your ``controllers.py`` file and replacing the contents with the following code: .. code-block:: python @@ -111,7 +85,7 @@ We'll be using a MapLayout for this application, so we'll begin by adding a MapL template_name = f'{App.package}/home.html' map_title = 'Secure Map Services Tutorial' -Next open the `home.html` in your `templates/secure_map_services_tutorial` directory and replace the contents with the following code: +Next open the ``home.html`` in your ``templates/secure_map_services_tutorial`` directory and replace the contents with the following code: .. code-block:: html+django @@ -120,15 +94,14 @@ Next open the `home.html` in your `templates/secure_map_services_tutorial` direc Now go ahead and open your app at localhost:8000 and you should see a fully interactive map like this one: -########################### -PUT A SCREENSHOT IMAGE HERE -########################### +.. figure:: ../images/tutorial/secure_map_services/secure-map-service-initial-map.png + :width: 650px 4. Add a Basemap ================ Now, you'll be setting up your first SecureMapService that you'll be using as a basemap. You'll want to make sure you have created your GEGD account and have your API key ready. -First, open your `app.py` file and first add the following import to the top of your file: +First, open your ``app.py`` file and first add the following import to the top of your file: .. code-block:: python @@ -195,7 +168,7 @@ Use the following configurations for your new Secure Map Service: Then save your new Secure Map Service and assign it to the GEGD Secure Map Service setting, then save your app settings. -Now we'll be adding the GEGD service as a basemap to your MapLayout. Open your `controllers.py` and add the following to your MapLayout class: +Now we'll be adding the GEGD service as a basemap to your MapLayout. Open your ``controllers.py`` and add the following to your MapLayout class: .. code-block:: python :emphasize-lines: 6-11 @@ -233,6 +206,7 @@ Then configure your portal to require users to link their GRiD account before be tethys settings --set OAUTH_REQUIREMENTS.secure_map_app grid The last step required to configure your application to work with GRiD is to register your application with GRiD. You'll need to register your application with GRiD to get a client ID and client secret. You can do this by going to the GRiD developer portal and creating a new application. Use the following settings: + - Go to https://grid.nga.mil/grid/api/application/list - Click on "Create new application" - Fill out the form with the following settings: @@ -256,7 +230,7 @@ Now when you try to open your app you will be redirected to your account setting Next, you'll be setting up your second SecureMapService that you'll be using as a map layer. You'll want to make sure you have created your GRiD account since we'll be using that service for this layer. -Begin by adding a new SecureMapServiceSetting to your app class in `app.py`: +Begin by adding a new SecureMapServiceSetting to your app class in ``app.py``: .. code-block:: python :emphasize-lines: 17, 30-34 @@ -324,7 +298,7 @@ Use the following configurations for your new Secure Map Service: Then save your new Secure Map Service and assign it to the GRiD Secure Map Service setting, then save your app settings. -Our next step will be to add a new map layer to our MapLayout using the GRiD service. Open your `controllers.py` and add the following to your MapLayout class: +Our next step will be to add a new map layer to our MapLayout using the GRiD service. Open your ``controllers.py`` and add the following to your MapLayout class: .. code-block:: python :emphasize-lines: 5-19 @@ -349,15 +323,15 @@ Our next step will be to add a new map layer to our MapLayout using the GRiD ser ] return layer_groups - Now just go ahead and refresh your app and you should see the GRiD layer on your map. You can toggle the visibility of the layer using the layers control in the top right corner of the map. +Now just go ahead and refresh your app and you should see the GRiD layer on your map. You can toggle the visibility of the layer using the layers control in the top right corner of the map. 7. Update Service Parameters ============================ -Now that you have data from GRiD displaying on your map in the form of a layer, you may want to change the parameters of the service to display different data. You can do this by going into the service settings and manually updating the parameters field. But you can also do this in your app dynamically using the `update_secure_map_service_setting_params()` method in your app code. +Now that you have data from GRiD displaying on your map in the form of a layer, you may want to change the parameters of the service to display different data. You can do this by going into the service settings and manually updating the parameters field. But you can also do this in your app dynamically using the ``update_secure_map_service_setting_params()`` method in your app code. -In order to demonstrate how this can be done dynamically in your app, we'll add a form to the app that will allow the user to select which GRiD layer they want to display on the map. We'll then use the `update_secure_map_service_setting_params()` method to update the parameters of the GRiD service based on the user's selection. +In order to demonstrate how this can be done dynamically in your app, we'll add a form to the app that will allow the user to select which GRiD layer they want to display on the map. We'll then use the ``update_secure_map_service_setting_params()`` method to update the parameters of the GRiD service based on the user's selection. -We'll begin by adding a custom map tab to your MapLayout that will contain this form. Open `home.html` and add the following code: +We'll begin by adding a custom map tab to your MapLayout that will contain this form. Open ``home.html`` and add the following code: .. code-block:: html+django @@ -378,7 +352,7 @@ We'll begin by adding a custom map tab to your MapLayout that will contain this {% endblock %} -Now we'll need to add the gizmos for the form to your MapLayout class in `controllers.py`. Add the following code: +Now we'll need to add the gizmos for the form to your MapLayout class in ``controllers.py``. Add the following code: .. code-block:: python @@ -411,7 +385,7 @@ Now we'll need to add the gizmos for the form to your MapLayout class in `contro return context -Now if you refresh your app, you should see a new tab on the left that you can switch to with a select input and a button. Right now if you click the "Update GRID Layer" button, nothing will happen. We'll need to add a `post()` method to your MapLayout class to handle the form submission and update the GRiD service parameters. +Now if you refresh your app, you should see a new tab on the left that you can switch to with a select input and a button. Right now if you click the "Update GRID Layer" button, nothing will happen. We'll need to add a ``post()`` method to your MapLayout class to handle the form submission and update the GRiD service parameters. To add that functionality, first add the following imports to the top of `controllers.py`: @@ -420,7 +394,7 @@ To add that functionality, first add the following imports to the top of `contro from django.http import HttpResponse from django.shortcuts import redirect -Then add the following `post()` method to your MapLayout class: +Then add the following ``post()`` method to your MapLayout class: .. code-block:: python @@ -443,9 +417,9 @@ Now go ahead and try selecting a different GRiD layer from the select input and 8. Using a Secure Map Service as a Response =========================================== -You can access a SecureMapService as a response in order to work directly with the data returned from the service in your python. We'll be using this to retreive spatial data from the GRiD API and format it into GeoJSON to display AOIs on the map. +You can access a SecureMapService as a response in order to work directly with the data returned from the service in your Python code. We'll be using this to retrieve spatial data from the GRiD API and format it into GeoJSON to display AOIs on the map. -First, let's add a new SecureMapServiceSetting to your app class in `app.py` for the GRiD AOI service. This service will be used to both dsiplay existing AOIs on the map, and to submit new AOIs to the GRiD service. Add the following code to your app class: +First, let's add a new SecureMapServiceSetting to your app class in ``app.py`` for the GRiD AOI service. This service will be used to both dsiplay existing AOIs on the map, and to submit new AOIs to the GRiD service. Add the following code to your app class: .. code-block:: python :emphasize-lines: 18, 36-40 @@ -516,9 +490,9 @@ Use these configurations for the new Secure Map Service: Save your new Secure Map Service and assign it to the GRiD AOI Secure Map Service setting, then save your app settings. -Next, add a new layer to your MapLayout class in `controllers.py` that will display the existing AOIs on the map. +Next, add a new layer to your MapLayout class in ``controllers.py`` that will display the existing AOIs on the map. -For that you'll need to first add the following packages to the dependencies of your application. Open your `install.yml` and edit the requirements block like so: +For that you'll need to first add the following packages to the dependencies of your application. Open your ``install.yml`` and edit the requirements block like so: .. code-block:: yaml :emphasize-lines: 6, 8 @@ -532,7 +506,7 @@ For that you'll need to first add the following packages to the dependencies of packages: - shapely -Next, add the following imports to `controllers.py`: +Next, add the following imports to ``controllers.py``: .. code-block:: python :emphasize-lines: 3, 6-7 @@ -547,7 +521,7 @@ Next, add the following imports to `controllers.py`: import json from .app import App -Now add the following helper function to `controllers.py` This function will help format the AOI data returned from the GRiD service into a GeoJSON format that can be used to create a new MVLayer: +Now add the following helper function to ``controllers.py``. This function will help format the AOI data returned from the GRiD service into a GeoJSON format that can be used to create a new MVLayer: .. code-block:: python @@ -584,7 +558,7 @@ Now add the following helper function to `controllers.py` This function will hel "features": features, } -Now update your `compose_layers()` method in your MapLayout class to create a new MVLayer for the AOIs: +Now update your ``compose_layers()`` method in your MapLayout class to create a new MVLayer for the AOIs: .. code-block:: python :emphasize-lines: 8-12, 14, 16-34, 40 @@ -642,7 +616,7 @@ The last feature we'll be adding to our app is the ability to use a SecureMapSer Now let's look at adding a new AOI to the GRiD service using the GRiD AOI Secure Map Service. We'll be adding a new form to the custom map tab that will allow the user to draw a new AOI on the map and submit it to the GRiD service. -First, we'll need to make some updates to your `controllers.py` file. +First, we'll need to make some updates to your ``controllers.py`` file. To start, update your imports: @@ -686,7 +660,7 @@ Begin by adding the MVDraw gizmo to your class: ) -Next, let's add the gizmos you'll need for your new AOI form. Add the following code to your `get_context` method in `controllers.py`: +Next, let's add the gizmos you'll need for your new AOI form. Add the following code to your ``get_context`` method in ``controllers.py``: .. code-block:: python :emphasize-lines: 19-23, 25-27, 29-34, 38-39 @@ -733,10 +707,11 @@ Next, let's add the gizmos you'll need for your new AOI form. Add the following return context -Now you need to add the new gizmos to a form in `home.html`. You'll also be making a slight update to the form you added in step 7 to differentiate it from the new AOI form in your app's requests. +Now you need to add the new gizmos to a form in ``home.html``. You'll also be making a slight update to the form you added in step 7 to differentiate it from the new AOI form in your app's requests. .. code-block:: html+django :emphasize-lines: 5, 10-15 + {% block custom_map_tab_panels %}
@@ -756,9 +731,9 @@ Now you need to add the new gizmos to a form in `home.html`. You'll also be maki
{% endblock %} -Next, we need to add some custom JavaScript to handle the AOI form submission and send the request +Next, we need to add some custom JavaScript to handle the AOI form submission and send the request. -Open the `public/js` folder and create a new file named `aoi.js` and add the following code: +Open the ``public/js`` folder and create a new file named ``aoi.js`` and add the following code: .. code-block:: javascript @@ -788,7 +763,7 @@ Open the `public/js` folder and create a new file named `aoi.js` and add the fol }) }); -Now just include new JavaScript file in your `home.html` file by adding this: +Now just include the new JavaScript file in your ``home.html`` file by adding this code block: .. code-block:: html+django @@ -800,18 +775,11 @@ Now just include new JavaScript file in your `home.html` file by adding this: That's it! Now just do a refresh on your page and you should be able to draw an AOI on the map, enter a name for it, and click the "Create AOI" button to submit it to the GRiD service. You can check your GRiD account to see if the new AOI was created successfully, or just refresh the page and the AOI should be there with the other already existing AOIs. That's it! You've now successfully created a Tethys app that uses Secure Map Services to display data from GEGD and GRiD, and allows users to create new AOIs on the map using the GRiD service. -For more information -12. Solution +10. Solution ============ +This concludes the tutorial. You can view the solution code for this tutorial in the `tethysapp-secure_map_tutorial `__ repository on GitHub. You can clone the repository using the following command: -.. note:: - - **TODO:** Add the solution repository and clone instructions once the tutorial app repo exists, - matching the pattern used by the other tutorials: - - .. parsed-literal:: +.. code-block:: bash - git clone https://github.com/tethysplatform/tethysapp-secure_map_services_tutorial - cd tethysapp-secure_map_services_tutorial - git checkout -b secure-map-services-solution secure-map-services-solution-|version| + git clone https://github.com/tethysplatform/tethysapp-secure_map_tutorial From c3e462de8d0967773225cb6857cf9fbedc31bcb9 Mon Sep 17 00:00:00 2001 From: Jacob Johnson Date: Wed, 12 Aug 2026 16:55:40 -0600 Subject: [PATCH 33/50] Updated app name throughout tutorial --- docs/tutorials/secure_map_services.rst | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/docs/tutorials/secure_map_services.rst b/docs/tutorials/secure_map_services.rst index 9857d9488d..ffa57e4e6b 100644 --- a/docs/tutorials/secure_map_services.rst +++ b/docs/tutorials/secure_map_services.rst @@ -39,7 +39,7 @@ To generate a new app using the scaffold, open a terminal, :ref:`activate_enviro .. code-block:: bash - tethys scaffold secure_map_app + tethys scaffold secure_map_tutorial You will be prompted to enter metadata about your app such as, proper name, version, author, and description. All of these metadata are optional. You can accept the default value that is shown in the square brackets by pressing enter. @@ -47,7 +47,7 @@ You'll then need to install your app by running these commands: .. code-block:: bash - cd tethysapp-secure_map_app + cd tethysapp-secure_map_tutorial tethys install -d @@ -119,7 +119,7 @@ Then add this to your main App class: """ name = 'Secure Map App' description = '' - package = 'secure_map_app' # WARNING: Do not change this value + package = 'secure_map_tutorial' # WARNING: Do not change this value index = 'home' icon = f'{package}/images/icon.gif' root_url = 'secure-map-app' @@ -203,7 +203,7 @@ Then configure your portal to require users to link their GRiD account before be .. code-block:: bash - tethys settings --set OAUTH_REQUIREMENTS.secure_map_app grid + tethys settings --set OAUTH_REQUIREMENTS.secure_map_tutorial grid The last step required to configure your application to work with GRiD is to register your application with GRiD. You'll need to register your application with GRiD to get a client ID and client secret. You can do this by going to the GRiD developer portal and creating a new application. Use the following settings: @@ -241,7 +241,7 @@ Begin by adding a new SecureMapServiceSetting to your app class in ``app.py``: """ name = 'Secure Map App' description = '' - package = 'secure_map_app' # WARNING: Do not change this value + package = 'secure_map_tutorial' # WARNING: Do not change this value index = 'home' icon = f'{package}/images/icon.gif' root_url = 'secure-map-app' @@ -430,7 +430,7 @@ First, let's add a new SecureMapServiceSetting to your app class in ``app.py`` f """ name = 'Secure Map App' description = '' - package = 'secure_map_app' # WARNING: Do not change this value + package = 'secure_map_tutorial' # WARNING: Do not change this value index = 'home' icon = f'{package}/images/icon.gif' root_url = 'secure-map-app' @@ -769,7 +769,7 @@ Now just include the new JavaScript file in your ``home.html`` file by adding th {% block scripts %} {{ block.super }} - + {% endblock %} That's it! Now just do a refresh on your page and you should be able to draw an AOI on the map, enter a name for it, and click the "Create AOI" button to submit it to the GRiD service. You can check your GRiD account to see if the new AOI was created successfully, or just refresh the page and the AOI should be there with the other already existing AOIs. From 8106510a46e66ffc022a29dfa49d8c3bfcbfc5a3 Mon Sep 17 00:00:00 2001 From: Jacob Johnson Date: Thu, 13 Aug 2026 10:16:21 -0600 Subject: [PATCH 34/50] Made some small adjustments to SecureMapService model fields --- tethys_portal/middleware.py | 1 + ..._securemapservice_legend_title_and_more.py | 32 +++++++++++++++++++ 2 files changed, 33 insertions(+) create mode 100644 tethys_services/migrations/0005_alter_securemapservice_legend_title_and_more.py diff --git a/tethys_portal/middleware.py b/tethys_portal/middleware.py index 9aed72bfee..f989c87336 100644 --- a/tethys_portal/middleware.py +++ b/tethys_portal/middleware.py @@ -176,6 +176,7 @@ def __call__(self, request): required_provider = self.requirements.get(app_name) if not required_provider: return self.get_response(request) + # If the user is trying to access an app and there is a required OAuth provider for that app, check if the user is authenticated. if not request.user.is_authenticated: next_param = urlencode({"next": request.get_full_path()}) diff --git a/tethys_services/migrations/0005_alter_securemapservice_legend_title_and_more.py b/tethys_services/migrations/0005_alter_securemapservice_legend_title_and_more.py new file mode 100644 index 0000000000..e72f63ddd4 --- /dev/null +++ b/tethys_services/migrations/0005_alter_securemapservice_legend_title_and_more.py @@ -0,0 +1,32 @@ +# Generated by Django 5.2.15 on 2026-08-13 16:15 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("tethys_services", "0004_alter_securemapservice_service_type"), + ] + + operations = [ + migrations.AlterField( + model_name="securemapservice", + name="legend_title", + field=models.CharField(blank=True, max_length=100), + ), + migrations.AlterField( + model_name="securemapservice", + name="service_type", + field=models.CharField( + choices=[ + ("ImageWMS", "WMS"), + ("GML", "GML"), + ("GeoJSON", "GeoJSON"), + ("REST", "REST/JSON API"), + ], + default="ImageWMS", + max_length=50, + ), + ), + ] From f74ecc988c51271b888eab8ccc979766dabfd9fc Mon Sep 17 00:00:00 2001 From: Jacob Johnson Date: Thu, 13 Aug 2026 10:19:01 -0600 Subject: [PATCH 35/50] Black fixes --- tethys_portal/middleware.py | 4 ++-- tethys_services/models.py | 9 +++++++-- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/tethys_portal/middleware.py b/tethys_portal/middleware.py index f989c87336..a8cbfcd02f 100644 --- a/tethys_portal/middleware.py +++ b/tethys_portal/middleware.py @@ -176,13 +176,13 @@ def __call__(self, request): required_provider = self.requirements.get(app_name) if not required_provider: return self.get_response(request) - + # If the user is trying to access an app and there is a required OAuth provider for that app, check if the user is authenticated. if not request.user.is_authenticated: next_param = urlencode({"next": request.get_full_path()}) login_url = reverse("accounts:login") return redirect(f"{login_url}?{next_param}") - + if request.user.social_auth.filter(provider=required_provider).exists(): return self.get_response(request) diff --git a/tethys_services/models.py b/tethys_services/models.py index 08bcd11a76..77b95b88f8 100644 --- a/tethys_services/models.py +++ b/tethys_services/models.py @@ -412,7 +412,7 @@ class SecureMapService(models.Model): """ name = models.CharField(max_length=30, unique=True) - legend_title = models.CharField(max_length=100, unique=True) + legend_title = models.CharField(max_length=100, blank=True) endpoint = models.CharField(max_length=1024, validators=[validate_url]) authentication_method = models.CharField( max_length=100, blank=True, choices=[("api_key", "API Key"), ("oauth", "OAuth")] @@ -421,7 +421,12 @@ class SecureMapService(models.Model): oauth_provider = models.CharField(max_length=100, blank=True) service_type = models.CharField( max_length=50, - choices=[("ImageWMS", "WMS"), ("GML", "GML")], + choices=[ + ("ImageWMS", "WMS"), + ("GML", "GML"), + ("GeoJSON", "GeoJSON"), + ("REST", "REST/JSON API"), + ], default="ImageWMS", ) params = models.JSONField(blank=True, null=True, default=dict) From 4fa6cd3612ebc9bc8b107ac9aad6feb820678219 Mon Sep 17 00:00:00 2001 From: Jacob Johnson Date: Thu, 13 Aug 2026 12:48:22 -0600 Subject: [PATCH 36/50] * Added docstrings * Moved update params logic from setting to service * Renamed app facing update_params helper method to reflect moved update params logic --- .../test_base/test_app_base.py | 8 ++--- .../test_models/test_SecureMapService.py | 29 ++++++++++++++++ tethys_apps/base/app_base.py | 4 +-- tethys_apps/models.py | 6 +--- tethys_services/models.py | 33 +++++++++++++++++++ 5 files changed, 69 insertions(+), 11 deletions(-) diff --git a/tests/unit_tests/test_tethys_apps/test_base/test_app_base.py b/tests/unit_tests/test_tethys_apps/test_base/test_app_base.py index 1f70f647b5..58e1c66767 100644 --- a/tests/unit_tests/test_tethys_apps/test_base/test_app_base.py +++ b/tests/unit_tests/test_tethys_apps/test_base/test_app_base.py @@ -1478,7 +1478,7 @@ def test__resolve_secure_map_service_object_does_not_exist(self, mock_ta): @mock.patch("tethys_apps.models.SecureMapServiceSetting.update_params") @mock.patch("tethys_apps.models.TethysApp") - def test_update_secure_map_service_setting_params( + def test_update_secure_map_service_params( self, mock_ta, mock_update_params ): mock_setting = ( @@ -1486,7 +1486,7 @@ def test_update_secure_map_service_setting_params( ) fake_params = {"param1": "value1"} - TethysAppChild.update_secure_map_service_setting_params( + TethysAppChild.update_secure_map_service_params( name=self.fake_name, params=fake_params ) mock_ta.objects.get.assert_called_with(package=TethysAppChild.package) @@ -1496,7 +1496,7 @@ def test_update_secure_map_service_setting_params( mock_setting.update_params.assert_called_with(fake_params) @mock.patch("tethys_apps.models.TethysApp") - def test_update_secure_map_service_setting_params_object_does_not_exist( + def test_update_secure_map_service_params_object_does_not_exist( self, mock_ta ): mock_get = mock_ta.objects.get().secure_map_service_settings.get @@ -1504,7 +1504,7 @@ def test_update_secure_map_service_setting_params_object_does_not_exist( self.assertRaises( TethysAppSettingDoesNotExist, - TethysAppChild.update_secure_map_service_setting_params, + TethysAppChild.update_secure_map_service_params, name=self.fake_name, params={"param1": "value1"}, ) diff --git a/tests/unit_tests/test_tethys_services/test_models/test_SecureMapService.py b/tests/unit_tests/test_tethys_services/test_models/test_SecureMapService.py index f8ada47c19..83b39d13b5 100644 --- a/tests/unit_tests/test_tethys_services/test_models/test_SecureMapService.py +++ b/tests/unit_tests/test_tethys_services/test_models/test_SecureMapService.py @@ -167,3 +167,32 @@ def test_get_resolved_params_with_multiple_params(self): "test_api_key": "api_key_12345", }, ) + + def test_update_params(self): + secure_map_service = SecureMapService( + name="test_secure_map_service", + endpoint="http://example.com", + params={"param1": "value1", "param2": "value2"}, + ) + secure_map_service.save() + + secure_map_service.update_params({"param2": "new_value", "param3": "value3"}) + + updated_service = SecureMapService.objects.get(pk=secure_map_service.pk) + self.assertEqual( + updated_service.params, + {"param1": "value1", "param2": "new_value", "param3": "value3"}, + ) + + def test_update_params_no_existing_params(self): + secure_map_service = SecureMapService( + name="test_secure_map_service", + endpoint="http://example.com", + params=None, + ) + secure_map_service.save() + + secure_map_service.update_params({"param1": "value1"}) + + updated_service = SecureMapService.objects.get(pk=secure_map_service.pk) + self.assertEqual(updated_service.params, {"param1": "value1"}) diff --git a/tethys_apps/base/app_base.py b/tethys_apps/base/app_base.py index a5a7055dd1..354f5875e8 100644 --- a/tethys_apps/base/app_base.py +++ b/tethys_apps/base/app_base.py @@ -1987,13 +1987,13 @@ def _resolve_secure_map_service( ) @classmethod - def update_secure_map_service_setting_params(cls, name, params): + def update_secure_map_service_params(cls, name, params): """ Update the params for a given SecureMapServiceSetting. Args: name(str): name of the SecureMapServiceSetting as defined in the app.py. - params(dict): dictionary of params to update for the setting. + params(dict): dictionary of params to update for the service. """ from tethys_apps.models import TethysApp diff --git a/tethys_apps/models.py b/tethys_apps/models.py index a128903a36..90f8a0d362 100644 --- a/tethys_apps/models.py +++ b/tethys_apps/models.py @@ -1296,11 +1296,7 @@ def update_params(self, new_params): f'"{self.name}" for app "{self.tethys_app.package}": ' f"no SecureMapService assigned." ) - service = self.secure_map_service - params = service.params or {} - params.update(new_params) - service.params = params - service.save() + self.secure_map_service.update_params(new_params) class SchedulerSetting(TethysAppSetting): diff --git a/tethys_services/models.py b/tethys_services/models.py index 77b95b88f8..7bc649e43b 100644 --- a/tethys_services/models.py +++ b/tethys_services/models.py @@ -441,11 +441,23 @@ def __str__(self): @classmethod def get_authentication_method_options(cls): + """ + Get the available authentication method options for the SecureMapService model. + This method is used for populating the choices in the admin form. + """ return [ value for value, _ in cls._meta.get_field("authentication_method").choices ] def get_oauth_token(self, user): + """ + Retrieve the OAuth token for the given user. + Args: + user (User): The user for whom to retrieve the OAuth token. + + Returns: + str: The OAuth token for the user. + """ if self.authentication_method != "oauth": raise ValueError( "Authentication method must be 'oauth' to retrieve an OAuth token." @@ -467,6 +479,12 @@ def get_oauth_token(self, user): return access_token def get_resolved_params(self): + """ + Resolve template variables in the service parameters using the model's attributes. + + Returns: + dict: A dictionary of resolved parameters. + """ if not self.params: return {} @@ -480,3 +498,18 @@ def get_resolved_params(self): value = Template(value).safe_substitute(safe_attribute_names) resolved_params[key] = value return resolved_params + + def update_params(self, new_params): + """ + Merge the given parameters into the parameters of the service and save. + + Note that a SecureMapService may be shared by multiple settings and apps, + so updating its parameters affects every app that uses it. + + Args: + new_params (dict): The parameters to merge into the service parameters. + """ + params = self.params or {} + params.update(new_params) + self.params = params + self.save() From 3a4bb6747e8006554553b2203d44558ca76bcc56 Mon Sep 17 00:00:00 2001 From: Jacob Johnson Date: Thu, 13 Aug 2026 13:37:55 -0600 Subject: [PATCH 37/50] * Black fixes * Updated docstrings * Added Secure Map Service API documentation and linked to it in the secure map service tutorial * Fixed typos and code in secure map service tutorial --- docs/tethys_sdk/tethys_services.rst | 5 +- .../tethys_services/secure_map_services.rst | 307 ++++++++++++++++++ docs/tutorials/secure_map_services.rst | 59 ++-- .../test_base/test_app_base.py | 8 +- tethys_apps/base/app_base.py | 5 +- tethys_services/models.py | 4 +- 6 files changed, 348 insertions(+), 40 deletions(-) create mode 100644 docs/tethys_sdk/tethys_services/secure_map_services.rst diff --git a/docs/tethys_sdk/tethys_services.rst b/docs/tethys_sdk/tethys_services.rst index ec8f64b05c..d249ff1601 100644 --- a/docs/tethys_sdk/tethys_services.rst +++ b/docs/tethys_sdk/tethys_services.rst @@ -4,9 +4,9 @@ Tethys Services APIs ******************** -**Last Updated:** May 2017 +**Last Updated:** August 2026 -Tethys Services consists of several APIs that can be used to work with external data and processing services. Use the Persistent Store Services APIs to connect to SQL databases. Use the Dataset Services to consume file dataset services like CKAN or HydroShare. The Spatial Dataset Services can be used to connect to map servers like GeoServer and the Web Processing Services can be used to consume processing services such as those hosted by 52 North installations.1 +Tethys Services consists of several APIs that can be used to work with external data and processing services. Use the Persistent Store Services APIs to connect to SQL databases. Use the Dataset Services to consume file dataset services like CKAN or HydroShare. The Spatial Dataset Services can be used to connect to map servers like GeoServer and the Web Processing Services can be used to consume processing services such as those hosted by 52 North installations. Use the Secure Map Services to securely consume map services that require an API key or an OAuth2 access token. .. toctree:: :maxdepth: 1 @@ -15,4 +15,5 @@ Tethys Services consists of several APIs that can be used to work with external tethys_services/spatial_persistent_store tethys_services/dataset_services tethys_services/spatial_dataset_services + tethys_services/secure_map_services tethys_services/web_processing_services \ No newline at end of file diff --git a/docs/tethys_sdk/tethys_services/secure_map_services.rst b/docs/tethys_sdk/tethys_services/secure_map_services.rst new file mode 100644 index 0000000000..3a290c011d --- /dev/null +++ b/docs/tethys_sdk/tethys_services/secure_map_services.rst @@ -0,0 +1,307 @@ +.. _secure_map_services_api: + +************************ +Secure Map Services API +************************ + +**Last Updated:** August 2026 + +Secure map services are map services (e.g.: OGC WMS endpoints) that require credentials to access. The Secure Map Services API allows a developer to register the endpoint and credentials of such a service once, and then allows apps to consume that service without ever storing or handling the credentials themselves. + +In addition, requests can optionally be routed through a proxy endpoint provided by Tethys Portal so that credentials are never sent to the browser. See :ref:`secure_map_services_proxy` for more details. + +.. tip:: + + For a step-by-step tutorial on using secure map services in your app, see the :ref:`Secure Map Services Tutorial `. + +Two authentication methods are supported: + +* **API Key**: a key is stored (encrypted) with the service and added to each request as a query parameter. +* **OAuth**: an OAuth2 access token is retrieved from the requesting user's linked social auth account and added to each request as a ``Bearer`` token. + +.. important:: + + API keys assigned to a ``SecureMapService`` are encrypted before they are stored in the database. This requires a ``SALT_KEY`` to be set in your :file:`portal_config.yml`. If you have not already done so, generate one as follows: + + .. code-block:: bash + + tethys gen portal_config + tethys settings --generate-salt-key + +.. _secure_map_service_settings: + +Secure Map Service Settings +=========================== + +Using secure map services in your app is accomplished by adding the ``secure_map_service_settings()`` method to your :term:`app class`, which is located in your :term:`app configuration file` (:file:`app.py`). This method should return a list or tuple of ``SecureMapServiceSetting`` objects. For example: + +:: + + from tethys_sdk.app_settings import SecureMapServiceSetting + + class App(TethysAppBase): + """ + Tethys App Class for My First App. + """ + ... + def secure_map_service_settings(self): + """ + Example secure_map_service_settings method. + """ + secure_map_service_settings = ( + SecureMapServiceSetting( + name='primary_secure_map_service', + description='Secure map service for app to use.', + required=True, + ), + ) + + return secure_map_service_settings + +.. caution:: + + The ellipsis in the code block above indicates code that is not shown for brevity. **DO NOT COPY VERBATIM**. + +Unlike other Tethys Service settings, a ``SecureMapServiceSetting`` does not specify an engine. The type of service and how it is authenticated are properties of the ``SecureMapService`` that is assigned to the setting, not of the setting itself. + +.. _register_secure_map_service: + +Register a Secure Map Service +============================= + +Before a ``SecureMapService`` can be assigned to a setting, it must be registered in the Admin Interface of Tethys Portal: + +1. Access the Admin interface of Tethys Portal by clicking on the drop down menu next to your user name and selecting the "Site Admin" option. + +2. Scroll down to the **Tethys Services** section of the Admin Interface and select the link titled **Secure Map Services**. + +3. Click on the **Add Secure Map Service** button. + +4. Fill in the connection information for the service (see the table below). + +5. Press the **Save** button to save the new ``SecureMapService``. + +The following fields are available on a ``SecureMapService``: + +========================== ========================================================================================================================================================================== +Field Description +========================== ========================================================================================================================================================================== +**Name** Unique name used to identify the service. +**Endpoint** The URL of the map service. +**Legend Title** The title to use for the legend when the service is added to a map as a layer. +**Authentication Method** One of **API Key** or **OAuth**. The fields that apply to the other method are hidden in the admin form. +**API Key** The API key to use when the authentication method is **API Key**. The value is encrypted before it is stored in the database. +**OAuth Provider** The social auth backend to retrieve the access token from when the authentication method is **OAuth**. The options are populated from the authentication backends that are enabled for the portal (see :ref:`single_sign_on_config`). +**Service Type** The type of service: **WMS** (``ImageWMS``), **GML**, **GeoJSON**, or **REST/JSON API** (``REST``). For the map service types, the stored value is used as the ``source`` of the ``MVLayer`` that is created when the service is retrieved with ``as_layer=True``. Use **REST/JSON API** for services that are consumed with ``as_response=True`` or with ``as_endpoint=True`` rather than rendered as a map layer. +**Use Proxy for Requests** When checked, requests are routed through a Tethys Portal proxy endpoint so that credentials are never exposed to the browser. See :ref:`secure_map_services_proxy`. +**Parameters** A JSON object of additional query parameters to include with each request to the service. See :ref:`secure_map_services_params`. +========================== ========================================================================================================================================================================== + +.. tip:: + + You do not need to create a new ``SecureMapService`` for each ``SecureMapServiceSetting`` or each app. Apps and ``SecureMapServiceSettings`` can share ``SecureMapServices``. + +.. _assign_secure_map_service: + +Assign Secure Map Service +========================= + +The ``SecureMapServiceSetting`` can be thought of as a socket for a connection to a ``SecureMapService``. Before we can do anything with the ``SecureMapServiceSetting`` we need to "plug in" or assign a ``SecureMapService`` to the setting. Assigning a ``SecureMapService`` is done through the Admin Interface of Tethys Portal as follows: + +1. Navigate to App Settings Page + + a. Return to the Home page of the Admin Interface using the **Home** link in the breadcrumbs. + + b. Scroll to the **Tethys Apps** section of the Admin Interface and select the **Installed Apps** link. + + c. Select the link for your app from the list of installed apps. + +2. Assign ``SecureMapService`` to the appropriate ``SecureMapServiceSetting`` + + a. Scroll to the **Secure Map Service Settings** section and locate the ``SecureMapServiceSetting``. + + .. note:: + + If you don't see the ``SecureMapServiceSetting`` in the list, uninstall the app and reinstall it again. + + b. Assign the appropriate ``SecureMapService`` to your ``SecureMapServiceSetting`` using the drop down menu in the **Secure Map Service** column. + + c. Press the **Save** button at the bottom of the page to save your changes. + +.. note:: + + During development you will assign the ``SecureMapService`` setting yourself. However, when the app is installed in production, this step is performed by the portal administrator upon installing your app, which may or may not be yourself. + +Working with Secure Map Services +================================ + +After a secure map service has been assigned to a setting, use the ``get_secure_map_service()`` method of the app class to retrieve it. The value that is returned depends on which of the ``as_`` arguments is provided. + +Get the Service +--------------- + +Called with only a name, ``get_secure_map_service()`` returns the ``SecureMapService`` object that is assigned to the setting: + +.. code-block:: python + + from .app import App + + service = App.get_secure_map_service('primary_secure_map_service') + +Get an Endpoint +--------------- + +Pass ``as_endpoint=True`` to get the URL of the service with all of its parameters (and the API key, if applicable) applied: + +.. code-block:: python + + from .app import App + + endpoint = App.get_secure_map_service('primary_secure_map_service', as_endpoint=True) + +.. note:: + + A lazy string is returned when ``as_endpoint`` is ``True``. This ensures the endpoint reflects the current state of the service's settings every time it is used, and allows the endpoint to be referenced at import time (e.g.: as a ``MapLayout`` basemap) before the app URLs have been registered. + +Get a Map Layer +--------------- + +Pass ``as_layer=True`` to get an :ref:`MVLayer ` for the service that can be added to a ``MapView`` or ``MapLayout``. The ``source`` of the layer is the **Service Type** of the service and the ``legend_title`` is the **Legend Title** of the service: + +.. code-block:: python + + from .app import App + + layer = App.get_secure_map_service('primary_secure_map_service', as_layer=True) + +If the service uses OAuth authentication and is not proxied, the access token must be retrieved from the user making the request, so the ``request_user`` argument is required: + +.. code-block:: python + + layer = App.get_secure_map_service( + 'primary_secure_map_service', + as_layer=True, + request_user=request.user, + ) + +Get a Response +-------------- + +Pass ``as_response=True`` to perform the request server-side and get the resulting ``requests.Response`` object. This is useful for services that return data to be processed by the app rather than rendered as a map layer: + +.. code-block:: python + + from .app import App + + response = App.get_secure_map_service( + 'primary_secure_map_service', + as_response=True, + request_user=request.user, + ) + data = response.json() + +.. note:: + + As with ``as_layer``, ``request_user`` is required when the service uses OAuth authentication. An exception is raised if the request is not successful. + +.. _secure_map_services_params: + +Service Parameters +================== + +The **Parameters** field of a ``SecureMapService`` is a JSON object of query parameters that are added to every request made to the service. For example, the following parameters could be used for a WMS service: + +.. code-block:: json + + { + "service": "WMS", + "request": "GetMap", + "layers": "my:layer", + "format": "image/png", + "transparent": true + } + +String values may contain placeholders that reference fields of the ``SecureMapService``, using Python's ``string.Template`` syntax. This is most commonly used to place the API key in a parameter with a name other than ``api_key``: + +.. code-block:: json + + { + "connectId": "${api_key}" + } + +.. note:: + + If the API key is not referenced by any parameter, it is added automatically as a parameter named ``api_key``. + +Overriding Parameters Per Request +--------------------------------- + +Pass a ``param_overrides`` dictionary to ``get_secure_map_service()`` to add to or replace the parameters of the service for a single request. This does not modify the ``SecureMapService``: + +.. code-block:: python + + from .app import App + + response = App.get_secure_map_service( + 'primary_secure_map_service', + as_response=True, + param_overrides={'layers': 'my:other_layer'}, + request_user=request.user, + ) + +Updating Parameters Permanently +------------------------------- + +Use the ``update_secure_map_service_params()`` method of the app class to permanently update the parameters of the ``SecureMapService`` that is assigned to a setting. The given parameters are merged with the existing parameters and saved: + +.. code-block:: python + + from .app import App + + App.update_secure_map_service_params( + 'primary_secure_map_service', + {'layers': 'my:other_layer'}, + ) + +.. caution:: + + ``SecureMapServices`` can be shared by multiple settings and apps, so updating the parameters of a service affects every app that uses it. + +.. _secure_map_services_proxy: + +Proxying Requests +================= + +When the **Use Proxy for Requests** option is enabled on a ``SecureMapService``, the endpoint that is returned by ``get_secure_map_service()`` is not the endpoint of the map service, but rather a URL to a proxy view provided by Tethys Portal (``secure-map-proxy//``). The browser makes its requests against the proxy, which then adds the credentials and parameters of the service server-side and streams the response back. As a result, the API key or access token is never sent to the browser. + +The proxy view requires the user to be logged in, and for OAuth services it uses the access token of the logged in user making the request. + +.. tip:: + + Enable **Use Proxy for Requests** for any service whose credentials should not be visible to end users. Without it, the API key is included in the URL of the tile requests that the browser makes, where it can be read from the browser's developer tools. + +.. _secure_map_services_oauth: + +Requiring an OAuth Provider +=========================== + +Services that use OAuth authentication require the user to have linked the corresponding social auth account to their Tethys Portal account. To require users to link an account before they can access an app, add the app package and provider name to the ``OAUTH_REQUIREMENTS`` portal setting: + +.. code-block:: bash + + tethys settings --set OAUTH_REQUIREMENTS.my_first_app my_provider + +Users who access the app without having linked an account for the given provider are redirected to their user settings page with a message prompting them to link it. + +.. note:: + + The provider must also be configured as an authentication backend for the portal. See :ref:`single_sign_on_config` for more details. + +API Documentation +================= + +.. automethod:: tethys_apps.base.TethysAppBase.secure_map_service_settings + +.. automethod:: tethys_apps.base.TethysAppBase.get_secure_map_service + +.. automethod:: tethys_apps.base.TethysAppBase.update_secure_map_service_params diff --git a/docs/tutorials/secure_map_services.rst b/docs/tutorials/secure_map_services.rst index ffa57e4e6b..e96c1053f4 100644 --- a/docs/tutorials/secure_map_services.rst +++ b/docs/tutorials/secure_map_services.rst @@ -1,4 +1,4 @@ -.. _tutorial_secure_map_services: +.. _secure_map_services_tutorial: ******************* Secure Map Services @@ -22,13 +22,15 @@ The following topics are covered: * Utilizing a Secure Map Service as a response to retrieve data from a service and format it for use in your app. * Utilizing a Secure Map Service as an endpoint to make requests to a service from your app using JavaScript. +For more information on Secure Map Services, see the :ref:`Secure Map Services API Documentation `. + 0. Prerequisites ================ For this tutorial, we'll be utilizing two services that require authentication to use their data in your app: * **GEGD** - a WMS imagery service authenticated with an API key. -* **GRiD** - a REST API authenticated with an OAuth2 access token. +* **GRiD** - an OGC/WFS imagery service and REST API authenticated with an OAuth2 access token. Before beginning this tutorial, make sure you have created an account with each service and have retrieved an API key from the GEGD page. @@ -177,6 +179,7 @@ Now we'll be adding the GEGD service as a basemap to your MapLayout. Open your ` class SecureMapServiceMapLayout(MapLayout): app = App base_template = f'{App.package}/base.html' + template_name = f'{App.package}/home.html' map_title = 'Secure Map Services Tutorial' basemaps = [ {"WMS": { @@ -190,7 +193,7 @@ Now reopen your app and you should see the GEGD imagery on your map. Notice that if you look at the network traffic in your browser, you will see that the requests to the GEGD service are being proxied through your Tethys Portal and the API key is not visible in the request. 5. Configure for OAuth2 with GRiD -================================= +================================= Next, we want to add a map layer using the GRiD service. Before we can authenticate with OAuth2 to do that, we need to configure the Tethys Portal to use GRiD as an OAuth2 provider. Start by running this command: @@ -327,9 +330,9 @@ Now just go ahead and refresh your app and you should see the GRiD layer on your 7. Update Service Parameters ============================ -Now that you have data from GRiD displaying on your map in the form of a layer, you may want to change the parameters of the service to display different data. You can do this by going into the service settings and manually updating the parameters field. But you can also do this in your app dynamically using the ``update_secure_map_service_setting_params()`` method in your app code. +Now that you have data from GRiD displaying on your map in the form of a layer, you may want to change the parameters of the service to display different data. You can do this by going into the service settings and manually updating the parameters field. But you can also do this in your app dynamically using the ``update_secure_map_service_params()`` method in your app code. -In order to demonstrate how this can be done dynamically in your app, we'll add a form to the app that will allow the user to select which GRiD layer they want to display on the map. We'll then use the ``update_secure_map_service_setting_params()`` method to update the parameters of the GRiD service based on the user's selection. +In order to demonstrate how this can be done dynamically in your app, we'll add a form to the app that will allow the user to select which GRiD layer they want to display on the map. We'll then use the ``update_secure_map_service_params()`` method to update the parameters of the GRiD service based on the user's selection. We'll begin by adding a custom map tab to your MapLayout that will contain this form. Open ``home.html`` and add the following code: @@ -345,7 +348,6 @@ We'll begin by adding a custom map tab to your MapLayout that will contain this
{% csrf_token %} - {% gizmo grid_type %} {% gizmo update_grid_button %} @@ -403,7 +405,7 @@ Then add the following ``post()`` method to your MapLayout class: if grid_type not in ["pointcloud", "raster"]: return HttpResponse("Invalid GRID layer type selected.", status=400) - App.update_secure_map_service_setting_params( + App.update_secure_map_service_params( App.GRID_SECURE_MAP_SERVICE_NAME, params={ "typename": f"ms:gridws_{grid_type}", @@ -419,7 +421,7 @@ Now go ahead and try selecting a different GRiD layer from the select input and You can access a SecureMapService as a response in order to work directly with the data returned from the service in your Python code. We'll be using this to retrieve spatial data from the GRiD API and format it into GeoJSON to display AOIs on the map. -First, let's add a new SecureMapServiceSetting to your app class in ``app.py`` for the GRiD AOI service. This service will be used to both dsiplay existing AOIs on the map, and to submit new AOIs to the GRiD service. Add the following code to your app class: +First, let's add a new SecureMapServiceSetting to your app class in ``app.py`` for the GRiD AOI service. This service will be used to both display existing AOIs on the map, and to submit new AOIs to the GRiD service. Add the following code to your app class: .. code-block:: python :emphasize-lines: 18, 36-40 @@ -498,13 +500,13 @@ For that you'll need to first add the following packages to the dependencies of :emphasize-lines: 6, 8 requirements: - # Putting in a skip true param will skip the entire section. Ignoring the option will assume it be set to False - skip: false - conda: - channels: - - conda-forge - packages: - - shapely + # Putting in a skip true param will skip the entire section. Ignoring the option will assume it be set to False + skip: false + conda: + channels: + - conda-forge + packages: + - shapely Next, add the following imports to ``controllers.py``: @@ -556,7 +558,7 @@ Now add the following helper function to ``controllers.py``. This function will "type": "FeatureCollection", "crs": {"type": "name", "properties": {"name": "EPSG:4326"}}, "features": features, - } + } Now update your ``compose_layers()`` method in your MapLayout class to create a new MVLayer for the AOIs: @@ -707,27 +709,23 @@ Next, let's add the gizmos you'll need for your new AOI form. Add the following return context -Now you need to add the new gizmos to a form in ``home.html``. You'll also be making a slight update to the form you added in step 7 to differentiate it from the new AOI form in your app's requests. +Now you need to add the new gizmos to a form in ``home.html``. .. code-block:: html+django - :emphasize-lines: 5, 10-15 + :emphasize-lines: 8-12 {% block custom_map_tab_panels %}
{% csrf_token %} - {% gizmo grid_type %} {% gizmo update_grid_button %} -
-
-
- {% csrf_token %} - - {% gizmo aoi_name %} - {% gizmo create_aoi_button %} -
-
+ +
+ {% csrf_token %} + {% gizmo aoi_name %} + {% gizmo create_aoi_button %} +
{% endblock %} @@ -772,10 +770,13 @@ Now just include the new JavaScript file in your ``home.html`` file by adding th {% endblock %} -That's it! Now just do a refresh on your page and you should be able to draw an AOI on the map, enter a name for it, and click the "Create AOI" button to submit it to the GRiD service. You can check your GRiD account to see if the new AOI was created successfully, or just refresh the page and the AOI should be there with the other already existing AOIs. +Now just refresh the page and you should be able to draw an AOI on the map, enter a name for it, and click the "Create AOI" button to submit it to the GRiD service. You can check your GRiD account to see if the new AOI was created successfully, or just refresh the page and the AOI should be there with the other already existing AOIs. That's it! You've now successfully created a Tethys app that uses Secure Map Services to display data from GEGD and GRiD, and allows users to create new AOIs on the map using the GRiD service. +For more information, see the :ref:`Secure Map Services API Documentation `. + + 10. Solution ============ This concludes the tutorial. You can view the solution code for this tutorial in the `tethysapp-secure_map_tutorial `__ repository on GitHub. You can clone the repository using the following command: diff --git a/tests/unit_tests/test_tethys_apps/test_base/test_app_base.py b/tests/unit_tests/test_tethys_apps/test_base/test_app_base.py index 58e1c66767..f0e0339f79 100644 --- a/tests/unit_tests/test_tethys_apps/test_base/test_app_base.py +++ b/tests/unit_tests/test_tethys_apps/test_base/test_app_base.py @@ -1478,9 +1478,7 @@ def test__resolve_secure_map_service_object_does_not_exist(self, mock_ta): @mock.patch("tethys_apps.models.SecureMapServiceSetting.update_params") @mock.patch("tethys_apps.models.TethysApp") - def test_update_secure_map_service_params( - self, mock_ta, mock_update_params - ): + def test_update_secure_map_service_params(self, mock_ta, mock_update_params): mock_setting = ( mock_ta.objects.get().secure_map_service_settings.get.return_value ) @@ -1496,9 +1494,7 @@ def test_update_secure_map_service_params( mock_setting.update_params.assert_called_with(fake_params) @mock.patch("tethys_apps.models.TethysApp") - def test_update_secure_map_service_params_object_does_not_exist( - self, mock_ta - ): + def test_update_secure_map_service_params_object_does_not_exist(self, mock_ta): mock_get = mock_ta.objects.get().secure_map_service_settings.get mock_get.side_effect = ObjectDoesNotExist diff --git a/tethys_apps/base/app_base.py b/tethys_apps/base/app_base.py index 354f5875e8..f0d63109d3 100644 --- a/tethys_apps/base/app_base.py +++ b/tethys_apps/base/app_base.py @@ -1989,7 +1989,10 @@ def _resolve_secure_map_service( @classmethod def update_secure_map_service_params(cls, name, params): """ - Update the params for a given SecureMapServiceSetting. + Update the params for a given SecureMapServiceSetting's assigned SecureMapService. + + **NOTE**: This method will not update the params for the SecureMapServiceSetting itself. + It will only update the params for the assigned SecureMapService. Args: name(str): name of the SecureMapServiceSetting as defined in the app.py. diff --git a/tethys_services/models.py b/tethys_services/models.py index 7bc649e43b..bd21ea9dad 100644 --- a/tethys_services/models.py +++ b/tethys_services/models.py @@ -442,7 +442,7 @@ def __str__(self): @classmethod def get_authentication_method_options(cls): """ - Get the available authentication method options for the SecureMapService model. + Get the available authentication method options for the SecureMapService model. This method is used for populating the choices in the admin form. """ return [ @@ -454,7 +454,7 @@ def get_oauth_token(self, user): Retrieve the OAuth token for the given user. Args: user (User): The user for whom to retrieve the OAuth token. - + Returns: str: The OAuth token for the user. """ From fc45e5adeb85f1ba80b688a8fbb0758c7a6fb792 Mon Sep 17 00:00:00 2001 From: Jacob Johnson Date: Thu, 13 Aug 2026 13:54:38 -0600 Subject: [PATCH 38/50] Added references to SecureMapService throughout docs --- .../admin_pages/service_settings.rst | 2 +- .../admin_pages/tethys_services.rst | 3 +- docs/tethys_portal/configuration.rst | 2 ++ docs/tethys_sdk/app_class.rst | 9 ++++++ docs/tethys_sdk/app_settings.rst | 32 +++++++++++++++++++ docs/tethys_sdk/layouts/map_layout.rst | 9 +++++- 6 files changed, 54 insertions(+), 3 deletions(-) diff --git a/docs/tethys_portal/admin_pages/service_settings.rst b/docs/tethys_portal/admin_pages/service_settings.rst index 464d4ea76b..2358b5c2bf 100644 --- a/docs/tethys_portal/admin_pages/service_settings.rst +++ b/docs/tethys_portal/admin_pages/service_settings.rst @@ -4,7 +4,7 @@ Service Settings **************** -There are several different types of Service Settings including: ``Persistent Store Connection Settings``, ``Persistent Store Database Settings``, ``Dataset Service Settings``, ``Spatial Dataset Service Settings``, and ``Web Processing Service Settings`` (see Figure 1). These settings specify the types of services that the apps require. Use the drop down next to each Service Setting to assign a pre-registered ``Tethys Service`` to that app or use the *plus* button to create a new one. +There are several different types of Service Settings including: ``Persistent Store Connection Settings``, ``Persistent Store Database Settings``, ``Dataset Service Settings``, ``Spatial Dataset Service Settings``, ``Web Processing Service Settings``, and ``Secure Map Service Settings`` (see Figure 1). These settings specify the types of services that the apps require. Use the drop down next to each Service Setting to assign a pre-registered ``Tethys Service`` to that app or use the *plus* button to create a new one. .. figure:: ../../images/site_admin/service_settings.png :width: 675px diff --git a/docs/tethys_portal/admin_pages/tethys_services.rst b/docs/tethys_portal/admin_pages/tethys_services.rst index bb16900d84..5129c65349 100644 --- a/docs/tethys_portal/admin_pages/tethys_services.rst +++ b/docs/tethys_portal/admin_pages/tethys_services.rst @@ -4,7 +4,7 @@ Tethys Services *************** -The links under the ``TETHYS SERVICES`` heading can be used to register external services with Tethys Platform for use by apps and extensions. Use the ``Spatial Dataset Services`` link to register your Tethys Portal to GeoServer, the ``Dataset Services`` link to register to CKAN or HydroShare instances, the ``Web Processing Services`` link to register to WPS instances, or the ``PostgreSQL Persistent Store Services`` or ``SQLite Persistent Store Services`` link to register a database. +The links under the ``TETHYS SERVICES`` heading can be used to register external services with Tethys Platform for use by apps and extensions. Use the ``Spatial Dataset Services`` link to register your Tethys Portal to GeoServer, the ``Dataset Services`` link to register to CKAN or HydroShare instances, the ``Web Processing Services`` link to register to WPS instances, the ``Secure Map Services`` link to register map services that require an API key or OAuth2 access token, or the ``PostgreSQL Persistent Store Services`` or ``SQLite Persistent Store Services`` link to register a database. .. tip:: @@ -13,6 +13,7 @@ The links under the ``TETHYS SERVICES`` heading can be used to register external * :doc:`../tethys_sdk/tethys_services/spatial_dataset_services` * :doc:`../tethys_sdk/tethys_services/dataset_services` * :doc:`../tethys_sdk/tethys_services/web_processing_services` + * :doc:`../tethys_sdk/tethys_services/secure_map_services` * :doc:`../tethys_sdk/tethys_services/persistent_store` * :doc:`../tethys_sdk/tethys_services/spatial_persistent_store` * :ref:`tethys_portal_service_settings` diff --git a/docs/tethys_portal/configuration.rst b/docs/tethys_portal/configuration.rst index bb92c30d34..fa7b291a97 100644 --- a/docs/tethys_portal/configuration.rst +++ b/docs/tethys_portal/configuration.rst @@ -70,6 +70,8 @@ CONTEXT_PROCESSORS_OVERRIDE override for ``CONTEXT_PROCES RESOURCE_QUOTA_HANDLERS a list of Tethys ``ResourceQuotaHandler`` classes to load (see: :ref:`sdk_quotas_api`). For convenience, any quota handlers listed here will be appended to the default list of quota handlerss. To override ``RESOURCE_QUOTA_HANDLERS`` completely, use the ``RESOURCE_QUOTA_HANDLERS_OVERRIDE`` setting. RESOURCE_QUOTA_HANDLERS_OVERRIDE override for ``RESOURCE_QUOTA_HANDLERS`` setting. CAUTION: improper use of this setting can break the Tethys Portal. USE_OLD_WORKSPACES_API a temporary setting that maintains backward compatibility for the :ref:`tethys_workspaces_api` when True. When False the the new :ref:`tethys_paths_api` functionality will apply. Defaults to True. Will be removed in 5.0. +SALT_KEY the key used to encrypt sensitive values that are stored in the database, such as ``SecureMapService`` API keys (see: :ref:`secure_map_services_api`). Automatically generated by ``tethys gen portal_config``. Generate a new one at any time with ``tethys settings --generate-salt-key``. +OAUTH_REQUIREMENTS a mapping of app package to the name of a social auth provider that users must be linked to before they can access that app (e.g. ``{"my_first_app": "my_provider"}``). Users who have not linked an account are redirected to their user settings page. See :ref:`secure_map_services_oauth`. ================================================== ================================================================================ .. _tethys_portal_config_settings: diff --git a/docs/tethys_sdk/app_class.rst b/docs/tethys_sdk/app_class.rst index b9d48759af..cc0580842c 100644 --- a/docs/tethys_sdk/app_class.rst +++ b/docs/tethys_sdk/app_class.rst @@ -47,6 +47,9 @@ Override these methods (add them to your app class) to define objects that are u .. automethod:: tethys_sdk.base.TethysAppBase.web_processing_service_settings :noindex: +.. automethod:: tethys_sdk.base.TethysAppBase.secure_map_service_settings + :noindex: + .. automethod:: tethys_sdk.base.TethysAppBase.scheduler_settings :noindex: @@ -86,6 +89,12 @@ Class Methods .. automethod:: tethys_sdk.base.TethysAppBase.get_web_processing_service :noindex: +.. automethod:: tethys_sdk.base.TethysAppBase.get_secure_map_service + :noindex: + +.. automethod:: tethys_sdk.base.TethysAppBase.update_secure_map_service_params + :noindex: + .. automethod:: tethys_sdk.base.TethysAppBase.get_handoff_manager :noindex: diff --git a/docs/tethys_sdk/app_settings.rst b/docs/tethys_sdk/app_settings.rst index 9926520b6b..4163d9998d 100644 --- a/docs/tethys_sdk/app_settings.rst +++ b/docs/tethys_sdk/app_settings.rst @@ -108,6 +108,30 @@ To retrieve a connection to a Web Processing Service, import your :term:`app cla See the :doc:`./tethys_services/web_processing_services` for more details on how to use Dataset Services in your apps. +.. _app_settings_secure_map_service_settings: + +Secure Map Service Settings +=========================== + +Secure Map Service Settings are used to request map services that require credentials to access (e.g. a WMS service authenticated with an API key or an OAuth2 access token). Create Secure Map Service Settings by implementing the ``secure_map_service_settings()`` method in your :term:`app class`. This method should return a list of SecureMapServiceSetting_ objects: + +.. automethod:: tethys_apps.base.TethysAppBase.secure_map_service_settings + :noindex: + +To retrieve a Secure Map Service, import your :term:`app class` and call the ``get_secure_map_service()`` class method: + +.. automethod:: tethys_apps.base.TethysAppBase.get_secure_map_service + :noindex: + +To update the parameters of the service assigned to a setting, call the ``update_secure_map_service_params()`` class method: + +.. automethod:: tethys_apps.base.TethysAppBase.update_secure_map_service_params + :noindex: + +.. tip:: + + See the :doc:`./tethys_services/secure_map_services` for more details on how to use Secure Map Services in your apps. + .. _app_settings_scheduler_settings: Scheduler Settings @@ -163,6 +187,10 @@ Settings Objects .. autoclass:: tethys_sdk.app_settings.WebProcessingServiceSetting +.. _SecureMapServiceSetting: + +.. autoclass:: tethys_sdk.app_settings.SecureMapServiceSetting + .. _SchedulerSetting: .. autoclass:: tethys_sdk.app_settings.SchedulerSetting @@ -180,6 +208,8 @@ Settings Declaration Methods .. automethod:: tethys_apps.base.TethysAppBase.web_processing_service_settings +.. automethod:: tethys_apps.base.TethysAppBase.secure_map_service_settings + .. automethod:: tethys_apps.base.TethysAppBase.scheduler_settings @@ -198,4 +228,6 @@ Settings Getter Methods .. automethod:: tethys_apps.base.TethysAppBase.get_web_processing_service +.. automethod:: tethys_apps.base.TethysAppBase.get_secure_map_service + .. automethod:: tethys_apps.base.TethysAppBase.get_scheduler diff --git a/docs/tethys_sdk/layouts/map_layout.rst b/docs/tethys_sdk/layouts/map_layout.rst index 7a8beb9d69..e3b5b0e2c5 100644 --- a/docs/tethys_sdk/layouts/map_layout.rst +++ b/docs/tethys_sdk/layouts/map_layout.rst @@ -60,7 +60,7 @@ Add Layers To add layers to the map in a ``MapLayout``, override the :ref:`compose_layers ` method. The ``MapLayout`` view uses the :ref:`MapView Gizmo ` under the covers and it is given to the ``compose_layers()`` method via the ``map_view`` argument. Use the ``map_view`` argument to add new :ref:`MVLayers ` to the ``MapView``. -While the ``MapView`` Gizmo will be able to accept any ``MVLayer`` object, the ``MapLayout`` needs layers to have additional metadata attached for them to be recognized by the layers in the Layer Tree, legend, and other features of ``MapLayout``. Several helper methods are provided by ``MapLayout`` to assist with building ``MVLayer`` objects in the correct way: :ref:`build_wms_layer() `, :ref:`build_geojson_layer() `, and :ref:`build_arc_gis_layer() `. +While the ``MapView`` Gizmo will be able to accept any ``MVLayer`` object, the ``MapLayout`` needs layers to have additional metadata attached for them to be recognized by the layers in the Layer Tree, legend, and other features of ``MapLayout``. Several helper methods are provided by ``MapLayout`` to assist with building ``MVLayer`` objects in the correct way: :ref:`build_wms_layer() `, :ref:`build_geojson_layer() `, :ref:`build_arc_gis_layer() `, and :ref:`build_gml_layer() `. In addition, the ``compose_layers()`` method needs to return a ``list`` of at least one Layer Group. A Layer Group contains a list of layers and is used by the Layer Tree of ``MapLayout`` to organize layers. In addition, a control type is specified for each Layer Group (``'check' or 'radio'``), and can be used to control whether all the layers in a Layer Group can be viewed simultaneously (``'check'``) or only one at a time (``'radio'``). Create Layer Groups using the :ref:`map_layout_build_layer_group` helper method. @@ -838,6 +838,13 @@ build_arc_gis_layer .. automethod:: tethys_layouts.views.map_layout.MapLayout.build_arc_gis_layer +.. _map_layout_build_gml_layer: + +build_gml_layer +^^^^^^^^^^^^^^^ + +.. automethod:: tethys_layouts.views.map_layout.MapLayout.build_gml_layer + .. _map_layout_build_layer_group: build_layer_group From 478589136ea90c051decb0005f97789388620d8c Mon Sep 17 00:00:00 2001 From: Jacob Johnson Date: Thu, 13 Aug 2026 14:50:18 -0600 Subject: [PATCH 39/50] Removed models mock to get rid of MagicMocks showing up in documentation --- docs/conf.py | 1 - 1 file changed, 1 deletion(-) diff --git a/docs/conf.py b/docs/conf.py index 079d909d1b..c498bd993c 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -94,7 +94,6 @@ "sqlalchemy", "sqlalchemy.orm", "tethys_apps.harvester", - "tethys_apps.models", # Mocked to prevent issues with loading apps during docs build. "tethys_apps.admin", # Mocked to prevent issues with loading models during docs build. "yaml", ] From e4d03965ae9f2a7d1661af325b76a1f8ccc9be51 Mon Sep 17 00:00:00 2001 From: Jacob Johnson Date: Thu, 13 Aug 2026 15:00:03 -0600 Subject: [PATCH 40/50] Added missing install portion in secure map services tutorial --- docs/tutorials/secure_map_services.rst | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/docs/tutorials/secure_map_services.rst b/docs/tutorials/secure_map_services.rst index e96c1053f4..55d7b53ff8 100644 --- a/docs/tutorials/secure_map_services.rst +++ b/docs/tutorials/secure_map_services.rst @@ -508,6 +508,12 @@ For that you'll need to first add the following packages to the dependencies of packages: - shapely +Then you'll need to actually install the shapely package into your environment. You can do this by running the following command: + +.. code-block:: bash + + conda install -c conda-forge shapely + Next, add the following imports to ``controllers.py``: .. code-block:: python From a6e04f900a894be220dda31f17689a8b4fbe5dec Mon Sep 17 00:00:00 2001 From: Jacob Johnson Date: Fri, 14 Aug 2026 00:26:36 -0600 Subject: [PATCH 41/50] Final updates/fixes in secure map services tutorial --- docs/tutorials/secure_map_services.rst | 47 +++++++++++++++----------- 1 file changed, 27 insertions(+), 20 deletions(-) diff --git a/docs/tutorials/secure_map_services.rst b/docs/tutorials/secure_map_services.rst index 55d7b53ff8..24c1af4598 100644 --- a/docs/tutorials/secure_map_services.rst +++ b/docs/tutorials/secure_map_services.rst @@ -92,7 +92,7 @@ Next open the ``home.html`` in your ``templates/secure_map_services_tutorial`` d .. code-block:: html+django {% extends "tethys_layouts/map_layout/map_layout.html" %} - {% load tethys %} + {% load static tethys %} Now go ahead and open your app at localhost:8000 and you should see a fully interactive map like this one: @@ -173,7 +173,7 @@ Then save your new Secure Map Service and assign it to the GEGD Secure Map Servi Now we'll be adding the GEGD service as a basemap to your MapLayout. Open your ``controllers.py`` and add the following to your MapLayout class: .. code-block:: python - :emphasize-lines: 6-11 + :emphasize-lines: 7-12 @controller(name='home') class SecureMapServiceMapLayout(MapLayout): @@ -326,6 +326,10 @@ Our next step will be to add a new map layer to our MapLayout using the GRiD ser ] return layer_groups +.. caution:: + + The ellipsis in the code block above indicates code that is not shown for brevity. **DO NOT COPY VERBATIM**. + Now just go ahead and refresh your app and you should see the GRiD layer on your map. You can toggle the visibility of the layer using the layers control in the top right corner of the map. 7. Update Service Parameters @@ -357,6 +361,7 @@ We'll begin by adding a custom map tab to your MapLayout that will contain this Now we'll need to add the gizmos for the form to your MapLayout class in ``controllers.py``. Add the following code: .. code-block:: python + :emphasize-lines: 1, 7-28 from tethys_sdk.gizmos import Button, SelectInput ... @@ -399,22 +404,26 @@ To add that functionality, first add the following imports to the top of `contro Then add the following ``post()`` method to your MapLayout class: .. code-block:: python + :emphasize-lines: 4-16 - def post(self, request, *args, **kwargs): - grid_type = request.POST.get("grid_type") - if grid_type not in ["pointcloud", "raster"]: - return HttpResponse("Invalid GRID layer type selected.", status=400) + class SecureMapServiceMapLayout(MapLayout): + ... - App.update_secure_map_service_params( - App.GRID_SECURE_MAP_SERVICE_NAME, - params={ - "typename": f"ms:gridws_{grid_type}", - }, - ) + def post(self, request, *args, **kwargs): + grid_type = request.POST.get("grid_type") + if grid_type not in ["pointcloud", "raster"]: + return HttpResponse("Invalid GRID layer type selected.", status=400) + + App.update_secure_map_service_params( + App.GRID_SECURE_MAP_SERVICE_NAME, + params={ + "typename": f"ms:gridws_{grid_type}", + }, + ) - return redirect(request.path) + return redirect(request.path) -Now go ahead and try selecting a different GRiD layer from the select input and clicking the "Update GRID Layer" button. The app will refresh and you should see the layer on the map update to reflect your selection. You can even go in and look at the service settings and see that the parameters have been updated to reflect your selection manually. +Now go ahead and try selecting a different GRiD layer from the select input and clicking the "Update GRID Layer" button. The app will refresh and you should see the layer on the map update to reflect your selection. You can even go in and look at the service settings and see that the parameters have been updated to reflect your selection. 8. Using a Secure Map Service as a Response =========================================== @@ -477,7 +486,7 @@ Use these configurations for the new Secure Map Service: - **Legend Title:** GRiD AOIs - **Authentication Method:** OAuth - **OAuth Provider:** grid -- **Service Type:** WMS +- **Service Type:** REST/JSON API - **Use Proxy for Requests:** True - **Parameters:** @@ -492,7 +501,7 @@ Use these configurations for the new Secure Map Service: Save your new Secure Map Service and assign it to the GRiD AOI Secure Map Service setting, then save your app settings. -Next, add a new layer to your MapLayout class in ``controllers.py`` that will display the existing AOIs on the map. +Next, you'll add a new layer to your MapLayout class in ``controllers.py`` that will display the existing AOIs on the map. For that you'll need to first add the following packages to the dependencies of your application. Open your ``install.yml`` and edit the requirements block like so: @@ -526,7 +535,6 @@ Next, add the following imports to ``controllers.py``: from django.shortcuts import redirect from shapely import wkt from shapely.geometry import mapping - import json from .app import App Now add the following helper function to ``controllers.py``. This function will help format the AOI data returned from the GRiD service into a GeoJSON format that can be used to create a new MVLayer: @@ -629,7 +637,7 @@ First, we'll need to make some updates to your ``controllers.py`` file. To start, update your imports: .. code-block:: python - :emphasize-lines: 3, 9 + :emphasize-lines: 3, 8 from tethys_sdk.layouts import MapLayout from tethys_sdk.routing import controller @@ -638,7 +646,6 @@ To start, update your imports: from django.shortcuts import redirect from shapely import wkt from shapely.geometry import mapping - import json import requests from .app import App @@ -647,7 +654,7 @@ Next, you need to add drawing capabilities to your map so that users can draw AO Begin by adding the MVDraw gizmo to your class: .. code-block:: python - :emphasize-lines: 6-11 + :emphasize-lines: 13-17 @controller(name='home') class SecureMapServiceMapLayout(MapLayout): From c0dd8135e88580cd42f21962de3c2a0a0f2938fa Mon Sep 17 00:00:00 2001 From: Jacob Johnson Date: Fri, 14 Aug 2026 11:12:15 -0600 Subject: [PATCH 42/50] Added testing --- .../test_tethys_cli/test_settings_commands.py | 116 ++++++++++++++++++ .../test_tethys_portal/test_middleware.py | 27 ++++ 2 files changed, 143 insertions(+) diff --git a/tests/unit_tests/test_tethys_cli/test_settings_commands.py b/tests/unit_tests/test_tethys_cli/test_settings_commands.py index 10e17d8d80..07c7b44924 100644 --- a/tests/unit_tests/test_tethys_cli/test_settings_commands.py +++ b/tests/unit_tests/test_tethys_cli/test_settings_commands.py @@ -127,3 +127,119 @@ def test_settings_command_rm(self, _, mock_remove_setting): mock_args = mock.MagicMock(set_kwargs=None, get_key=None, rm_key=rm_key) cmds.settings_command(mock_args) mock_remove_setting.assert_called_with({}, rm_key[0]) + + @mock.patch("tethys_cli.settings_commands.generate_salt_key_setting") + @mock.patch( + "tethys_cli.settings_commands.read_settings", + return_value={"SALT_KEY": "existing_salt_key"}, + ) + def test_settings_command_gsk(self, _, mock_gsk): + mock_args = mock.MagicMock( + set_kwargs=None, + get_key=None, + rm_key=None, + generate_salt_key=True, + overwrite=True, + ) + cmds.settings_command(mock_args) + mock_gsk.assert_called_with({"SALT_KEY": "existing_salt_key"}, True) + + @mock.patch("tethys_cli.settings_commands.generate_salt_key") + @mock.patch("tethys_cli.settings_commands.write_success") + @mock.patch("tethys_cli.settings_commands.write_settings") + def test_generate_salt_key_setting_no_salt_key( + self, mock_write_settings, mock_write_success, mock_gsk + ): + tethys_settings = {} + mock_gsk.return_value = "mock_salt_key123" + cmds.generate_salt_key_setting(tethys_settings, overwrite=False) + mock_write_settings.assert_called_with({"SALT_KEY": "mock_salt_key123"}) + mock_write_success.assert_called_with("Successfully generated a new SALT_KEY.") + + @mock.patch("tethys_cli.settings_commands.generate_salt_key") + @mock.patch("tethys_cli.settings_commands.write_success") + @mock.patch("tethys_cli.settings_commands.write_settings") + def test_generate_salt_key_setting_existing_salt_key_overwrite_true( + self, mock_write_settings, mock_write_success, mock_gsk + ): + tethys_settings = {"SALT_KEY": "existing_salt_key"} + mock_gsk.return_value = "mock_salt_key123" + cmds.generate_salt_key_setting(tethys_settings, overwrite=True) + mock_write_settings.assert_called_with({"SALT_KEY": "mock_salt_key123"}) + mock_write_success.assert_called_with("Successfully generated a new SALT_KEY.") + + @mock.patch("builtins.input") + @mock.patch("tethys_cli.settings_commands.generate_salt_key") + @mock.patch("tethys_cli.settings_commands.write_success") + @mock.patch( + "tethys_cli.settings_commands.write_warning" + ) # Mocked to avoid actual warning output during test + @mock.patch("tethys_cli.settings_commands.write_settings") + def test_generate_salt_key_setting_existing_salt_key_invalid_then_yes( + self, mock_write_settings, mock_ww, mock_write_success, mock_gsk, mock_input + ): + tethys_settings = {"SALT_KEY": "existing_salt_key"} + mock_gsk.return_value = "mock_salt_key123" + mock_input.side_effect = ["invalid", "maybe", "y"] + cmds.generate_salt_key_setting(tethys_settings, overwrite=False) + mock_input.assert_has_calls( + [ + mock.call("Overwrite? (y/n): "), + mock.call("Invalid option. Overwrite? (y/n): "), + mock.call("Invalid option. Overwrite? (y/n): "), + ] + ) + self.assertEqual(mock_input.call_count, 3) + mock_write_settings.assert_called_with({"SALT_KEY": "mock_salt_key123"}) + mock_write_success.assert_called_with("Successfully generated a new SALT_KEY.") + + @mock.patch("builtins.input") + @mock.patch("tethys_cli.settings_commands.write_settings") + @mock.patch("tethys_cli.settings_commands.write_warning") + def test_generate_salt_key_setting_existing_salt_key_no( + self, mock_ww, mock_ws, mock_input + ): + tethys_settings = {"SALT_KEY": "existing_salt_key"} + mock_input.return_value = "n" + cmds.generate_salt_key_setting(tethys_settings, overwrite=False) + mock_ww.assert_called_with("Generation of SALT_KEY cancelled.") + mock_input.assert_called_once_with("Overwrite? (y/n): ") + mock_ws.assert_not_called() + + @mock.patch("builtins.input") + @mock.patch("tethys_cli.settings_commands.generate_salt_key") + @mock.patch("tethys_cli.settings_commands.write_success") + @mock.patch( + "tethys_cli.settings_commands.write_warning" + ) # Mocked to avoid actual warning output during test + @mock.patch("tethys_cli.settings_commands.write_settings") + def test_generate_salt_key_setting_existing_salt_key_yes( + self, mock_write_settings, mock_ww, mock_write_success, mock_gsk, mock_input + ): + tethys_settings = {"SALT_KEY": "existing_salt_key"} + mock_gsk.return_value = "mock_salt_key123" + mock_input.return_value = "y" + cmds.generate_salt_key_setting(tethys_settings, overwrite=False) + mock_input.assert_called_once_with("Overwrite? (y/n): ") + mock_write_settings.assert_called_with({"SALT_KEY": "mock_salt_key123"}) + mock_write_success.assert_called_with("Successfully generated a new SALT_KEY.") + + @mock.patch("builtins.input") + @mock.patch("tethys_cli.settings_commands.write_settings") + @mock.patch("tethys_cli.settings_commands.write_warning") + def test_generate_salt_key_setting_existing_salt_key_invalid_then_no( + self, mock_ww, mock_ws, mock_input + ): + tethys_settings = {"SALT_KEY": "existing_salt_key"} + mock_input.side_effect = ["invalid", "maybe", "n"] + cmds.generate_salt_key_setting(tethys_settings, overwrite=False) + mock_input.assert_has_calls( + [ + mock.call("Overwrite? (y/n): "), + mock.call("Invalid option. Overwrite? (y/n): "), + mock.call("Invalid option. Overwrite? (y/n): "), + ] + ) + self.assertEqual(mock_input.call_count, 3) + mock_ww.assert_called_with("Generation of SALT_KEY cancelled.") + mock_ws.assert_not_called() diff --git a/tests/unit_tests/test_tethys_portal/test_middleware.py b/tests/unit_tests/test_tethys_portal/test_middleware.py index 5eb826f454..55f44bdbce 100644 --- a/tests/unit_tests/test_tethys_portal/test_middleware.py +++ b/tests/unit_tests/test_tethys_portal/test_middleware.py @@ -928,3 +928,30 @@ def test_oauth_required_no_provider( mock_redirect.assert_called_once_with( f"{mock_reverse.return_value}?next=/apps/test_package/test_path" ) + + @mock.patch("tethys_portal.middleware.urlencode") + @mock.patch("tethys_portal.middleware.reverse") + @mock.patch("tethys_portal.middleware.redirect") + @mock.patch("tethys_portal.middleware.get_active_app") + @mock.patch("tethys_portal.middleware.settings") + def test_oauth_required_user_not_authenticated( + self, + mock_settings, + mock_gap, + mock_redirect, + mock_reverse, + mock_urlencode, + ): + mock_get_response = mock.MagicMock() + mock_settings.OAUTH_REQUIREMENTS = {"test_package": "test_provider"} + mock_gap.return_value = mock.MagicMock(package="test_package") + mock_request = mock.MagicMock() + mock_request.get_full_path.return_value = "/apps/test_package/test_path" + mock_request.user.is_authenticated = False + mock_reverse.return_value = "/accounts/login/" + mock_urlencode.return_value = "next=/apps/test_package/test_path" + TethysOauthRequiredMiddleware(mock_get_response)(mock_request) + mock_reverse.assert_called_once_with("accounts:login") + mock_redirect.assert_called_once_with( + f"{mock_reverse.return_value}?next=/apps/test_package/test_path" + ) From ad43863f7268e1e3a24e1057a78cc45d6c848bba Mon Sep 17 00:00:00 2001 From: Jacob Johnson Date: Fri, 14 Aug 2026 14:33:32 -0600 Subject: [PATCH 43/50] Added OAUTH_REQUIREMENTS setting --- tethys_portal/settings.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tethys_portal/settings.py b/tethys_portal/settings.py index f9c67f638f..61f3f94e5b 100644 --- a/tethys_portal/settings.py +++ b/tethys_portal/settings.py @@ -592,6 +592,10 @@ "OAUTH2_PROVIDER", portal_config_settings.get("OAUTH2_PROVIDER", {}) ).pop("URL_NAMESPACE", "o") +# Mapping of app package to the social auth provider users must be linked to +# before they can access that app (e.g. {"my_first_app": "my_provider"}) +OAUTH_REQUIREMENTS = portal_config_settings.pop("OAUTH_REQUIREMENTS", {}) + # MFA Settings # See: https://github.com/mkalioby/django-mfa2 # Methods that shouldn't be allowed for the user, U2F, FIDO2, TOTP, Trusted_Devices, Email From e55d9bead6c42d9ebad4b892c4eeea7db7b970de Mon Sep 17 00:00:00 2001 From: Jacob Johnson Date: Fri, 14 Aug 2026 15:44:55 -0600 Subject: [PATCH 44/50] Fixed typo in error handling --- tethys_gizmos/static/tethys_gizmos/js/tethys_map_view.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tethys_gizmos/static/tethys_gizmos/js/tethys_map_view.js b/tethys_gizmos/static/tethys_gizmos/js/tethys_map_view.js index e92567c7f7..e60c5a9815 100644 --- a/tethys_gizmos/static/tethys_gizmos/js/tethys_map_view.js +++ b/tethys_gizmos/static/tethys_gizmos/js/tethys_map_view.js @@ -1061,7 +1061,7 @@ ol_layers_init = function() .catch(err => { console.error('GML load failed: ', err); gmlSource.removeLoadedExtent(extent); - failure(); + failer(); }) } }) From 294d11d1817d6d93f889619f3795f411e9726993 Mon Sep 17 00:00:00 2001 From: Jacob Johnson Date: Fri, 14 Aug 2026 16:56:11 -0600 Subject: [PATCH 45/50] Added token support for default vector layers --- .../tethys_gizmos/js/tethys_map_view.js | 44 +++++++++++++++++-- 1 file changed, 41 insertions(+), 3 deletions(-) diff --git a/tethys_gizmos/static/tethys_gizmos/js/tethys_map_view.js b/tethys_gizmos/static/tethys_gizmos/js/tethys_map_view.js index e60c5a9815..de79141789 100644 --- a/tethys_gizmos/static/tethys_gizmos/js/tethys_map_view.js +++ b/tethys_gizmos/static/tethys_gizmos/js/tethys_map_view.js @@ -1061,7 +1061,7 @@ ol_layers_init = function() .catch(err => { console.error('GML load failed: ', err); gmlSource.removeLoadedExtent(extent); - failer(); + if (failer) { failer(); } }) } }) @@ -1084,8 +1084,46 @@ ol_layers_init = function() // Generic vector case else { - Source = string_to_function('ol.source.' + current_layer.source); - current_layer_layer_options['source'] = new Source(current_layer.options); + let token = current_layer.options ? current_layer.options.token: null; + let baseUrl = current_layer.options ? current_layer.options.url: null; + + if (token && baseUrl) { + let format_name = current_layer.options.format || 'GeoJSON'; + let VectorFormat = string_to_function('ol.format.' + format_name); + let vector_format = new VectorFormat(); + + let vector_source = new ol.source.Vector({ + format: vector_format, + strategy: use_bbox ? ol.loadingstrategy.bbox : ol.loadingstrategy.all, + loader: function(extent, resolution, projection, success, failer) { + let sep = baseUrl.indexOf('?') === -1 ? '?' : '&'; + let url = baseUrl + sep + 'bbox=' + extent.join(',') + ',EPSG:3857'; + + let headers = {'Authorization': 'Bearer ' + token}; + + fetch(url, {credentials: 'same-origin', headers: headers}) + .then(r => r.text()) + .then(text => { + let features = vector_format.readFeatures(text, { + dataProjection: 'EPSG:4326', + featureProjection: DEFAULT_PROJECTION, + }); + vector_source.addFeatures(features); + success(features); + }) + .catch(err => { + console.error('Vector load failed: ', err); + vector_source.removeLoadedExtent(extent); + if (failer) { failer(); } + }) + }, + }); + current_layer_layer_options['source'] = vector_source; + } else { + Source = string_to_function('ol.source.' + current_layer.source); + current_layer_layer_options['source'] = new Source(current_layer.options); + } + layer = new ol.layer.Vector(current_layer_layer_options); } } From 5fa8f8c439e1ed7d97e47953b2adfde72ac10ffe Mon Sep 17 00:00:00 2001 From: Jacob Johnson Date: Fri, 14 Aug 2026 17:00:30 -0600 Subject: [PATCH 46/50] Fixes in default vector token handling --- .../static/tethys_gizmos/js/tethys_map_view.js | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/tethys_gizmos/static/tethys_gizmos/js/tethys_map_view.js b/tethys_gizmos/static/tethys_gizmos/js/tethys_map_view.js index de79141789..15e46310d5 100644 --- a/tethys_gizmos/static/tethys_gizmos/js/tethys_map_view.js +++ b/tethys_gizmos/static/tethys_gizmos/js/tethys_map_view.js @@ -1056,7 +1056,7 @@ ol_layers_init = function() featureProjection: DEFAULT_PROJECTION, }); gmlSource.addFeatures(features); - success(features); + if (success) { success(features); } }) .catch(err => { console.error('GML load failed: ', err); @@ -1091,13 +1091,17 @@ ol_layers_init = function() let format_name = current_layer.options.format || 'GeoJSON'; let VectorFormat = string_to_function('ol.format.' + format_name); let vector_format = new VectorFormat(); + let use_bbox = current_layer.options.strategy === 'bbox'; let vector_source = new ol.source.Vector({ format: vector_format, strategy: use_bbox ? ol.loadingstrategy.bbox : ol.loadingstrategy.all, loader: function(extent, resolution, projection, success, failer) { - let sep = baseUrl.indexOf('?') === -1 ? '?' : '&'; - let url = baseUrl + sep + 'bbox=' + extent.join(',') + ',EPSG:3857'; + let url = baseUrl; + if (use_bbox) { + let sep = baseUrl.indexOf('?') === -1 ? '?' : '&'; + url += sep + 'bbox=' + extent.join(',') + ',' + DEFAULT_PROJECTION; + } let headers = {'Authorization': 'Bearer ' + token}; @@ -1109,7 +1113,7 @@ ol_layers_init = function() featureProjection: DEFAULT_PROJECTION, }); vector_source.addFeatures(features); - success(features); + if (success) { success(features); } }) .catch(err => { console.error('Vector load failed: ', err); @@ -1123,7 +1127,7 @@ ol_layers_init = function() Source = string_to_function('ol.source.' + current_layer.source); current_layer_layer_options['source'] = new Source(current_layer.options); } - + layer = new ol.layer.Vector(current_layer_layer_options); } } From 96e76e7e64c50f5871731f3902dba19a45723b93 Mon Sep 17 00:00:00 2001 From: Jacob Johnson Date: Tue, 18 Aug 2026 07:51:56 -0600 Subject: [PATCH 47/50] Updated tutorial screenshot --- .../secure_map_services/secure-map-service-initial-map.png | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/images/tutorial/secure_map_services/secure-map-service-initial-map.png b/docs/images/tutorial/secure_map_services/secure-map-service-initial-map.png index e870700ced..56b2c80679 100644 --- a/docs/images/tutorial/secure_map_services/secure-map-service-initial-map.png +++ b/docs/images/tutorial/secure_map_services/secure-map-service-initial-map.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:6e3c569706c139843ff47205a521037114e10e4cb9e4a6c60d1dd2dd62411a01 -size 1036797 +oid sha256:1cbef96d7b11298281208279a21eb67f963b9b665cff08eb3bbfdc50a03dc8d6 +size 1057771 From 5598324b111e25f21a578e2b121625e24a8d8f0b Mon Sep 17 00:00:00 2001 From: Jacob Johnson Date: Tue, 18 Aug 2026 08:12:22 -0600 Subject: [PATCH 48/50] Added token support for other layer options --- .../tethys_gizmos/js/tethys_map_view.js | 146 +++++++++++++++--- 1 file changed, 126 insertions(+), 20 deletions(-) diff --git a/tethys_gizmos/static/tethys_gizmos/js/tethys_map_view.js b/tethys_gizmos/static/tethys_gizmos/js/tethys_map_view.js index 15e46310d5..ff58ff89c9 100644 --- a/tethys_gizmos/static/tethys_gizmos/js/tethys_map_view.js +++ b/tethys_gizmos/static/tethys_gizmos/js/tethys_map_view.js @@ -150,7 +150,8 @@ var map_clicked, set_map_on_click, clear_clicked_point, highlight_clicked_point; var update_field; // Utility Methods -var is_defined, in_array, string_to_function, build_ol_objects, add_default_base_map_layer; +var is_defined, in_array, string_to_function, build_ol_objects, add_default_base_map_layer, + get_token_headers, remove_token, load_tile_token, load_image_token; // Class Declarations var DrawingControl, DragFeatureInteraction, DeleteFeatureInteraction; @@ -919,9 +920,15 @@ ol_layers_init = function() // Tile layer case if (in_array(current_layer.source, TILE_SOURCES)) { - var resolutions, source_options, tile_grid; + var resolutions, source_options, tile_grid, tile_token; - source_options = current_layer.options; + tile_token = current_layer.options ? current_layer.options.token : null; + source_options = remove_token(current_layer.options); + + // Load the tiles with an Authorization header when a token is given + if (tile_token) { + source_options['tileLoadFunction'] = load_tile_token(tile_token); + } if (source_options && 'tileGrid' in source_options) { source_options['tileGrid'] = new ol.tilegrid.TileGrid(source_options['tileGrid']); @@ -968,8 +975,16 @@ ol_layers_init = function() // Image layer case else if (in_array(current_layer.source, IMAGE_SOURCES)) { + let image_token = current_layer.options ? current_layer.options.token : null; + let image_source_options = remove_token(current_layer.options); + + // Load the images with an Authorization header when a token is given + if (image_token) { + image_source_options['imageLoadFunction'] = load_image_token(image_token); + } + Source = string_to_function('ol.source.' + current_layer.source); - current_layer_layer_options['source'] = new Source(current_layer.options); + current_layer_layer_options['source'] = new Source(image_source_options); layer = new ol.layer.Image(current_layer_layer_options); } @@ -1005,11 +1020,44 @@ ol_layers_init = function() else if (current_layer.source === KML){ // From URL case if (current_layer.options.hasOwnProperty('url')) { - current_layer_layer_options['source'] = new ol.source.Vector({ - url: current_layer.options.url, - format: new ol.format.KML(), - projection: new ol.proj.get(DEFAULT_PROJECTION) - }); + let kml_url = current_layer.options.url; + let kml_token = current_layer.options.token; + + // Load the KML with an Authorization header when a token is given + if (kml_token) { + let kml_format = new ol.format.KML(); + + let kml_url_source = new ol.source.Vector({ + format: kml_format, + strategy: ol.loadingstrategy.all, + projection: new ol.proj.get(DEFAULT_PROJECTION), + loader: function(extent, resolution, projection, success, failer) { + fetch(kml_url, {credentials: 'same-origin', headers: get_token_headers(kml_token)}) + .then(r => r.text()) + .then(text => { + let features = kml_format.readFeatures(text, { + featureProjection: DEFAULT_PROJECTION, + }); + kml_url_source.addFeatures(features); + if (success) { success(features); } + }) + .catch(err => { + console.error('KML load failed: ', err); + kml_url_source.removeLoadedExtent(extent); + if (failer) { failer(); } + }) + }, + }); + + current_layer_layer_options['source'] = kml_url_source; + } else { + current_layer_layer_options['source'] = new ol.source.Vector({ + url: kml_url, + format: new ol.format.KML(), + projection: new ol.proj.get(DEFAULT_PROJECTION) + }); + } + layer = new ol.layer.Vector(current_layer_layer_options); } @@ -1043,12 +1091,7 @@ ol_layers_init = function() let sep = baseUrl.indexOf('?') === -1 ? '?' : '&'; let url = baseUrl + sep + 'bbox=' + extent.join(',') + ',EPSG:3857'; - let headers = {}; - if (token) { - headers['Authorization'] = 'Bearer ' + token; - } - - fetch(url, {credentials: 'same-origin', headers: headers}) + fetch(url, {credentials: 'same-origin', headers: get_token_headers(token)}) .then(r => r.text()) .then(text => { let features = gmlFormat.readFeatures(text, { @@ -1102,10 +1145,7 @@ ol_layers_init = function() let sep = baseUrl.indexOf('?') === -1 ? '?' : '&'; url += sep + 'bbox=' + extent.join(',') + ',' + DEFAULT_PROJECTION; } - - let headers = {'Authorization': 'Bearer ' + token}; - - fetch(url, {credentials: 'same-origin', headers: headers}) + fetch(url, {credentials: 'same-origin', headers: get_token_headers(token)}) .then(r => r.text()) .then(text => { let features = vector_format.readFeatures(text, { @@ -1125,7 +1165,7 @@ ol_layers_init = function() current_layer_layer_options['source'] = vector_source; } else { Source = string_to_function('ol.source.' + current_layer.source); - current_layer_layer_options['source'] = new Source(current_layer.options); + current_layer_layer_options['source'] = new Source(remove_token(current_layer.options)); } layer = new ol.layer.Vector(current_layer_layer_options); @@ -2418,6 +2458,72 @@ is_defined = function(variable) return !!(typeof variable !== typeof undefined && variable !== false && variable !== null); }; +// Build request headers with a token for authentication +get_token_headers = function(token) { + let headers = {}; + + if (token) { + headers['Authorization'] = 'Bearer ' + token; + } + + return headers; +}; + +// Remove the token from the options object +remove_token = function(options) { + if (!options) { return options; } + + let stripped_options = Object.assign({}, options); + delete stripped_options.token; + return stripped_options; +}; + +// Load a tile with an authorization header +load_tile_token = function(token) { + return function(tile, src) { + fetch(src, {credentials: 'same-origin', headers: get_token_headers(token)}) + .then(r => { + if (!r.ok) { throw new Error('HTTP ' + r.status); } + return r.blob(); + }) + .then(blob => { + let object_url = URL.createObjectURL(blob); + let image = tile.getImage(); + image.onload = function() { URL.revokeObjectURL(object_url); }; + image.src = object_url; + }) + .catch(err => { + console.error('Tile load failed: ', err); + if (is_defined(ol.TileState) && tile.setState) { + tile.setState(ol.TileState.ERROR); + } + }); + }; +}; + +// Load an image with an authorization header +load_image_token = function(token) { + return function(image, src) { + fetch(src, {credentials: 'same-origin', headers: get_token_headers(token)}) + .then(r => { + if (!r.ok) { throw new Error('HTTP ' + r.status); } + return r.blob(); + }) + .then(blob => { + let object_url = URL.createObjectURL(blob); + let img = image.getImage(); + img.onload = function() { URL.revokeObjectURL(object_url); }; + img.src = object_url; + }) + .catch(err => { + console.error('Image load failed: ', err); + if (is_defined(ol.ImageState) && image.setState) { + image.setState(ol.ImageState.ERROR); + } + }); + }; +}; + // Instantiate a function from a string // credits: http://stackoverflow.com/questions/1366127/instantiate-a-javascript-object-using-a-string-to-define-the-class-name string_to_function = function(str) { From 34584f8da9bd2256b7922b31eb79d172d07bb893 Mon Sep 17 00:00:00 2001 From: Jacob Johnson Date: Tue, 18 Aug 2026 13:44:43 -0600 Subject: [PATCH 49/50] Fixed grid application setup instructions --- docs/tutorials/secure_map_services.rst | 29 ++++++++++++++++++-------- 1 file changed, 20 insertions(+), 9 deletions(-) diff --git a/docs/tutorials/secure_map_services.rst b/docs/tutorials/secure_map_services.rst index 24c1af4598..400cc1ac8c 100644 --- a/docs/tutorials/secure_map_services.rst +++ b/docs/tutorials/secure_map_services.rst @@ -208,15 +208,26 @@ Then configure your portal to require users to link their GRiD account before be tethys settings --set OAUTH_REQUIREMENTS.secure_map_tutorial grid -The last step required to configure your application to work with GRiD is to register your application with GRiD. You'll need to register your application with GRiD to get a client ID and client secret. You can do this by going to the GRiD developer portal and creating a new application. Use the following settings: +The last step required to configure your application to work with GRiD is to register your application with GRiD to get a client ID and client secret. You can do this by going to the GRiD developer portal and setting up a new application with the following settings: -- Go to https://grid.nga.mil/grid/api/application/list -- Click on "Create new application" -- Fill out the form with the following settings: - - **Application Name:** [YOUR APP NAME] - - **Redirect URI:** http://localhost:8000/oauth2/complete/grid/ -- Before submitting the form, make sure you've copied the client ID and client secret that are generated for your application. You'll need to add these to your Tethys Portal settings. -- Add the provided redirect URI to the Redirect uris field, along with `http://localhost:8000/oauth2/complete/grid/`, with each URI separated by a space. You can update this list later when you deploy your app to a production server. +#. Navigate to the `GRiD application list `_. + +#. Click **Create new application**. + +#. Fill out the form: + + :Application Name: Your application's name + :Redirect URIs: ``http://localhost:8000/oauth2/complete/grid/`` + + .. note:: + + Below the Redirect URIs field, you will see instructions to add a second redirect URI required + for the GRiD service to work with Tethys. Add it to the same field, separated from the first + by a space. You can update this list later when deploying to a production server. + +#. Copy the generated **client ID** and **client secret** and store them somewhere secure. + +#. Submit the form. Once you've registered your application, you'll need to add the client ID and client secret to your Tethys Portal settings. You can do this by running the following commands: @@ -394,7 +405,7 @@ Now we'll need to add the gizmos for the form to your MapLayout class in ``contr Now if you refresh your app, you should see a new tab on the left that you can switch to with a select input and a button. Right now if you click the "Update GRID Layer" button, nothing will happen. We'll need to add a ``post()`` method to your MapLayout class to handle the form submission and update the GRiD service parameters. -To add that functionality, first add the following imports to the top of `controllers.py`: +To add that functionality, first add the following imports to the top of ``controllers.py``: .. code-block:: python From 99f2dbb1245da50d40522e15815059476f47fa67 Mon Sep 17 00:00:00 2001 From: Jacob Johnson Date: Tue, 18 Aug 2026 14:07:21 -0600 Subject: [PATCH 50/50] More fixes in tutorial --- docs/tutorials/secure_map_services.rst | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/docs/tutorials/secure_map_services.rst b/docs/tutorials/secure_map_services.rst index 400cc1ac8c..29e5dae140 100644 --- a/docs/tutorials/secure_map_services.rst +++ b/docs/tutorials/secure_map_services.rst @@ -72,7 +72,7 @@ If you've already generated a portal_config file, you may still need to generate 3. Add a MapLayout ================== -We'll be using a MapLayout for this application, so we'll begin by adding a MapLayout controller to your app. Begin by opening your ``controllers.py`` file and replacing the contents with the following code: +We'll be using a MapLayout for this application, so we'll begin by adding a MapLayout controller to your app. Open your ``controllers.py`` file and replace the contents with the following code: .. code-block:: python @@ -103,7 +103,7 @@ Now go ahead and open your app at localhost:8000 and you should see a fully inte ================ Now, you'll be setting up your first SecureMapService that you'll be using as a basemap. You'll want to make sure you have created your GEGD account and have your API key ready. -First, open your ``app.py`` file and first add the following import to the top of your file: +First, open your ``app.py`` file and add the following import to the top of your file: .. code-block:: python @@ -219,11 +219,11 @@ The last step required to configure your application to work with GRiD is to reg :Application Name: Your application's name :Redirect URIs: ``http://localhost:8000/oauth2/complete/grid/`` - .. note:: +.. note:: - Below the Redirect URIs field, you will see instructions to add a second redirect URI required - for the GRiD service to work with Tethys. Add it to the same field, separated from the first - by a space. You can update this list later when deploying to a production server. + Below the Redirect URIs field, you will see instructions to add a second redirect URI required + for the GRiD service to work with Tethys. Add it to the same field, separated from the first + by a space. You can update this list later when deploying to a production server. #. Copy the generated **client ID** and **client secret** and store them somewhere secure. @@ -641,14 +641,14 @@ Go ahead and refresh your app and you should see the AOIs displayed on the map a The last feature we'll be adding to our app is the ability to use a SecureMapService as an endpoint that can be used to make requests to the service from your app. You'll be using the GRiD service for this example, allowing you to draw AOIs on the map and submitting them to the GRiD service to create new AOIs using the GRiD REST API. -Now let's look at adding a new AOI to the GRiD service using the GRiD AOI Secure Map Service. We'll be adding a new form to the custom map tab that will allow the user to draw a new AOI on the map and submit it to the GRiD service. +We'll be adding a new form to the custom map tab that will allow the user to set a name for their new AOI. First, we'll need to make some updates to your ``controllers.py`` file. To start, update your imports: .. code-block:: python - :emphasize-lines: 3, 8 + :emphasize-lines: 8 from tethys_sdk.layouts import MapLayout from tethys_sdk.routing import controller @@ -657,7 +657,6 @@ To start, update your imports: from django.shortcuts import redirect from shapely import wkt from shapely.geometry import mapping - import requests from .app import App Next, you need to add drawing capabilities to your map so that users can draw AOIs on the map that they would like to submit to the GRiD service. We'll be using the MVDraw gizmo for this.