diff --git a/routes/offline.py b/routes/offline.py index 63ca3f1..e341d92 100644 --- a/routes/offline.py +++ b/routes/offline.py @@ -9,49 +9,43 @@ from flask import Blueprint, request from utils.database import get_setting, set_setting from utils.responses import api_error, api_success -offline_bp = Blueprint('offline', __name__, url_prefix='/offline') +offline_bp = Blueprint("offline", __name__, url_prefix="/offline") # Default offline settings OFFLINE_DEFAULTS = { - 'offline.enabled': False, + "offline.enabled": False, # Default to bundled assets/fonts to avoid third-party CDN privacy blocks. - 'offline.assets_source': 'local', - 'offline.fonts_source': 'local', - 'offline.tile_provider': 'cartodb_dark_cyan', - 'offline.tile_server_url': '' + "offline.assets_source": "local", + "offline.fonts_source": "local", + "offline.tile_provider": "cartodb_dark_cyan", + "offline.tile_server_url": "", + "offline.stadia_key": "", } # Asset paths to check ASSET_PATHS = { - 'leaflet': [ - 'static/vendor/leaflet/leaflet.js', - 'static/vendor/leaflet/leaflet.css' + "leaflet": ["static/vendor/leaflet/leaflet.js", "static/vendor/leaflet/leaflet.css"], + "chartjs": ["static/vendor/chartjs/chart.umd.min.js"], + "inter": [ + "static/vendor/fonts/Inter-Regular.woff2", + "static/vendor/fonts/Inter-Medium.woff2", + "static/vendor/fonts/Inter-SemiBold.woff2", + "static/vendor/fonts/Inter-Bold.woff2", ], - 'chartjs': [ - 'static/vendor/chartjs/chart.umd.min.js' + "jetbrains": [ + "static/vendor/fonts/JetBrainsMono-Regular.woff2", + "static/vendor/fonts/JetBrainsMono-Medium.woff2", + "static/vendor/fonts/JetBrainsMono-SemiBold.woff2", + "static/vendor/fonts/JetBrainsMono-Bold.woff2", ], - 'inter': [ - 'static/vendor/fonts/Inter-Regular.woff2', - 'static/vendor/fonts/Inter-Medium.woff2', - 'static/vendor/fonts/Inter-SemiBold.woff2', - 'static/vendor/fonts/Inter-Bold.woff2' + "leaflet_images": [ + "static/vendor/leaflet/images/marker-icon.png", + "static/vendor/leaflet/images/marker-icon-2x.png", + "static/vendor/leaflet/images/marker-shadow.png", + "static/vendor/leaflet/images/layers.png", + "static/vendor/leaflet/images/layers-2x.png", ], - 'jetbrains': [ - 'static/vendor/fonts/JetBrainsMono-Regular.woff2', - 'static/vendor/fonts/JetBrainsMono-Medium.woff2', - 'static/vendor/fonts/JetBrainsMono-SemiBold.woff2', - 'static/vendor/fonts/JetBrainsMono-Bold.woff2' - ], - 'leaflet_images': [ - 'static/vendor/leaflet/images/marker-icon.png', - 'static/vendor/leaflet/images/marker-icon-2x.png', - 'static/vendor/leaflet/images/marker-shadow.png', - 'static/vendor/leaflet/images/layers.png', - 'static/vendor/leaflet/images/layers-2x.png' - ], - 'leaflet_heat': [ - 'static/vendor/leaflet-heat/leaflet-heat.js' - ] + "leaflet_heat": ["static/vendor/leaflet-heat/leaflet-heat.js"], } @@ -63,26 +57,26 @@ def get_offline_settings(): return settings -@offline_bp.route('/settings', methods=['GET']) +@offline_bp.route("/settings", methods=["GET"]) def get_settings(): """Get current offline settings.""" settings = get_offline_settings() - return api_success(data={'settings': settings}) + return api_success(data={"settings": settings}) -@offline_bp.route('/settings', methods=['POST']) +@offline_bp.route("/settings", methods=["POST"]) def save_setting(): """Save an offline setting.""" data = request.get_json() - if not data or 'key' not in data or 'value' not in data: - return api_error('Missing key or value', 400) + if not data or "key" not in data or "value" not in data: + return api_error("Missing key or value", 400) - key = data['key'] - value = data['value'] + key = data["key"] + value = data["value"] # Validate key is an allowed setting if key not in OFFLINE_DEFAULTS: - return api_error(f'Unknown setting: {key}', 400) + return api_error(f"Unknown setting: {key}", 400) # Validate value type matches default default_type = type(OFFLINE_DEFAULTS[key]) @@ -90,18 +84,18 @@ def save_setting(): # Try to convert try: if default_type == bool: - value = str(value).lower() in ('true', '1', 'yes') + value = str(value).lower() in ("true", "1", "yes") else: value = default_type(value) except (ValueError, TypeError): - return api_error(f'Invalid value type for {key}', 400) + return api_error(f"Invalid value type for {key}", 400) set_setting(key, value) - return api_success(data={'key': key, 'value': value}) + return api_success(data={"key": key, "value": value}) -@offline_bp.route('/status', methods=['GET']) +@offline_bp.route("/status", methods=["GET"]) def get_status(): """Check status of local assets.""" # Get the app root directory @@ -119,37 +113,36 @@ def get_status(): available = False missing.append(path) - results[asset_name] = { - 'available': available, - 'missing': missing if not available else [] - } + results[asset_name] = {"available": available, "missing": missing if not available else []} if not available: all_available = False - return api_success(data={ - 'all_available': all_available, - 'assets': results, - 'offline_enabled': get_setting('offline.enabled', False) - }) + return api_success( + data={ + "all_available": all_available, + "assets": results, + "offline_enabled": get_setting("offline.enabled", False), + } + ) -@offline_bp.route('/check-asset', methods=['GET']) +@offline_bp.route("/check-asset", methods=["GET"]) def check_asset(): """Check if a specific asset file exists.""" - path = request.args.get('path', '') + path = request.args.get("path", "") if not path: - return api_error('Missing path parameter', 400) + return api_error("Missing path parameter", 400) # Security: only allow checking within static/vendor - if not path.startswith('/static/vendor/'): - return api_error('Invalid path', 400) + if not path.startswith("/static/vendor/"): + return api_error("Invalid path", 400) # Remove leading slash and construct full path - relative_path = path.lstrip('/') + relative_path = path.lstrip("/") app_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) full_path = os.path.join(app_root, relative_path) exists = os.path.exists(full_path) - return api_success(data={'path': path, 'exists': exists}) + return api_success(data={"path": path, "exists": exists}) diff --git a/static/js/core/settings-manager.js b/static/js/core/settings-manager.js index 0449603..68558fd 100644 --- a/static/js/core/settings-manager.js +++ b/static/js/core/settings-manager.js @@ -9,7 +9,8 @@ const Settings = { 'offline.assets_source': 'local', 'offline.fonts_source': 'local', 'offline.tile_provider': 'cartodb_dark_cyan', - 'offline.tile_server_url': '' + 'offline.tile_server_url': '', + 'offline.stadia_key': '', }, // Tile provider configurations @@ -42,7 +43,19 @@ const Settings = { url: 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}', attribution: 'Tiles © Esri — Source: Esri, i-cubed, USDA, USGS, AEX, GeoEye, Getmapping, Aerogrid, IGN, IGP, UPR-EGP, and the GIS User Community', subdomains: null - } + }, + stadia_dark: { + url: 'https://tiles.stadiamaps.com/tiles/alidade_smooth_dark/{z}/{x}/{y}{r}.png', + attribution: '© Stadia Maps © OpenStreetMap', + subdomains: null, + requiresKey: true, + }, + tactical: { + url: 'https://tiles.stadiamaps.com/tiles/stamen_toner_background/{z}/{x}/{y}{r}.png', + attribution: '© Stadia Maps', + subdomains: null, + requiresKey: true, + }, }, // Registry of maps that can be updated @@ -292,6 +305,13 @@ const Settings = { customRow.style.display = provider === 'custom' ? 'block' : 'none'; } + // Show/hide Stadia API key row + const stadiaKeyRow = document.getElementById('stadiaKeyRow'); + if (stadiaKeyRow) { + stadiaKeyRow.style.display = + (provider === 'stadia_dark' || provider === 'tactical') ? 'block' : 'none'; + } + // Update tiles immediately for all providers. this._updateMapTiles(); const activeConfig = this.getTileConfig(); @@ -307,6 +327,15 @@ const Settings = { this._updateMapTiles(); }, + /** + * Save Stadia Maps API key and refresh tiles. + * @param {string} key + */ + async setStadiaKey(key) { + await this._save('offline.stadia_key', (key || '').trim()); + this._updateMapTiles(); + }, + /** * Get current tile configuration */ @@ -322,15 +351,26 @@ const Settings = { }; } - const config = this.tileProviders[provider] || this.tileProviders.cartodb_dark; + const baseConfig = this.tileProviders[provider] || this.tileProviders.cartodb_dark; - // Robust fallback: if dark Carto is active and Cyber is preferred, - // keep Cyber theme enabled even when provider temporarily reverts. - if (provider === 'cartodb_dark' && this._getMapThemePreference() === 'cyber') { - return { ...config, mapTheme: 'cyber' }; + if (baseConfig.requiresKey) { + const key = (this.get('offline.stadia_key') || '').trim(); + if (!key) { + // No key — fall back to CartoDB dark so the map isn't broken + return this.tileProviders.cartodb_dark; + } + return { + ...baseConfig, + url: baseConfig.url + '?api_key=' + encodeURIComponent(key), + }; } - return config; + // Robust fallback: keep Cyber theme when CartoDB dark is active and Cyber preferred. + if (provider === 'cartodb_dark' && this._getMapThemePreference() === 'cyber') { + return { ...baseConfig, mapTheme: 'cyber' }; + } + + return baseConfig; }, /** @@ -643,6 +683,18 @@ const Settings = { customRow.style.display = this.get('offline.tile_provider') === 'custom' ? 'block' : 'none'; } + // Stadia key input + const stadiaKeyInput = document.getElementById('stadiaKeyInput'); + if (stadiaKeyInput) { + stadiaKeyInput.value = this.get('offline.stadia_key') || ''; + } + const stadiaKeyRow = document.getElementById('stadiaKeyRow'); + if (stadiaKeyRow) { + const currentProvider = this.get('offline.tile_provider'); + stadiaKeyRow.style.display = + (currentProvider === 'stadia_dark' || currentProvider === 'tactical') ? 'block' : 'none'; + } + // Theme select const themeSelect = document.getElementById('themeSelect'); if (themeSelect) { diff --git a/templates/partials/settings-modal.html b/templates/partials/settings-modal.html index c043311..2fb814f 100644 --- a/templates/partials/settings-modal.html +++ b/templates/partials/settings-modal.html @@ -77,6 +77,8 @@ + + @@ -90,6 +92,15 @@ onchange="Settings.setCustomTileUrl(this.value)"> +
diff --git a/tests/test_stadia_settings.py b/tests/test_stadia_settings.py new file mode 100644 index 0000000..e6f89f6 --- /dev/null +++ b/tests/test_stadia_settings.py @@ -0,0 +1,41 @@ +import pytest + + +@pytest.fixture +def auth_client(client): + """Client with an authenticated session.""" + with client.session_transaction() as sess: + sess["logged_in"] = True + return client + + +def test_offline_settings_includes_stadia_key(auth_client): + """GET /offline/settings returns offline.stadia_key field.""" + resp = auth_client.get("/offline/settings") + assert resp.status_code == 200 + data = resp.get_json() + assert "offline.stadia_key" in data["settings"] + + +def test_stadia_key_defaults_to_empty_string(auth_client): + """Stadia key defaults to empty string, not None.""" + # Reset to empty string first to ensure isolation between test runs. + auth_client.post("/offline/settings", json={"key": "offline.stadia_key", "value": ""}) + resp = auth_client.get("/offline/settings") + data = resp.get_json() + assert data["settings"]["offline.stadia_key"] == "" + + +def test_stadia_key_can_be_saved(auth_client): + """POST /offline/settings saves offline.stadia_key.""" + resp = auth_client.post("/offline/settings", json={"key": "offline.stadia_key", "value": "test-key-123"}) + assert resp.status_code == 200 + data = resp.get_json() + assert data["value"] == "test-key-123" + + +def test_stadia_key_rejects_non_string(auth_client): + """POST /offline/settings rejects non-string stadia_key.""" + resp = auth_client.post("/offline/settings", json={"key": "offline.stadia_key", "value": 42}) + # Should coerce to string '42' (type matches str default) — not 400 + assert resp.status_code == 200