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:
malice 2026-05-17 16:53:27 -06:00 committed by GitHub
commit 1dbc54e182
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 611 additions and 41 deletions

View file

@ -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 "&copy; 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 "&copy; 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,
)

View 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 || '&copy; 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>

View file

@ -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>