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."""
from __future__ import annotations
import os
import subprocess
import sys
from flask import Blueprint, Response, jsonify, request
from utils.database import (
from __future__ import annotations
import contextlib
import os
import re
import subprocess
import sys
import threading
from pathlib import Path
from flask import Blueprint, Response, jsonify, request
from utils.database import (
delete_setting,
get_all_settings,
get_correlations,
get_setting,
set_setting,
)
from utils.logging import get_logger
from utils.responses import api_error, api_success
logger = get_logger('intercept.settings')
settings_bp = Blueprint('settings', __name__, url_prefix='/settings')
)
from utils.logging import get_logger
from utils.responses import api_error, api_success
from utils.validation import validate_latitude, validate_longitude
logger = get_logger('intercept.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'])
@@ -92,8 +167,8 @@ def update_single_setting(key: str) -> Response:
return api_error(str(e), 500)
@settings_bp.route('/<key>', methods=['DELETE'])
def delete_single_setting(key: str) -> Response:
@settings_bp.route('/<key>', methods=['DELETE'])
def delete_single_setting(key: str) -> Response:
"""Delete a setting."""
try:
deleted = delete_setting(key)
@@ -106,7 +181,35 @@ def delete_single_setting(key: str) -> Response:
}), 404
except Exception as 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.
// Default: shared location enabled unless explicitly disabled via config.
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 AIS_KEY = 'ais_observerLocation';
const LEGACY_LAT_KEY = 'observerLat';
@@ -21,6 +18,9 @@ window.ObserverLocation = (function() {
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) {
if (!raw) return null;
try {
@@ -39,7 +39,7 @@ window.ObserverLocation = (function() {
function readLegacyLatLon() {
const lat = localStorage.getItem(LEGACY_LAT_KEY);
const lon = localStorage.getItem(LEGACY_LON_KEY);
if (!lat || !lon) return null;
if (lat === null || lon === null) return null;
return normalize(lat, lon);
}
@@ -60,11 +60,12 @@ window.ObserverLocation = (function() {
}
function setShared(location, options = {}) {
if (!location) return;
localStorage.setItem(SHARED_KEY, JSON.stringify(location));
const normalized = location ? normalize(location.lat, location.lon) : null;
if (!normalized) return;
localStorage.setItem(SHARED_KEY, JSON.stringify(normalized));
if (options.updateLegacy !== false) {
localStorage.setItem(LEGACY_LAT_KEY, location.lat.toString());
localStorage.setItem(LEGACY_LON_KEY, location.lon.toString());
localStorage.setItem(LEGACY_LAT_KEY, normalized.lat.toString());
localStorage.setItem(LEGACY_LON_KEY, normalized.lon.toString());
}
}
@@ -84,16 +85,17 @@ window.ObserverLocation = (function() {
}
function setForModule(moduleKey, location, options = {}) {
if (!location) return;
const normalized = location ? normalize(location.lat, location.lon) : null;
if (!normalized) return;
if (isSharedEnabled()) {
setShared(location, options);
setShared(normalized, options);
return;
}
if (moduleKey) {
localStorage.setItem(moduleKey, JSON.stringify(location));
localStorage.setItem(moduleKey, JSON.stringify(normalized));
} else if (options.fallbackToLatLon) {
localStorage.setItem(LEGACY_LAT_KEY, location.lat.toString());
localStorage.setItem(LEGACY_LON_KEY, location.lon.toString());
localStorage.setItem(LEGACY_LAT_KEY, normalized.lat.toString());
localStorage.setItem(LEGACY_LON_KEY, normalized.lon.toString());
}
}
+35 -11
View File
@@ -896,23 +896,26 @@ function loadObserverLocation() {
lon = shared.lon.toString();
}
const hasLat = lat !== undefined && lat !== null && lat !== '';
const hasLon = lon !== undefined && lon !== null && lon !== '';
const latInput = document.getElementById('observerLatInput');
const lonInput = document.getElementById('observerLonInput');
const currentLatDisplay = document.getElementById('currentLatDisplay');
const currentLonDisplay = document.getElementById('currentLonDisplay');
if (latInput && lat) latInput.value = lat;
if (lonInput && lon) lonInput.value = lon;
if (latInput && hasLat) latInput.value = lat;
if (lonInput && hasLon) lonInput.value = lon;
if (currentLatDisplay) {
currentLatDisplay.textContent = lat ? parseFloat(lat).toFixed(4) + '°' : 'Not set';
currentLatDisplay.textContent = hasLat ? parseFloat(lat).toFixed(4) + '°' : 'Not set';
}
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
if (lat !== undefined && lat !== null && lat !== '' && lon !== undefined && lon !== null && lon !== '') {
if (hasLat && hasLon) {
const locationObj = JSON.stringify({ lat: parseFloat(lat), lon: parseFloat(lon) });
if (!localStorage.getItem('observerLocation')) {
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 lonInput = document.getElementById('observerLonInput');
@@ -1056,19 +1059,40 @@ function saveObserverLocation() {
if (currentLatDisplay) currentLatDisplay.textContent = lat.toFixed(4) + '°';
if (currentLonDisplay) currentLonDisplay.textContent = lon.toFixed(4) + '°';
if (typeof showNotification === 'function') {
showNotification('Location', 'Observer location saved');
}
if (window.observerLocation) {
window.observerLocation.lat = lat;
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
if (typeof SSTV !== 'undefined' && typeof SSTV.loadIssSchedule === 'function') {
SSTV.loadIssSchedule();
}
if (typeof showNotification === 'function') {
showNotification('Location', notificationMessage);
}
}
// =============================================================================
+20 -15
View File
@@ -3890,7 +3890,11 @@
if (saved) {
try {
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) { }
}
return { lat: 51.5074, lon: -0.1278 };
@@ -9857,16 +9861,16 @@
(function _seedAprsLocation() {
if (typeof ObserverLocation !== 'undefined' && ObserverLocation.getShared) {
const shared = ObserverLocation.getShared();
if (shared && shared.lat && shared.lon) {
if (shared && aprsHasValidCoordinates(shared.lat, shared.lon)) {
aprsUserLocation.lat = shared.lat;
aprsUserLocation.lon = shared.lon;
return;
}
}
// Fallback: read the Jinja-injected defaults directly
const lat = window.INTERCEPT_DEFAULT_LAT;
const lon = window.INTERCEPT_DEFAULT_LON;
if (lat && lon && Number.isFinite(lat) && Number.isFinite(lon)) {
const lat = Number(window.INTERCEPT_DEFAULT_LAT);
const lon = Number(window.INTERCEPT_DEFAULT_LON);
if (aprsHasValidCoordinates(lat, lon)) {
aprsUserLocation.lat = lat;
aprsUserLocation.lon = lon;
}
@@ -10824,25 +10828,26 @@
});
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;
}
if (Number.isFinite(fixQuality) && fixQuality < 2) return;
// Update satellite observer location
const satLatInput = document.getElementById('obsLat');
const satLonInput = document.getElementById('obsLon');
if (satLatInput) satLatInput.value = position.latitude.toFixed(4);
if (satLonInput) satLonInput.value = position.longitude.toFixed(4);
if (satLatInput) satLatInput.value = lat.toFixed(4);
if (satLonInput) satLonInput.value = lon.toFixed(4);
// Update observerLocation
observerLocation.lat = position.latitude;
observerLocation.lon = position.longitude;
if (window.ObserverLocation && ObserverLocation.isSharedEnabled()) {
ObserverLocation.setShared({ lat: position.latitude, lon: position.longitude });
}
observerLocation.lat = lat;
observerLocation.lon = lon;
// Update APRS user location
updateAprsUserLocation(position);
// Keep live GPS separate from the configured shared observer location.
updateAprsUserLocation({ latitude: lat, longitude: lon });
}
function showGpsIndicator(show) {
+64 -13
View File
@@ -1,9 +1,9 @@
"""Tests for Flask routes and API endpoints."""
import json
from unittest.mock import MagicMock, patch
import pytest
"""Tests for Flask routes and API endpoints."""
import json
from unittest.mock import MagicMock, patch
import pytest
@pytest.fixture(scope='session')
@@ -103,8 +103,8 @@ class TestDependenciesEndpoint:
assert 'modes' in data
class TestSettingsEndpoints:
"""Tests for settings API endpoints."""
class TestSettingsEndpoints:
"""Tests for settings API endpoints."""
def test_get_settings(self, client):
"""Test getting all settings."""
@@ -172,8 +172,8 @@ class TestSettingsEndpoints:
assert data['status'] == 'success'
assert data['value'] == 'updated_value'
def test_delete_setting(self, client):
"""Test deleting a setting."""
def test_delete_setting(self, client):
"""Test deleting a setting."""
# First create a setting
client.post(
'/settings',
@@ -185,9 +185,60 @@ class TestSettingsEndpoints:
response = client.delete('/settings/delete_me')
assert response.status_code == 200
data = json.loads(response.data)
assert data['status'] == 'success'
assert data['deleted'] is True
data = json.loads(response.data)
assert data['status'] == 'success'
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: