mirror of
https://github.com/zvx-echo6/central.git
synced 2026-05-21 18:14:44 +02:00
feat(gui): Leaflet region picker (1b-5) (#19)
* feat(gui): add Leaflet region picker to adapter edit (1b-5)
- Add _region_picker.html template with Leaflet map and editable rectangle
- Add Leaflet 1.9.4 and Leaflet.draw 1.0.4 CDN deps to adapters_edit.html
- Update GET /adapters/{name} to fetch map_tile_url from config.system
- Update POST /adapters/{name} to validate and save region coordinates
- Validation: -90 <= south < north <= 90, -180 <= west < east <= 180
- Region changes flow through to audit log via existing settings capture
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* fix(tests): update adapter tests for region picker mocks
Add region coordinates to form data mocks and system settings rows
to fetchrow.side_effect for tests that re-render on validation errors.
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
---------
Co-authored-by: Ubuntu <zvx@cortex.echo6.co>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
Co-authored-by: Matt Johnson <mj@k7zvx.com>
This commit is contained in:
parent
b87aec9c69
commit
1dbc54e182
5 changed files with 611 additions and 41 deletions
|
|
@ -620,6 +620,13 @@ async def adapters_edit_form(
|
|||
"SELECT alias FROM config.api_keys ORDER BY alias"
|
||||
)
|
||||
|
||||
# Get map tile settings from config.system
|
||||
sys_row = await conn.fetchrow(
|
||||
"SELECT map_tile_url, map_attribution FROM config.system WHERE id = true"
|
||||
)
|
||||
tile_url = sys_row["map_tile_url"] if sys_row else "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||||
tile_attribution = sys_row["map_attribution"] if sys_row else "© OpenStreetMap contributors"
|
||||
|
||||
settings = row["settings"] or {}
|
||||
adapter = {
|
||||
"name": row["name"],
|
||||
|
|
@ -643,6 +650,8 @@ async def adapters_edit_form(
|
|||
"api_keys": [{"alias": k["alias"]} for k in api_keys],
|
||||
"valid_satellites": _get_valid_satellites(),
|
||||
"valid_feeds": sorted(_get_valid_feeds()),
|
||||
"tile_url": tile_url,
|
||||
"tile_attribution": tile_attribution,
|
||||
},
|
||||
)
|
||||
csrf_protect.set_csrf_cookie(signed_token, response)
|
||||
|
|
@ -749,6 +758,39 @@ async def adapters_edit_submit(
|
|||
else:
|
||||
new_settings["feed"] = feed
|
||||
|
||||
# Region validation (applies to all adapters)
|
||||
region_north_str = form.get("region_north", "").strip()
|
||||
region_south_str = form.get("region_south", "").strip()
|
||||
region_east_str = form.get("region_east", "").strip()
|
||||
region_west_str = form.get("region_west", "").strip()
|
||||
|
||||
form_data["region_north"] = region_north_str
|
||||
form_data["region_south"] = region_south_str
|
||||
form_data["region_east"] = region_east_str
|
||||
form_data["region_west"] = region_west_str
|
||||
|
||||
try:
|
||||
region_north = float(region_north_str)
|
||||
region_south = float(region_south_str)
|
||||
region_east = float(region_east_str)
|
||||
region_west = float(region_west_str)
|
||||
|
||||
# Validate latitude bounds
|
||||
if not (-90 <= region_south < region_north <= 90):
|
||||
errors["region"] = "Invalid latitude: south must be less than north, both between -90 and 90"
|
||||
# Validate longitude bounds
|
||||
elif not (-180 <= region_west < region_east <= 180):
|
||||
errors["region"] = "Invalid longitude: west must be less than east, both between -180 and 180"
|
||||
else:
|
||||
new_settings["region"] = {
|
||||
"north": region_north,
|
||||
"south": region_south,
|
||||
"east": region_east,
|
||||
"west": region_west,
|
||||
}
|
||||
except ValueError:
|
||||
errors["region"] = "Region coordinates must be valid numbers"
|
||||
|
||||
# If there are errors, re-render the form
|
||||
if errors:
|
||||
adapter = {
|
||||
|
|
@ -764,6 +806,13 @@ async def adapters_edit_submit(
|
|||
"SELECT alias FROM config.api_keys ORDER BY alias"
|
||||
)
|
||||
|
||||
# Get map tile settings for re-render
|
||||
sys_row = await conn.fetchrow(
|
||||
"SELECT map_tile_url, map_attribution FROM config.system WHERE id = true"
|
||||
)
|
||||
tile_url = sys_row["map_tile_url"] if sys_row else "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||||
tile_attribution = sys_row["map_attribution"] if sys_row else "© OpenStreetMap contributors"
|
||||
|
||||
csrf_token, signed_token = csrf_protect.generate_csrf_tokens()
|
||||
response = templates.TemplateResponse(
|
||||
request=request,
|
||||
|
|
@ -777,6 +826,8 @@ async def adapters_edit_submit(
|
|||
"api_keys": [{"alias": k["alias"]} for k in api_keys],
|
||||
"valid_satellites": _get_valid_satellites(),
|
||||
"valid_feeds": sorted(_get_valid_feeds()),
|
||||
"tile_url": tile_url,
|
||||
"tile_attribution": tile_attribution,
|
||||
},
|
||||
status_code=200,
|
||||
)
|
||||
|
|
|
|||
112
src/central/gui/templates/_region_picker.html
Normal file
112
src/central/gui/templates/_region_picker.html
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
<div id="region-picker-container"
|
||||
data-north="{{ (form_data.region_north if form_data and form_data.region_north else adapter.settings.region.north) if adapter.settings.region else 49.5 }}"
|
||||
data-south="{{ (form_data.region_south if form_data and form_data.region_south else adapter.settings.region.south) if adapter.settings.region else 31.0 }}"
|
||||
data-east="{{ (form_data.region_east if form_data and form_data.region_east else adapter.settings.region.east) if adapter.settings.region else -102.0 }}"
|
||||
data-west="{{ (form_data.region_west if form_data and form_data.region_west else adapter.settings.region.west) if adapter.settings.region else -124.5 }}"
|
||||
data-tile-url="{{ tile_url }}"
|
||||
data-tile-attr="{{ tile_attribution }}">
|
||||
|
||||
<div id="region-map" style="height: 400px; margin-bottom: 1rem;"></div>
|
||||
|
||||
<div class="grid">
|
||||
<div>
|
||||
<label for="region_north">North</label>
|
||||
<input type="number" id="region_north" name="region_north" step="0.0001" min="-90" max="90" readonly
|
||||
value="{{ (form_data.region_north if form_data and form_data.region_north else adapter.settings.region.north) if adapter.settings.region else 49.5 }}">
|
||||
</div>
|
||||
<div>
|
||||
<label for="region_south">South</label>
|
||||
<input type="number" id="region_south" name="region_south" step="0.0001" min="-90" max="90" readonly
|
||||
value="{{ (form_data.region_south if form_data and form_data.region_south else adapter.settings.region.south) if adapter.settings.region else 31.0 }}">
|
||||
</div>
|
||||
<div>
|
||||
<label for="region_east">East</label>
|
||||
<input type="number" id="region_east" name="region_east" step="0.0001" min="-180" max="180" readonly
|
||||
value="{{ (form_data.region_east if form_data and form_data.region_east else adapter.settings.region.east) if adapter.settings.region else -102.0 }}">
|
||||
</div>
|
||||
<div>
|
||||
<label for="region_west">West</label>
|
||||
<input type="number" id="region_west" name="region_west" step="0.0001" min="-180" max="180" readonly
|
||||
value="{{ (form_data.region_west if form_data and form_data.region_west else adapter.settings.region.west) if adapter.settings.region else -124.5 }}">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if errors and errors.region %}
|
||||
<small style="color: var(--pico-color-red-500);">{{ errors.region }}</small>
|
||||
{% endif %}
|
||||
|
||||
<button type="button" id="region-reset-btn" class="outline secondary">Reset to Saved</button>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function() {
|
||||
const container = document.getElementById('region-picker-container');
|
||||
const savedNorth = parseFloat(container.dataset.north);
|
||||
const savedSouth = parseFloat(container.dataset.south);
|
||||
const savedEast = parseFloat(container.dataset.east);
|
||||
const savedWest = parseFloat(container.dataset.west);
|
||||
const tileUrl = container.dataset.tileUrl || 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png';
|
||||
const tileAttr = container.dataset.tileAttr || '© OpenStreetMap contributors';
|
||||
|
||||
// Initialize map centered on the bbox
|
||||
const centerLat = (savedNorth + savedSouth) / 2;
|
||||
const centerLng = (savedEast + savedWest) / 2;
|
||||
const map = L.map('region-map').setView([centerLat, centerLng], 5);
|
||||
|
||||
// Add tile layer
|
||||
L.tileLayer(tileUrl, {
|
||||
attribution: tileAttr,
|
||||
maxZoom: 18
|
||||
}).addTo(map);
|
||||
|
||||
// Create initial rectangle
|
||||
const bounds = L.latLngBounds(
|
||||
L.latLng(savedSouth, savedWest),
|
||||
L.latLng(savedNorth, savedEast)
|
||||
);
|
||||
|
||||
// Fit map to bounds
|
||||
map.fitBounds(bounds.pad(0.1));
|
||||
|
||||
// Create editable rectangle
|
||||
const rectangle = L.rectangle(bounds, {
|
||||
color: '#3388ff',
|
||||
weight: 2,
|
||||
fillOpacity: 0.2
|
||||
}).addTo(map);
|
||||
|
||||
// Make rectangle editable
|
||||
rectangle.editing.enable();
|
||||
|
||||
// Input elements
|
||||
const northInput = document.getElementById('region_north');
|
||||
const southInput = document.getElementById('region_south');
|
||||
const eastInput = document.getElementById('region_east');
|
||||
const westInput = document.getElementById('region_west');
|
||||
|
||||
// Update inputs from rectangle bounds
|
||||
function updateInputs() {
|
||||
const b = rectangle.getBounds();
|
||||
northInput.value = b.getNorth().toFixed(4);
|
||||
southInput.value = b.getSouth().toFixed(4);
|
||||
eastInput.value = b.getEast().toFixed(4);
|
||||
westInput.value = b.getWest().toFixed(4);
|
||||
}
|
||||
|
||||
// Listen for rectangle edit events
|
||||
rectangle.on('edit', updateInputs);
|
||||
|
||||
// Reset button
|
||||
document.getElementById('region-reset-btn').addEventListener('click', function() {
|
||||
const originalBounds = L.latLngBounds(
|
||||
L.latLng(savedSouth, savedWest),
|
||||
L.latLng(savedNorth, savedEast)
|
||||
);
|
||||
rectangle.setBounds(originalBounds);
|
||||
updateInputs();
|
||||
});
|
||||
|
||||
// Initial update
|
||||
updateInputs();
|
||||
})();
|
||||
</script>
|
||||
|
|
@ -2,6 +2,13 @@
|
|||
|
||||
{% block title %}Central — Edit {{ adapter.name }}{% endblock %}
|
||||
|
||||
{% block head %}
|
||||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY=" crossorigin="">
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/leaflet.draw/1.0.4/leaflet.draw.css" integrity="sha512-gc3xjCmIy673V6MyOAZhIW93xhM9ei1I+gLbmFjUHIjocENRsLX/QUE1htk5q1XV2D/iie/VQ8DXI6Uj8GB1Og==" crossorigin="anonymous">
|
||||
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js" integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo=" crossorigin=""></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/leaflet.draw/1.0.4/leaflet.draw.js" integrity="sha512-ozq8xQKq6urvuU6jNgkfqAmT7jKN2XumbrX1JiB3TnF7tI48DPI4Ber9dLJ0ikXiRg9G9Vl2jXwqjZ5LDGQ3g==" crossorigin="anonymous"></script>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h1>Edit Adapter: {{ adapter.name }}</h1>
|
||||
|
||||
|
|
@ -29,18 +36,8 @@
|
|||
</fieldset>
|
||||
|
||||
<fieldset>
|
||||
<legend>Region (read-only)</legend>
|
||||
{% if adapter.settings.region %}
|
||||
<p>
|
||||
<strong>North:</strong> {{ adapter.settings.region.north }}<br>
|
||||
<strong>South:</strong> {{ adapter.settings.region.south }}<br>
|
||||
<strong>East:</strong> {{ adapter.settings.region.east }}<br>
|
||||
<strong>West:</strong> {{ adapter.settings.region.west }}
|
||||
</p>
|
||||
{% else %}
|
||||
<p>No region configured.</p>
|
||||
{% endif %}
|
||||
<small>Region editing comes in 1b-5.</small>
|
||||
<legend>Region</legend>
|
||||
{% include "_region_picker.html" %}
|
||||
</fieldset>
|
||||
|
||||
<button type="submit">Save Changes</button>
|
||||
|
|
|
|||
|
|
@ -84,14 +84,17 @@ class TestAdaptersEditForm:
|
|||
mock_request.state.operator = MagicMock(id=1, username="testop")
|
||||
|
||||
mock_conn = AsyncMock()
|
||||
mock_conn.fetchrow.return_value = {
|
||||
mock_conn.fetchrow.side_effect = [
|
||||
{
|
||||
"name": "nws",
|
||||
"enabled": True,
|
||||
"cadence_s": 60,
|
||||
"settings": {"contact_email": "test@example.com", "region": {"north": 49, "south": 24, "east": -66, "west": -125}},
|
||||
"paused_at": None,
|
||||
"updated_at": None,
|
||||
}
|
||||
},
|
||||
{"map_tile_url": "https://tile.example.com/{z}/{x}/{y}.png", "map_attribution": "Test"},
|
||||
]
|
||||
mock_conn.fetch.return_value = [] # No API keys
|
||||
|
||||
mock_pool = MagicMock()
|
||||
|
|
@ -156,6 +159,10 @@ class TestAdaptersEditSubmit:
|
|||
mock_form.get.side_effect = lambda k, d="": {
|
||||
"cadence_s": "120",
|
||||
"contact_email": "new@example.com",
|
||||
"region_north": "49.0",
|
||||
"region_south": "24.0",
|
||||
"region_east": "-66.0",
|
||||
"region_west": "-125.0",
|
||||
}.get(k, d)
|
||||
mock_form.getlist.return_value = []
|
||||
mock_form.__contains__ = lambda self, k: k == "enabled"
|
||||
|
|
@ -166,7 +173,7 @@ class TestAdaptersEditSubmit:
|
|||
"name": "nws",
|
||||
"enabled": True,
|
||||
"cadence_s": 60,
|
||||
"settings": {"contact_email": "old@example.com"},
|
||||
"settings": {"contact_email": "old@example.com", "region": {"north": 49, "south": 24, "east": -66, "west": -125}},
|
||||
"paused_at": None,
|
||||
"updated_at": None,
|
||||
}
|
||||
|
|
@ -200,20 +207,27 @@ class TestAdaptersEditSubmit:
|
|||
mock_form.get.side_effect = lambda k, d="": {
|
||||
"cadence_s": "30",
|
||||
"contact_email": "test@example.com",
|
||||
"region_north": "49.0",
|
||||
"region_south": "24.0",
|
||||
"region_east": "-66.0",
|
||||
"region_west": "-125.0",
|
||||
}.get(k, d)
|
||||
mock_form.getlist.return_value = []
|
||||
mock_form.__contains__ = lambda self, k: k == "enabled"
|
||||
mock_request.form = AsyncMock(return_value=mock_form)
|
||||
|
||||
mock_conn = AsyncMock()
|
||||
mock_conn.fetchrow.return_value = {
|
||||
mock_conn.fetchrow.side_effect = [
|
||||
{
|
||||
"name": "nws",
|
||||
"enabled": True,
|
||||
"cadence_s": 60,
|
||||
"settings": {"contact_email": "test@example.com"},
|
||||
"settings": {"contact_email": "test@example.com", "region": {"north": 49, "south": 24, "east": -66, "west": -125}},
|
||||
"paused_at": None,
|
||||
"updated_at": None,
|
||||
}
|
||||
},
|
||||
{"map_tile_url": None, "map_attribution": None}, # system settings for re-render
|
||||
]
|
||||
mock_conn.fetch.return_value = []
|
||||
|
||||
mock_pool = MagicMock()
|
||||
|
|
@ -252,6 +266,10 @@ class TestAdaptersEditSubmit:
|
|||
mock_form.get.side_effect = lambda k, d="": {
|
||||
"cadence_s": "300",
|
||||
"api_key_alias": "nonexistent_key",
|
||||
"region_north": "49.5",
|
||||
"region_south": "31.0",
|
||||
"region_east": "-102.0",
|
||||
"region_west": "-124.5",
|
||||
}.get(k, d)
|
||||
mock_form.getlist.return_value = ["VIIRS_SNPP_NRT"]
|
||||
mock_form.__contains__ = lambda self, k: k == "enabled"
|
||||
|
|
@ -263,11 +281,12 @@ class TestAdaptersEditSubmit:
|
|||
"name": "firms",
|
||||
"enabled": True,
|
||||
"cadence_s": 300,
|
||||
"settings": {"api_key_alias": "firms", "satellites": ["VIIRS_SNPP_NRT"]},
|
||||
"settings": {"api_key_alias": "firms", "satellites": ["VIIRS_SNPP_NRT"], "region": {"north": 49.5, "south": 31.0, "east": -102.0, "west": -124.5}},
|
||||
"paused_at": None,
|
||||
"updated_at": None,
|
||||
},
|
||||
None, # Second call: check api_key exists - returns None
|
||||
{"map_tile_url": None, "map_attribution": None}, # system settings for re-render
|
||||
]
|
||||
mock_conn.fetch.return_value = []
|
||||
|
||||
|
|
@ -306,20 +325,27 @@ class TestAdaptersEditSubmit:
|
|||
mock_form.get.side_effect = lambda k, d="": {
|
||||
"cadence_s": "120",
|
||||
"feed": "invalid_feed",
|
||||
"region_north": "49.0",
|
||||
"region_south": "24.0",
|
||||
"region_east": "-66.0",
|
||||
"region_west": "-125.0",
|
||||
}.get(k, d)
|
||||
mock_form.getlist.return_value = []
|
||||
mock_form.__contains__ = lambda self, k: k == "enabled"
|
||||
mock_request.form = AsyncMock(return_value=mock_form)
|
||||
|
||||
mock_conn = AsyncMock()
|
||||
mock_conn.fetchrow.return_value = {
|
||||
mock_conn.fetchrow.side_effect = [
|
||||
{
|
||||
"name": "usgs_quake",
|
||||
"enabled": True,
|
||||
"cadence_s": 120,
|
||||
"settings": {"feed": "all_hour"},
|
||||
"settings": {"feed": "all_hour", "region": {"north": 49, "south": 24, "east": -66, "west": -125}},
|
||||
"paused_at": None,
|
||||
"updated_at": None,
|
||||
}
|
||||
},
|
||||
{"map_tile_url": None, "map_attribution": None}, # system settings for re-render
|
||||
]
|
||||
mock_conn.fetch.return_value = []
|
||||
|
||||
mock_pool = MagicMock()
|
||||
|
|
@ -360,6 +386,10 @@ class TestAdaptersAudit:
|
|||
mock_form.get.side_effect = lambda k, d="": {
|
||||
"cadence_s": "120",
|
||||
"contact_email": "new@example.com",
|
||||
"region_north": "49.0",
|
||||
"region_south": "24.0",
|
||||
"region_east": "-66.0",
|
||||
"region_west": "-125.0",
|
||||
}.get(k, d)
|
||||
mock_form.getlist.return_value = []
|
||||
mock_form.__contains__ = lambda self, k: k == "enabled"
|
||||
|
|
@ -370,7 +400,7 @@ class TestAdaptersAudit:
|
|||
"name": "nws",
|
||||
"enabled": True,
|
||||
"cadence_s": 60,
|
||||
"settings": {"contact_email": "old@example.com"},
|
||||
"settings": {"contact_email": "old@example.com", "region": {"north": 49, "south": 24, "east": -66, "west": -125}},
|
||||
"paused_at": None,
|
||||
"updated_at": None,
|
||||
}
|
||||
|
|
@ -422,6 +452,10 @@ class TestAdaptersJsonbRegression:
|
|||
mock_form.get.side_effect = lambda k, d="": {
|
||||
"cadence_s": "120",
|
||||
"contact_email": "test@example.com",
|
||||
"region_north": "49.0",
|
||||
"region_south": "24.0",
|
||||
"region_east": "-66.0",
|
||||
"region_west": "-125.0",
|
||||
}.get(k, d)
|
||||
mock_form.getlist.return_value = []
|
||||
mock_form.__contains__ = lambda self, k: k == "enabled"
|
||||
|
|
@ -432,7 +466,7 @@ class TestAdaptersJsonbRegression:
|
|||
"name": "nws",
|
||||
"enabled": True,
|
||||
"cadence_s": 60,
|
||||
"settings": {"contact_email": "old@example.com"}, # dict, as asyncpg returns
|
||||
"settings": {"contact_email": "old@example.com", "region": {"north": 49, "south": 24, "east": -66, "west": -125}}, # dict, as asyncpg returns
|
||||
"paused_at": None,
|
||||
"updated_at": None,
|
||||
}
|
||||
|
|
@ -471,6 +505,10 @@ class TestAdaptersJsonbRegression:
|
|||
mock_form.get.side_effect = lambda k, d="": {
|
||||
"cadence_s": "120",
|
||||
"contact_email": "new@example.com",
|
||||
"region_north": "49.0",
|
||||
"region_south": "24.0",
|
||||
"region_east": "-66.0",
|
||||
"region_west": "-125.0",
|
||||
}.get(k, d)
|
||||
mock_form.getlist.return_value = []
|
||||
mock_form.__contains__ = lambda self, k: k == "enabled"
|
||||
|
|
@ -481,7 +519,7 @@ class TestAdaptersJsonbRegression:
|
|||
"name": "nws",
|
||||
"enabled": True,
|
||||
"cadence_s": 60,
|
||||
"settings": {"contact_email": "old@example.com"}, # dict
|
||||
"settings": {"contact_email": "old@example.com", "region": {"north": 49, "south": 24, "east": -66, "west": -125}}, # dict
|
||||
"paused_at": None,
|
||||
"updated_at": None,
|
||||
}
|
||||
|
|
|
|||
372
tests/test_region_picker.py
Normal file
372
tests/test_region_picker.py
Normal file
|
|
@ -0,0 +1,372 @@
|
|||
"""Tests for region picker functionality."""
|
||||
|
||||
import json
|
||||
import os
|
||||
from unittest.mock import MagicMock, AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
os.environ.setdefault("CENTRAL_DB_DSN", "postgresql://test:test@localhost/test")
|
||||
os.environ.setdefault("CENTRAL_CSRF_SECRET", "testsecret12345678901234567890ab")
|
||||
os.environ.setdefault("CENTRAL_NATS_URL", "nats://localhost:4222")
|
||||
|
||||
|
||||
class TestRegionPickerInTemplate:
|
||||
"""Test region picker is included in adapter edit page."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_adapters_firms_includes_map_div(self):
|
||||
"""GET /adapters/firms includes the map div with tile URL."""
|
||||
from central.gui.routes import adapters_edit_form
|
||||
|
||||
mock_request = MagicMock()
|
||||
mock_request.state.operator = MagicMock(id=1, username="testop")
|
||||
|
||||
mock_conn = AsyncMock()
|
||||
mock_conn.fetchrow.side_effect = [
|
||||
{ # Adapter row
|
||||
"name": "firms",
|
||||
"enabled": True,
|
||||
"cadence_s": 300,
|
||||
"settings": {
|
||||
"api_key_alias": "firms",
|
||||
"satellites": ["VIIRS_SNPP_NRT"],
|
||||
"region": {"north": 49.5, "south": 31.0, "east": -102.0, "west": -124.5}
|
||||
},
|
||||
"paused_at": None,
|
||||
"updated_at": None,
|
||||
},
|
||||
{ # System settings row
|
||||
"map_tile_url": "https://tile.example.com/{z}/{x}/{y}.png",
|
||||
"map_attribution": "Test Attribution",
|
||||
},
|
||||
]
|
||||
mock_conn.fetch.return_value = [{"alias": "firms"}]
|
||||
|
||||
mock_pool = MagicMock()
|
||||
mock_pool.acquire.return_value.__aenter__ = AsyncMock(return_value=mock_conn)
|
||||
mock_pool.acquire.return_value.__aexit__ = AsyncMock(return_value=None)
|
||||
|
||||
mock_templates = MagicMock()
|
||||
mock_response = MagicMock()
|
||||
mock_templates.TemplateResponse.return_value = mock_response
|
||||
|
||||
mock_csrf = MagicMock()
|
||||
mock_csrf.generate_csrf_tokens.return_value = ("token", "signed")
|
||||
mock_csrf.set_csrf_cookie = MagicMock()
|
||||
|
||||
with patch("central.gui.routes._get_templates", return_value=mock_templates):
|
||||
with patch("central.gui.routes.get_pool", return_value=mock_pool):
|
||||
result = await adapters_edit_form(mock_request, "firms", mock_csrf)
|
||||
|
||||
call_args = mock_templates.TemplateResponse.call_args
|
||||
context = call_args.kwargs.get("context", call_args[1].get("context"))
|
||||
|
||||
# Should include tile URL from config.system
|
||||
assert context["tile_url"] == "https://tile.example.com/{z}/{x}/{y}.png"
|
||||
assert context["tile_attribution"] == "Test Attribution"
|
||||
|
||||
|
||||
class TestRegionValidation:
|
||||
"""Test region coordinate validation in POST handler."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_valid_region_updates_settings(self):
|
||||
"""POST with valid bbox updates settings.region."""
|
||||
from central.gui.routes import adapters_edit_submit
|
||||
|
||||
mock_request = MagicMock()
|
||||
mock_request.state.operator = MagicMock(id=1, username="testop")
|
||||
|
||||
mock_form = MagicMock()
|
||||
mock_form.get.side_effect = lambda k, d="": {
|
||||
"cadence_s": "300",
|
||||
"api_key_alias": "firms",
|
||||
"region_north": "45.0",
|
||||
"region_south": "35.0",
|
||||
"region_east": "-100.0",
|
||||
"region_west": "-120.0",
|
||||
}.get(k, d)
|
||||
mock_form.getlist.return_value = ["VIIRS_SNPP_NRT"]
|
||||
mock_form.__contains__ = lambda self, k: k == "enabled"
|
||||
mock_request.form = AsyncMock(return_value=mock_form)
|
||||
|
||||
mock_conn = AsyncMock()
|
||||
mock_conn.fetchrow.side_effect = [
|
||||
{ # Adapter row
|
||||
"name": "firms",
|
||||
"enabled": True,
|
||||
"cadence_s": 300,
|
||||
"settings": {"api_key_alias": "firms", "region": {"north": 49.5, "south": 31.0, "east": -102.0, "west": -124.5}},
|
||||
"paused_at": None,
|
||||
"updated_at": None,
|
||||
},
|
||||
{"id": 1}, # api_key exists check
|
||||
]
|
||||
mock_conn.execute = AsyncMock()
|
||||
|
||||
mock_pool = MagicMock()
|
||||
mock_pool.acquire.return_value.__aenter__ = AsyncMock(return_value=mock_conn)
|
||||
mock_pool.acquire.return_value.__aexit__ = AsyncMock(return_value=None)
|
||||
|
||||
mock_csrf = MagicMock()
|
||||
mock_csrf.validate_csrf = AsyncMock()
|
||||
|
||||
captured_settings = {}
|
||||
|
||||
async def capture_execute(query, *args):
|
||||
if "UPDATE config.adapters" in query:
|
||||
captured_settings["settings"] = args[2]
|
||||
|
||||
mock_conn.execute.side_effect = capture_execute
|
||||
|
||||
with patch("central.gui.routes.get_pool", return_value=mock_pool):
|
||||
with patch("central.gui.routes.write_audit", new_callable=AsyncMock):
|
||||
result = await adapters_edit_submit(mock_request, "firms", mock_csrf)
|
||||
|
||||
assert result.status_code == 302
|
||||
assert captured_settings["settings"]["region"]["north"] == 45.0
|
||||
assert captured_settings["settings"]["region"]["south"] == 35.0
|
||||
assert captured_settings["settings"]["region"]["east"] == -100.0
|
||||
assert captured_settings["settings"]["region"]["west"] == -120.0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_north_less_than_south_shows_error(self):
|
||||
"""POST with north <= south shows validation error."""
|
||||
from central.gui.routes import adapters_edit_submit
|
||||
|
||||
mock_request = MagicMock()
|
||||
mock_request.state.operator = MagicMock(id=1, username="testop")
|
||||
|
||||
mock_form = MagicMock()
|
||||
mock_form.get.side_effect = lambda k, d="": {
|
||||
"cadence_s": "300",
|
||||
"api_key_alias": "firms",
|
||||
"region_north": "30.0", # Less than south!
|
||||
"region_south": "35.0",
|
||||
"region_east": "-100.0",
|
||||
"region_west": "-120.0",
|
||||
}.get(k, d)
|
||||
mock_form.getlist.return_value = ["VIIRS_SNPP_NRT"]
|
||||
mock_form.__contains__ = lambda self, k: k == "enabled"
|
||||
mock_request.form = AsyncMock(return_value=mock_form)
|
||||
|
||||
mock_conn = AsyncMock()
|
||||
mock_conn.fetchrow.side_effect = [
|
||||
{
|
||||
"name": "firms",
|
||||
"enabled": True,
|
||||
"cadence_s": 300,
|
||||
"settings": {"api_key_alias": "firms", "region": {"north": 49.5, "south": 31.0, "east": -102.0, "west": -124.5}},
|
||||
"paused_at": None,
|
||||
"updated_at": None,
|
||||
},
|
||||
{"id": 1}, # api_key exists
|
||||
{"map_tile_url": None, "map_attribution": None}, # system settings for re-render
|
||||
]
|
||||
mock_conn.fetch.return_value = [{"alias": "firms"}]
|
||||
|
||||
mock_pool = MagicMock()
|
||||
mock_pool.acquire.return_value.__aenter__ = AsyncMock(return_value=mock_conn)
|
||||
mock_pool.acquire.return_value.__aexit__ = AsyncMock(return_value=None)
|
||||
|
||||
mock_templates = MagicMock()
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 200
|
||||
mock_templates.TemplateResponse.return_value = mock_response
|
||||
|
||||
mock_csrf = MagicMock()
|
||||
mock_csrf.validate_csrf = AsyncMock()
|
||||
mock_csrf.generate_csrf_tokens.return_value = ("token", "signed")
|
||||
mock_csrf.set_csrf_cookie = MagicMock()
|
||||
|
||||
with patch("central.gui.routes._get_templates", return_value=mock_templates):
|
||||
with patch("central.gui.routes.get_pool", return_value=mock_pool):
|
||||
result = await adapters_edit_submit(mock_request, "firms", mock_csrf)
|
||||
|
||||
call_args = mock_templates.TemplateResponse.call_args
|
||||
context = call_args.kwargs.get("context", call_args[1].get("context"))
|
||||
assert "region" in context["errors"]
|
||||
assert "latitude" in context["errors"]["region"].lower()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_west_greater_than_east_shows_error(self):
|
||||
"""POST with west >= east shows validation error."""
|
||||
from central.gui.routes import adapters_edit_submit
|
||||
|
||||
mock_request = MagicMock()
|
||||
mock_request.state.operator = MagicMock(id=1, username="testop")
|
||||
|
||||
mock_form = MagicMock()
|
||||
mock_form.get.side_effect = lambda k, d="": {
|
||||
"cadence_s": "300",
|
||||
"api_key_alias": "firms",
|
||||
"region_north": "45.0",
|
||||
"region_south": "35.0",
|
||||
"region_east": "-130.0", # Less than west!
|
||||
"region_west": "-120.0",
|
||||
}.get(k, d)
|
||||
mock_form.getlist.return_value = ["VIIRS_SNPP_NRT"]
|
||||
mock_form.__contains__ = lambda self, k: k == "enabled"
|
||||
mock_request.form = AsyncMock(return_value=mock_form)
|
||||
|
||||
mock_conn = AsyncMock()
|
||||
mock_conn.fetchrow.side_effect = [
|
||||
{
|
||||
"name": "firms",
|
||||
"enabled": True,
|
||||
"cadence_s": 300,
|
||||
"settings": {"api_key_alias": "firms", "region": {"north": 49.5, "south": 31.0, "east": -102.0, "west": -124.5}},
|
||||
"paused_at": None,
|
||||
"updated_at": None,
|
||||
},
|
||||
{"id": 1},
|
||||
{"map_tile_url": None, "map_attribution": None},
|
||||
]
|
||||
mock_conn.fetch.return_value = [{"alias": "firms"}]
|
||||
|
||||
mock_pool = MagicMock()
|
||||
mock_pool.acquire.return_value.__aenter__ = AsyncMock(return_value=mock_conn)
|
||||
mock_pool.acquire.return_value.__aexit__ = AsyncMock(return_value=None)
|
||||
|
||||
mock_templates = MagicMock()
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 200
|
||||
mock_templates.TemplateResponse.return_value = mock_response
|
||||
|
||||
mock_csrf = MagicMock()
|
||||
mock_csrf.validate_csrf = AsyncMock()
|
||||
mock_csrf.generate_csrf_tokens.return_value = ("token", "signed")
|
||||
mock_csrf.set_csrf_cookie = MagicMock()
|
||||
|
||||
with patch("central.gui.routes._get_templates", return_value=mock_templates):
|
||||
with patch("central.gui.routes.get_pool", return_value=mock_pool):
|
||||
result = await adapters_edit_submit(mock_request, "firms", mock_csrf)
|
||||
|
||||
call_args = mock_templates.TemplateResponse.call_args
|
||||
context = call_args.kwargs.get("context", call_args[1].get("context"))
|
||||
assert "region" in context["errors"]
|
||||
assert "longitude" in context["errors"]["region"].lower()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_latitude_out_of_bounds_shows_error(self):
|
||||
"""POST with lat > 90 shows validation error."""
|
||||
from central.gui.routes import adapters_edit_submit
|
||||
|
||||
mock_request = MagicMock()
|
||||
mock_request.state.operator = MagicMock(id=1, username="testop")
|
||||
|
||||
mock_form = MagicMock()
|
||||
mock_form.get.side_effect = lambda k, d="": {
|
||||
"cadence_s": "300",
|
||||
"api_key_alias": "firms",
|
||||
"region_north": "95.0", # > 90!
|
||||
"region_south": "35.0",
|
||||
"region_east": "-100.0",
|
||||
"region_west": "-120.0",
|
||||
}.get(k, d)
|
||||
mock_form.getlist.return_value = ["VIIRS_SNPP_NRT"]
|
||||
mock_form.__contains__ = lambda self, k: k == "enabled"
|
||||
mock_request.form = AsyncMock(return_value=mock_form)
|
||||
|
||||
mock_conn = AsyncMock()
|
||||
mock_conn.fetchrow.side_effect = [
|
||||
{
|
||||
"name": "firms",
|
||||
"enabled": True,
|
||||
"cadence_s": 300,
|
||||
"settings": {"api_key_alias": "firms", "region": {"north": 49.5, "south": 31.0, "east": -102.0, "west": -124.5}},
|
||||
"paused_at": None,
|
||||
"updated_at": None,
|
||||
},
|
||||
{"id": 1},
|
||||
{"map_tile_url": None, "map_attribution": None},
|
||||
]
|
||||
mock_conn.fetch.return_value = [{"alias": "firms"}]
|
||||
|
||||
mock_pool = MagicMock()
|
||||
mock_pool.acquire.return_value.__aenter__ = AsyncMock(return_value=mock_conn)
|
||||
mock_pool.acquire.return_value.__aexit__ = AsyncMock(return_value=None)
|
||||
|
||||
mock_templates = MagicMock()
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 200
|
||||
mock_templates.TemplateResponse.return_value = mock_response
|
||||
|
||||
mock_csrf = MagicMock()
|
||||
mock_csrf.validate_csrf = AsyncMock()
|
||||
mock_csrf.generate_csrf_tokens.return_value = ("token", "signed")
|
||||
mock_csrf.set_csrf_cookie = MagicMock()
|
||||
|
||||
with patch("central.gui.routes._get_templates", return_value=mock_templates):
|
||||
with patch("central.gui.routes.get_pool", return_value=mock_pool):
|
||||
result = await adapters_edit_submit(mock_request, "firms", mock_csrf)
|
||||
|
||||
call_args = mock_templates.TemplateResponse.call_args
|
||||
context = call_args.kwargs.get("context", call_args[1].get("context"))
|
||||
assert "region" in context["errors"]
|
||||
|
||||
|
||||
class TestRegionAuditLog:
|
||||
"""Test region changes are captured in audit log."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_audit_captures_region_change(self):
|
||||
"""Audit log captures region change in before/after settings."""
|
||||
from central.gui.routes import adapters_edit_submit
|
||||
|
||||
mock_request = MagicMock()
|
||||
mock_request.state.operator = MagicMock(id=1, username="testop")
|
||||
|
||||
mock_form = MagicMock()
|
||||
mock_form.get.side_effect = lambda k, d="": {
|
||||
"cadence_s": "300",
|
||||
"api_key_alias": "firms",
|
||||
"region_north": "45.0",
|
||||
"region_south": "35.0",
|
||||
"region_east": "-100.0",
|
||||
"region_west": "-120.0",
|
||||
}.get(k, d)
|
||||
mock_form.getlist.return_value = ["VIIRS_SNPP_NRT"]
|
||||
mock_form.__contains__ = lambda self, k: k == "enabled"
|
||||
mock_request.form = AsyncMock(return_value=mock_form)
|
||||
|
||||
mock_conn = AsyncMock()
|
||||
mock_conn.fetchrow.side_effect = [
|
||||
{
|
||||
"name": "firms",
|
||||
"enabled": True,
|
||||
"cadence_s": 300,
|
||||
"settings": {
|
||||
"api_key_alias": "firms",
|
||||
"region": {"north": 49.5, "south": 31.0, "east": -102.0, "west": -124.5}
|
||||
},
|
||||
"paused_at": None,
|
||||
"updated_at": None,
|
||||
},
|
||||
{"id": 1},
|
||||
]
|
||||
mock_conn.execute = AsyncMock()
|
||||
|
||||
mock_pool = MagicMock()
|
||||
mock_pool.acquire.return_value.__aenter__ = AsyncMock(return_value=mock_conn)
|
||||
mock_pool.acquire.return_value.__aexit__ = AsyncMock(return_value=None)
|
||||
|
||||
mock_csrf = MagicMock()
|
||||
mock_csrf.validate_csrf = AsyncMock()
|
||||
|
||||
captured_audit = {}
|
||||
|
||||
async def capture_audit(conn, action, operator_id=None, target=None, before=None, after=None):
|
||||
captured_audit["before"] = before
|
||||
captured_audit["after"] = after
|
||||
|
||||
with patch("central.gui.routes.get_pool", return_value=mock_pool):
|
||||
with patch("central.gui.routes.write_audit", side_effect=capture_audit):
|
||||
result = await adapters_edit_submit(mock_request, "firms", mock_csrf)
|
||||
|
||||
# Before should have old region
|
||||
assert captured_audit["before"]["settings"]["region"]["north"] == 49.5
|
||||
# After should have new region
|
||||
assert captured_audit["after"]["settings"]["region"]["north"] == 45.0
|
||||
assert captured_audit["after"]["settings"]["region"]["south"] == 35.0
|
||||
Loading…
Add table
Add a link
Reference in a new issue