Fix observer location persistence and APRS defaults

This commit is contained in:
Smittix
2026-03-15 17:49:46 +00:00
parent 6b9c4ebebd
commit b5115d4aa1
5 changed files with 256 additions and 71 deletions
+122 -19
View File
@@ -1,26 +1,101 @@
"""Settings management routes.""" """Settings management routes."""
from __future__ import annotations from __future__ import annotations
import os import contextlib
import subprocess import os
import sys import re
import subprocess
from flask import Blueprint, Response, jsonify, request import sys
import threading
from utils.database import ( from pathlib import Path
from flask import Blueprint, Response, jsonify, request
from utils.database import (
delete_setting, delete_setting,
get_all_settings, get_all_settings,
get_correlations, get_correlations,
get_setting, get_setting,
set_setting, set_setting,
) )
from utils.logging import get_logger from utils.logging import get_logger
from utils.responses import api_error, api_success from utils.responses import api_error, api_success
from utils.validation import validate_latitude, validate_longitude
logger = get_logger('intercept.settings')
logger = get_logger('intercept.settings')
settings_bp = Blueprint('settings', __name__, url_prefix='/settings')
settings_bp = Blueprint('settings', __name__, url_prefix='/settings')
_env_lock = threading.Lock()
def _get_env_file_path() -> Path:
"""Return the project .env path."""
return Path(__file__).resolve().parent.parent / '.env'
def _write_env_value(key: str, value: str, env_path: Path | None = None) -> None:
"""Create or update a single key in the project .env file."""
path = env_path or _get_env_file_path()
path.parent.mkdir(parents=True, exist_ok=True)
with _env_lock:
lines = path.read_text().splitlines() if path.exists() else [
'# INTERCEPT environment configuration',
'',
]
pattern = re.compile(rf'^\s*{re.escape(key)}=')
updated = False
new_lines: list[str] = []
for line in lines:
if pattern.match(line):
if not updated:
new_lines.append(f'{key}={value}')
updated = True
continue
new_lines.append(line)
if not updated:
if new_lines and new_lines[-1] != '':
new_lines.append('')
new_lines.append(f'{key}={value}')
path.write_text('\n'.join(new_lines).rstrip('\n') + '\n')
sudo_uid = os.environ.get('INTERCEPT_SUDO_UID')
sudo_gid = os.environ.get('INTERCEPT_SUDO_GID')
if os.geteuid() == 0 and sudo_uid and sudo_gid:
with contextlib.suppress(OSError, ValueError):
os.chown(path, int(sudo_uid), int(sudo_gid))
def _apply_runtime_observer_defaults(lat: float, lon: float) -> None:
"""Update in-process defaults so refreshed pages use the saved location."""
lat_str = str(lat)
lon_str = str(lon)
os.environ['INTERCEPT_DEFAULT_LAT'] = lat_str
os.environ['INTERCEPT_DEFAULT_LON'] = lon_str
import config
config.DEFAULT_LATITUDE = lat
config.DEFAULT_LONGITUDE = lon
with contextlib.suppress(Exception):
import app as app_module
app_module.DEFAULT_LATITUDE = lat
app_module.DEFAULT_LONGITUDE = lon
with contextlib.suppress(Exception):
from routes import adsb as adsb_routes
adsb_routes.DEFAULT_LATITUDE = lat
adsb_routes.DEFAULT_LONGITUDE = lon
with contextlib.suppress(Exception):
from routes import ais as ais_routes
ais_routes.DEFAULT_LATITUDE = lat
ais_routes.DEFAULT_LONGITUDE = lon
@settings_bp.route('', methods=['GET']) @settings_bp.route('', methods=['GET'])
@@ -92,8 +167,8 @@ def update_single_setting(key: str) -> Response:
return api_error(str(e), 500) return api_error(str(e), 500)
@settings_bp.route('/<key>', methods=['DELETE']) @settings_bp.route('/<key>', methods=['DELETE'])
def delete_single_setting(key: str) -> Response: def delete_single_setting(key: str) -> Response:
"""Delete a setting.""" """Delete a setting."""
try: try:
deleted = delete_setting(key) deleted = delete_setting(key)
@@ -106,7 +181,35 @@ def delete_single_setting(key: str) -> Response:
}), 404 }), 404
except Exception as e: except Exception as e:
logger.error(f"Error deleting setting {key}: {e}") logger.error(f"Error deleting setting {key}: {e}")
return api_error(str(e), 500) return api_error(str(e), 500)
@settings_bp.route('/observer-location', methods=['POST'])
def save_observer_location() -> Response:
"""Persist observer location to .env and refresh in-process defaults."""
data = request.json or {}
try:
lat = validate_latitude(data.get('lat'))
lon = validate_longitude(data.get('lon'))
except ValueError as exc:
return api_error(str(exc), 400)
try:
_write_env_value('INTERCEPT_DEFAULT_LAT', str(lat))
_write_env_value('INTERCEPT_DEFAULT_LON', str(lon))
_apply_runtime_observer_defaults(lat, lon)
return api_success(
data={
'lat': lat,
'lon': lon,
'saved': ['INTERCEPT_DEFAULT_LAT', 'INTERCEPT_DEFAULT_LON'],
},
message='Observer location saved to .env',
)
except Exception as exc:
logger.error(f'Error saving observer location to .env: {exc}')
return api_error(str(exc), 500)
# ============================================================================= # =============================================================================
+15 -13
View File
@@ -1,9 +1,6 @@
// Shared observer location helper for map-based modules. // Shared observer location helper for map-based modules.
// Default: shared location enabled unless explicitly disabled via config. // Default: shared location enabled unless explicitly disabled via config.
window.ObserverLocation = (function() { window.ObserverLocation = (function() {
const DEFAULT_LOCATION = (window.INTERCEPT_DEFAULT_LAT && window.INTERCEPT_DEFAULT_LON)
? { lat: window.INTERCEPT_DEFAULT_LAT, lon: window.INTERCEPT_DEFAULT_LON }
: { lat: 51.5074, lon: -0.1278 };
const SHARED_KEY = 'observerLocation'; const SHARED_KEY = 'observerLocation';
const AIS_KEY = 'ais_observerLocation'; const AIS_KEY = 'ais_observerLocation';
const LEGACY_LAT_KEY = 'observerLat'; const LEGACY_LAT_KEY = 'observerLat';
@@ -21,6 +18,9 @@ window.ObserverLocation = (function() {
return { lat: latNum, lon: lonNum }; return { lat: latNum, lon: lonNum };
} }
const DEFAULT_LOCATION = normalize(window.INTERCEPT_DEFAULT_LAT, window.INTERCEPT_DEFAULT_LON)
|| { lat: 51.5074, lon: -0.1278 };
function parseLocation(raw) { function parseLocation(raw) {
if (!raw) return null; if (!raw) return null;
try { try {
@@ -39,7 +39,7 @@ window.ObserverLocation = (function() {
function readLegacyLatLon() { function readLegacyLatLon() {
const lat = localStorage.getItem(LEGACY_LAT_KEY); const lat = localStorage.getItem(LEGACY_LAT_KEY);
const lon = localStorage.getItem(LEGACY_LON_KEY); const lon = localStorage.getItem(LEGACY_LON_KEY);
if (!lat || !lon) return null; if (lat === null || lon === null) return null;
return normalize(lat, lon); return normalize(lat, lon);
} }
@@ -60,11 +60,12 @@ window.ObserverLocation = (function() {
} }
function setShared(location, options = {}) { function setShared(location, options = {}) {
if (!location) return; const normalized = location ? normalize(location.lat, location.lon) : null;
localStorage.setItem(SHARED_KEY, JSON.stringify(location)); if (!normalized) return;
localStorage.setItem(SHARED_KEY, JSON.stringify(normalized));
if (options.updateLegacy !== false) { if (options.updateLegacy !== false) {
localStorage.setItem(LEGACY_LAT_KEY, location.lat.toString()); localStorage.setItem(LEGACY_LAT_KEY, normalized.lat.toString());
localStorage.setItem(LEGACY_LON_KEY, location.lon.toString()); localStorage.setItem(LEGACY_LON_KEY, normalized.lon.toString());
} }
} }
@@ -84,16 +85,17 @@ window.ObserverLocation = (function() {
} }
function setForModule(moduleKey, location, options = {}) { function setForModule(moduleKey, location, options = {}) {
if (!location) return; const normalized = location ? normalize(location.lat, location.lon) : null;
if (!normalized) return;
if (isSharedEnabled()) { if (isSharedEnabled()) {
setShared(location, options); setShared(normalized, options);
return; return;
} }
if (moduleKey) { if (moduleKey) {
localStorage.setItem(moduleKey, JSON.stringify(location)); localStorage.setItem(moduleKey, JSON.stringify(normalized));
} else if (options.fallbackToLatLon) { } else if (options.fallbackToLatLon) {
localStorage.setItem(LEGACY_LAT_KEY, location.lat.toString()); localStorage.setItem(LEGACY_LAT_KEY, normalized.lat.toString());
localStorage.setItem(LEGACY_LON_KEY, location.lon.toString()); localStorage.setItem(LEGACY_LON_KEY, normalized.lon.toString());
} }
} }
+35 -11
View File
@@ -896,23 +896,26 @@ function loadObserverLocation() {
lon = shared.lon.toString(); lon = shared.lon.toString();
} }
const hasLat = lat !== undefined && lat !== null && lat !== '';
const hasLon = lon !== undefined && lon !== null && lon !== '';
const latInput = document.getElementById('observerLatInput'); const latInput = document.getElementById('observerLatInput');
const lonInput = document.getElementById('observerLonInput'); const lonInput = document.getElementById('observerLonInput');
const currentLatDisplay = document.getElementById('currentLatDisplay'); const currentLatDisplay = document.getElementById('currentLatDisplay');
const currentLonDisplay = document.getElementById('currentLonDisplay'); const currentLonDisplay = document.getElementById('currentLonDisplay');
if (latInput && lat) latInput.value = lat; if (latInput && hasLat) latInput.value = lat;
if (lonInput && lon) lonInput.value = lon; if (lonInput && hasLon) lonInput.value = lon;
if (currentLatDisplay) { if (currentLatDisplay) {
currentLatDisplay.textContent = lat ? parseFloat(lat).toFixed(4) + '°' : 'Not set'; currentLatDisplay.textContent = hasLat ? parseFloat(lat).toFixed(4) + '°' : 'Not set';
} }
if (currentLonDisplay) { if (currentLonDisplay) {
currentLonDisplay.textContent = lon ? parseFloat(lon).toFixed(4) + '°' : 'Not set'; currentLonDisplay.textContent = hasLon ? parseFloat(lon).toFixed(4) + '°' : 'Not set';
} }
// Sync dashboard-specific location keys for backward compatibility // Sync dashboard-specific location keys for backward compatibility
if (lat !== undefined && lat !== null && lat !== '' && lon !== undefined && lon !== null && lon !== '') { if (hasLat && hasLon) {
const locationObj = JSON.stringify({ lat: parseFloat(lat), lon: parseFloat(lon) }); const locationObj = JSON.stringify({ lat: parseFloat(lat), lon: parseFloat(lon) });
if (!localStorage.getItem('observerLocation')) { if (!localStorage.getItem('observerLocation')) {
localStorage.setItem('observerLocation', locationObj); localStorage.setItem('observerLocation', locationObj);
@@ -1011,9 +1014,9 @@ function detectLocationGPS(btn) {
} }
/** /**
* Save observer location to localStorage * Save observer location to localStorage and persist defaults to .env
*/ */
function saveObserverLocation() { async function saveObserverLocation() {
const latInput = document.getElementById('observerLatInput'); const latInput = document.getElementById('observerLatInput');
const lonInput = document.getElementById('observerLonInput'); const lonInput = document.getElementById('observerLonInput');
@@ -1056,19 +1059,40 @@ function saveObserverLocation() {
if (currentLatDisplay) currentLatDisplay.textContent = lat.toFixed(4) + '°'; if (currentLatDisplay) currentLatDisplay.textContent = lat.toFixed(4) + '°';
if (currentLonDisplay) currentLonDisplay.textContent = lon.toFixed(4) + '°'; if (currentLonDisplay) currentLonDisplay.textContent = lon.toFixed(4) + '°';
if (typeof showNotification === 'function') {
showNotification('Location', 'Observer location saved');
}
if (window.observerLocation) { if (window.observerLocation) {
window.observerLocation.lat = lat; window.observerLocation.lat = lat;
window.observerLocation.lon = lon; window.observerLocation.lon = lon;
} }
let notificationMessage = 'Observer location saved';
try {
const response = await fetch('/settings/observer-location', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ lat, lon }),
});
const data = await response.json().catch(() => ({}));
if (!response.ok || data.status === 'error') {
throw new Error(data.message || 'Failed to save observer location to .env');
}
window.INTERCEPT_DEFAULT_LAT = lat;
window.INTERCEPT_DEFAULT_LON = lon;
notificationMessage = 'Observer location saved to settings and .env';
} catch (error) {
notificationMessage = `Observer location saved for this browser, but .env update failed: ${error.message}`;
}
// Refresh SSTV ISS schedule if available // Refresh SSTV ISS schedule if available
if (typeof SSTV !== 'undefined' && typeof SSTV.loadIssSchedule === 'function') { if (typeof SSTV !== 'undefined' && typeof SSTV.loadIssSchedule === 'function') {
SSTV.loadIssSchedule(); SSTV.loadIssSchedule();
} }
if (typeof showNotification === 'function') {
showNotification('Location', notificationMessage);
}
} }
// ============================================================================= // =============================================================================
+20 -15
View File
@@ -3890,7 +3890,11 @@
if (saved) { if (saved) {
try { try {
const parsed = JSON.parse(saved); const parsed = JSON.parse(saved);
if (parsed.lat && parsed.lon) return parsed; const lat = Number(parsed.lat);
const lon = Number(parsed.lon);
if (Number.isFinite(lat) && Number.isFinite(lon)) {
return { lat, lon };
}
} catch (e) { } } catch (e) { }
} }
return { lat: 51.5074, lon: -0.1278 }; return { lat: 51.5074, lon: -0.1278 };
@@ -9857,16 +9861,16 @@
(function _seedAprsLocation() { (function _seedAprsLocation() {
if (typeof ObserverLocation !== 'undefined' && ObserverLocation.getShared) { if (typeof ObserverLocation !== 'undefined' && ObserverLocation.getShared) {
const shared = ObserverLocation.getShared(); const shared = ObserverLocation.getShared();
if (shared && shared.lat && shared.lon) { if (shared && aprsHasValidCoordinates(shared.lat, shared.lon)) {
aprsUserLocation.lat = shared.lat; aprsUserLocation.lat = shared.lat;
aprsUserLocation.lon = shared.lon; aprsUserLocation.lon = shared.lon;
return; return;
} }
} }
// Fallback: read the Jinja-injected defaults directly // Fallback: read the Jinja-injected defaults directly
const lat = window.INTERCEPT_DEFAULT_LAT; const lat = Number(window.INTERCEPT_DEFAULT_LAT);
const lon = window.INTERCEPT_DEFAULT_LON; const lon = Number(window.INTERCEPT_DEFAULT_LON);
if (lat && lon && Number.isFinite(lat) && Number.isFinite(lon)) { if (aprsHasValidCoordinates(lat, lon)) {
aprsUserLocation.lat = lat; aprsUserLocation.lat = lat;
aprsUserLocation.lon = lon; aprsUserLocation.lon = lon;
} }
@@ -10824,25 +10828,26 @@
}); });
function updateLocationFromGps(position) { function updateLocationFromGps(position) {
if (!position || !position.latitude || !position.longitude) { const lat = Number(position && position.latitude);
const lon = Number(position && position.longitude);
const fixQuality = Number(position && position.fix_quality);
if (!Number.isFinite(lat) || !Number.isFinite(lon)) {
return; return;
} }
if (Number.isFinite(fixQuality) && fixQuality < 2) return;
// Update satellite observer location // Update satellite observer location
const satLatInput = document.getElementById('obsLat'); const satLatInput = document.getElementById('obsLat');
const satLonInput = document.getElementById('obsLon'); const satLonInput = document.getElementById('obsLon');
if (satLatInput) satLatInput.value = position.latitude.toFixed(4); if (satLatInput) satLatInput.value = lat.toFixed(4);
if (satLonInput) satLonInput.value = position.longitude.toFixed(4); if (satLonInput) satLonInput.value = lon.toFixed(4);
// Update observerLocation // Update observerLocation
observerLocation.lat = position.latitude; observerLocation.lat = lat;
observerLocation.lon = position.longitude; observerLocation.lon = lon;
if (window.ObserverLocation && ObserverLocation.isSharedEnabled()) {
ObserverLocation.setShared({ lat: position.latitude, lon: position.longitude });
}
// Update APRS user location // Keep live GPS separate from the configured shared observer location.
updateAprsUserLocation(position); updateAprsUserLocation({ latitude: lat, longitude: lon });
} }
function showGpsIndicator(show) { function showGpsIndicator(show) {
+64 -13
View File
@@ -1,9 +1,9 @@
"""Tests for Flask routes and API endpoints.""" """Tests for Flask routes and API endpoints."""
import json import json
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
import pytest import pytest
@pytest.fixture(scope='session') @pytest.fixture(scope='session')
@@ -103,8 +103,8 @@ class TestDependenciesEndpoint:
assert 'modes' in data assert 'modes' in data
class TestSettingsEndpoints: class TestSettingsEndpoints:
"""Tests for settings API endpoints.""" """Tests for settings API endpoints."""
def test_get_settings(self, client): def test_get_settings(self, client):
"""Test getting all settings.""" """Test getting all settings."""
@@ -172,8 +172,8 @@ class TestSettingsEndpoints:
assert data['status'] == 'success' assert data['status'] == 'success'
assert data['value'] == 'updated_value' assert data['value'] == 'updated_value'
def test_delete_setting(self, client): def test_delete_setting(self, client):
"""Test deleting a setting.""" """Test deleting a setting."""
# First create a setting # First create a setting
client.post( client.post(
'/settings', '/settings',
@@ -185,9 +185,60 @@ class TestSettingsEndpoints:
response = client.delete('/settings/delete_me') response = client.delete('/settings/delete_me')
assert response.status_code == 200 assert response.status_code == 200
data = json.loads(response.data) data = json.loads(response.data)
assert data['status'] == 'success' assert data['status'] == 'success'
assert data['deleted'] is True assert data['deleted'] is True
def test_save_observer_location_updates_env_and_runtime_defaults(self, client, monkeypatch, tmp_path):
"""Saving observer location should persist to .env and update in-memory defaults."""
import app as app_module
import config
from routes import adsb as adsb_routes
from routes import ais as ais_routes
from routes import settings as settings_routes
with client.session_transaction() as sess:
sess['logged_in'] = True
env_path = tmp_path / '.env'
monkeypatch.setattr(settings_routes, '_get_env_file_path', lambda: env_path)
response = client.post(
'/settings/observer-location',
data=json.dumps({'lat': 48.0, 'lon': 16.16}),
content_type='application/json'
)
assert response.status_code == 200
data = json.loads(response.data)
assert data['status'] == 'success'
assert data['lat'] == 48.0
assert data['lon'] == 16.16
env_text = env_path.read_text()
assert 'INTERCEPT_DEFAULT_LAT=48.0' in env_text
assert 'INTERCEPT_DEFAULT_LON=16.16' in env_text
assert config.DEFAULT_LATITUDE == 48.0
assert config.DEFAULT_LONGITUDE == 16.16
assert app_module.DEFAULT_LATITUDE == 48.0
assert app_module.DEFAULT_LONGITUDE == 16.16
assert adsb_routes.DEFAULT_LATITUDE == 48.0
assert adsb_routes.DEFAULT_LONGITUDE == 16.16
assert ais_routes.DEFAULT_LATITUDE == 48.0
assert ais_routes.DEFAULT_LONGITUDE == 16.16
def test_save_observer_location_rejects_invalid_values(self, client):
"""Observer location save should validate coordinates."""
with client.session_transaction() as sess:
sess['logged_in'] = True
response = client.post(
'/settings/observer-location',
data=json.dumps({'lat': 200, 'lon': 16.16}),
content_type='application/json'
)
assert response.status_code == 400
class TestCorrelationEndpoints: class TestCorrelationEndpoints: