mirror of
https://github.com/smittix/intercept.git
synced 2026-06-08 06:01:56 -07:00
Fix observer location persistence and APRS defaults
This commit is contained in:
+122
-19
@@ -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)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user