Compare commits

...

4 Commits

Author SHA1 Message Date
Ian Renton
471c487132 Allow adding the DX grid when spotting #71 2025-11-01 11:11:33 +00:00
Ian Renton
57d950c1ca Fix search box appearance on mobile 2025-11-01 10:35:47 +00:00
Ian Renton
a3ec923c56 Improve add spot page warning and server-side validation. #71 2025-11-01 10:29:18 +00:00
Ian Renton
69821f817b Extract "add spot" into its own page 2025-11-01 08:52:46 +00:00
8 changed files with 214 additions and 147 deletions

View File

@@ -1,16 +1,17 @@
import json
import logging
import re
from datetime import datetime, timedelta
from threading import Thread
import bottle
import pytz
from bottle import run, request, response, template
from prometheus_client import CONTENT_TYPE_LATEST, generate_latest
from core.config import MAX_SPOT_AGE, ALLOW_SPOTTING
from core.constants import BANDS, ALL_MODES, MODE_TYPES, SIGS, CONTINENTS, SOFTWARE_VERSION
from core.prometheus_metrics_handler import page_requests_counter, registry, get_metrics, api_requests_counter
from core.constants import BANDS, ALL_MODES, MODE_TYPES, SIGS, CONTINENTS, SOFTWARE_VERSION, UNKNOWN_BAND
from core.lookup_helper import lookup_helper
from core.prometheus_metrics_handler import page_requests_counter, get_metrics, api_requests_counter
from data.spot import Spot
@@ -33,6 +34,7 @@ class WebServer:
# Base template data
bottle.BaseTemplate.defaults['software_version'] = SOFTWARE_VERSION
bottle.BaseTemplate.defaults['allow_spotting'] = ALLOW_SPOTTING
# Routes for API calls
bottle.get("/api/v1/spots")(lambda: self.serve_spots_api())
@@ -45,6 +47,7 @@ class WebServer:
bottle.get("/map")(lambda: self.serve_template('webpage_map'))
bottle.get("/bands")(lambda: self.serve_template('webpage_bands'))
bottle.get("/alerts")(lambda: self.serve_template('webpage_alerts'))
bottle.get("/add-spot")(lambda: self.serve_template('webpage_add_spot'))
bottle.get("/status")(lambda: self.serve_template('webpage_status'))
bottle.get("/about")(lambda: self.serve_template('webpage_about'))
bottle.get("/apidocs")(lambda: self.serve_template('webpage_apidocs'))
@@ -137,16 +140,41 @@ class WebServer:
json_spot = json.loads(post_data)
spot = Spot(**json_spot)
# Reject if no timestamp or dx_call
if not spot.time or not spot.dx_call:
# Reject if no timestamp, frequency, dx_call or de_call
if not spot.time or not spot.dx_call or not spot.freq or not spot.de_call:
response.content_type = 'application/json'
response.status = 422
return json.dumps("Error - 'time' and 'dx_call' must be provided as a minimum.",
return json.dumps("Error - 'time', 'dx_call', 'freq' and 'de_call' must be provided as a minimum.",
default=serialize_everything)
# Reject invalid-looking callsigns
if not re.match(r"^[A-Za-z0-9/\-]*$", spot.dx_call):
response.content_type = 'application/json'
response.status = 422
return json.dumps("Error - '" + spot.dx_call + "' does not look like a valid callsign.",
default=serialize_everything)
if not re.match(r"^[A-Za-z0-9/\-]*$", spot.de_call):
response.content_type = 'application/json'
response.status = 422
return json.dumps("Error - '" + spot.de_call + "' does not look like a valid callsign.",
default=serialize_everything)
# Reject if frequency not in a known band
if lookup_helper.infer_band_from_freq(spot.freq) == UNKNOWN_BAND:
response.content_type = 'application/json'
response.status = 422
return json.dumps("Error - Frequency of " + str(spot.freq / 1000.0) + "kHz is not in a known band.", default=serialize_everything)
# Reject if grid formatting incorrect
if spot.dx_grid and not re.match(r"^([A-R]{2}[0-9]{2}[A-X]{2}[0-9]{2}[A-X]{2}|[A-R]{2}[0-9]{2}[A-X]{2}[0-9]{2}|[A-R]{2}[0-9]{2}[A-X]{2}|[A-R]{2}[0-9]{2})$", spot.dx_grid.upper()):
response.content_type = 'application/json'
response.status = 422
return json.dumps("Error - '" + spot.dx_grid + "' does not look like a valid Maidenhead grid.", default=serialize_everything)
# infer missing data, and add it to our database.
spot.source = "API"
spot.icon = "desktop"
if not spot.sig:
spot.icon = "desktop"
spot.infer_missing()
self.spots.add(spot.id, spot, expire=MAX_SPOT_AGE)

View File

@@ -0,0 +1,59 @@
% rebase('webpage_base.tpl')
<div id="add-spot-intro-box" class="permanently-dismissible-box mt-3">
<div class="alert alert-primary alert-dismissible fade show" role="alert">
<i class="fa-solid fa-circle-info"></i> <strong>Adding spots to Spothole</strong><br/>This page is implemented as a proof of concept for adding spots to the Spothole system. Currently, spots added in this way are only visible within Spothole and are not sent "upstream" to DX clusters or xOTA spotting sites. The functionality might be extended to include this in future if there is demand for it. If you'd like this to be added, please give a thumbs-up on <a href="https://git.ianrenton.com/ian/spothole/issues/70" target="_new" class="alert-link">issue #70</a> or get in touch via email.
<button type="button" id="add-spot-intro-box-dismiss" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
</div>
<div class="mt-3">
<div id="add-spot-area" class="card mb-3">
<div class="card-header text-white bg-primary">
<div class="row">
<div class="col-auto me-auto">
Add a Spot
</div>
</div>
</div>
<div class="card-body">
<form class="row g-2">
<div class="col-auto">
<label for="dx-call" class="form-label">DX Call</label>
<input type="text" class="form-control" id="dx-call" placeholder="N0CALL" style="max-width: 8em;">
</div>
<div class="col-auto">
<label for="freq" class="form-label">Frequency (kHz)</label>
<input type="text" class="form-control" id="freq" placeholder="14100" style="max-width: 8em;">
</div>
<div class="col-auto">
<label for="mode" class="form-label">Mode</label>
<input type="text" class="form-control" id="mode" placeholder="SSB" style="max-width: 6em;">
</div>
<div class="col-auto">
<label for="dx-grid" class="form-label">DX Grid (Optional)</label>
<input type="text" class="form-control" id="dx-grid" placeholder="AA00aa" style="max-width: 8em;">
</div>
<div class="col-auto">
<label for="comment" class="form-label">Comment (Optional)</label>
<input type="text" class="form-control" id="comment" placeholder="59 TNX QSO 73" style="max-width: 12em;">
</div>
<div class="col-auto">
<label for="de-call" class="form-label">Your Call</label>
<input type="text" class="form-control storeable-text" id="de-call" placeholder="N0CALL" style="max-width: 8em;">
</div>
<div class="col-auto">
<button type="button" class="btn btn-primary" style="margin-top: 2em;" onclick="addSpot();">Spot</button>
<span id="result-good"></span>
</div>
</form>
<div id="result-bad"></div>
</div>
</div>
</div>
<script src="/js/common.js"></script>
<script src="/js/add-spot.js"></script>
<script>$(document).ready(function() { $("#nav-link-add-spot").addClass("active"); }); <!-- highlight active page in nav --></script>

View File

@@ -62,6 +62,9 @@
<li class="nav-item ms-4"><a href="/map" class="nav-link" id="nav-link-map"><i class="fa-solid fa-map"></i> Map</a></li>
<li class="nav-item ms-4"><a href="/bands" class="nav-link" id="nav-link-bands"><i class="fa-solid fa-ruler-vertical"></i> Bands</a></li>
<li class="nav-item ms-4"><a href="/alerts" class="nav-link" id="nav-link-alerts"><i class="fa-solid fa-bell"></i> Alerts</a></li>
% if allow_spotting:
<li class="nav-item ms-4"><a href="/add-spot" class="nav-link" id="nav-link-add-spot"><i class="fa-solid fa-comment"></i> Add Spot</a></li>
% end
<li class="nav-item ms-4"><a href="/status" class="nav-link" id="nav-link-status"><i class="fa-solid fa-chart-simple"></i> Status</a></li>
<li class="nav-item ms-4"><a href="/about" class="nav-link" id="nav-link-about"><i class="fa-solid fa-circle-info"></i> About</a></li>
<li class="nav-item ms-4"><a href="/apidocs" class="nav-link" id="nav-link-api"><i class="fa-solid fa-gear"></i> API</a></li>

View File

@@ -1,6 +1,6 @@
% rebase('webpage_base.tpl')
<div id="intro-box" class="mt-3">
<div id="intro-box" class="permanently-dismissible-box mt-3">
<div class="alert alert-primary alert-dismissible fade show" role="alert">
<i class="fa-solid fa-circle-info"></i> <strong>What is Spothole?</strong><br/>Spothole is an aggregator of amateur radio spots from DX clusters and outdoor activity programmes. It's free for anyone to use and includes an API that developers can build other applications on. For more information, check out the <a href="/about" class="alert-link">"About" page</a>. If that sounds like nonsense to you, you can visit <a href="/about#faq" class="alert-link">the FAQ section</a> to learn more.
<button type="button" id="intro-box-dismiss" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
@@ -14,11 +14,10 @@
</div>
<div class="col-auto">
<p class="d-inline-flex gap-1">
<div class="hideonmobile" style="position: relative; display: inline-block; top: 2px;">
<i class="fa-solid fa-magnifying-glass" style="position: absolute; left: 0px; padding: 10px; pointer-events: none;"></i>
<input id="filter-dx-call" type="text" class="form-control me-3" oninput="filtersUpdated();" placeholder="Search for call" style="max-width: 10em; padding-left: 2em;">
</div>
<button id="add-spot-button" type="button" class="btn btn-outline-primary" data-bs-toggle="button" onclick="toggleAddSpotPanel();"><i class="fa-solid fa-comment"></i> Add Spot</button>
<span style="position: relative;">
<i class="fa-solid fa-magnifying-glass" style="position: absolute; left: 0px; top: 2px; padding: 10px; pointer-events: none;"></i>
<input id="filter-dx-call" type="text" class="form-control" oninput="filtersUpdated();" placeholder="Search for call">
</span>
<button id="filters-button" type="button" class="btn btn-outline-primary" data-bs-toggle="button" onclick="toggleFiltersPanel();"><i class="fa-solid fa-filter"></i> Filters</button>
<button id="display-button" type="button" class="btn btn-outline-primary" data-bs-toggle="button" onclick="toggleDisplayPanel();"><i class="fa-solid fa-desktop"></i> Display</button>
</p>
@@ -188,55 +187,6 @@
</div>
</div>
<div id="add-spot-area" class="appearing-panel card mb-3">
<div class="card-header text-white bg-primary">
<div class="row">
<div class="col-auto me-auto">
Add a Spot
</div>
<div class="col-auto d-inline-flex">
<button id="close-add-spot-button" type="button" class="btn-close btn-close-white" aria-label="Close" onclick="closeAddSpotPanel();"></button>
</div>
</div>
</div>
<div class="card-body">
<form class="row g-2">
<div class="col-auto">
<label for="add-spot-dx-call" class="form-label">DX Call</label>
<input type="text" class="form-control" id="add-spot-dx-call" placeholder="N0CALL" style="max-width: 8em;">
</div>
<div class="col-auto">
<label for="add-spot-freq" class="form-label">Frequency (kHz)</label>
<input type="text" class="form-control" id="add-spot-freq" placeholder="14100" style="max-width: 8em;">
</div>
<div class="col-auto">
<label for="add-spot-mode" class="form-label">Mode</label>
<input type="text" class="form-control" id="add-spot-mode" placeholder="SSB" style="max-width: 6em;">
</div>
<div class="col-auto">
<label for="add-spot-comment" class="form-label">Comment</label>
<input type="text" class="form-control" id="add-spot-comment" placeholder="59 TNX QSO 73" style="max-width: 12em;">
</div>
<div class="col-auto">
<label for="add-spot-de-call" class="form-label">Your Call</label>
<input type="text" class="form-control" id="add-spot-de-call" placeholder="N0CALL" style="max-width: 8em;">
</div>
<div class="col-auto">
<button type="button" class="btn btn-primary" style="margin-top: 2em;" onclick="addSpot();">Spot</button>
<span id="post-spot-result-good"></span>
</div>
</form>
<div id="post-spot-result-bad"></div>
<div class="alert alert-warning alert-dismissible fade show mb-0 mt-4" role="alert">
Please note that spots added to Spothole are not currently sent "upstream" to DX clusters or xOTA spotting sites.
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
</div>
</div>
<div id="table-container"></div>
</div>

View File

@@ -493,7 +493,7 @@ paths:
tags:
- spots
summary: Add a spot
description: "Supply a new spot object, which will be added to the system. Currently, this will not be reported up the chain to a cluster, POTA, SOTA etc. This will be introduced in a future version. cURL example: `curl --request POST --header \"Content-Type: application/json\" --data '{\"dx_call\":\"M0TRT\",\"time\":1760019539, \"freq\":14200000, \"comment\":\"Test spot please ignore\", \"de_call\":\"M0TRT\"}' https://spothole.app/api/v1/spot`"
description: "Supply a new spot object, which will be added to the system. Currently, this will not be reported up the chain to a cluster, POTA, SOTA etc. This may be introduced in a future version. cURL example: `curl --request POST --header \"Content-Type: application/json\" --data '{\"dx_call\":\"M0TRT\",\"time\":1760019539, \"freq\":14200000, \"comment\":\"Test spot please ignore\", \"de_call\":\"M0TRT\"}' https://spothole.app/api/v1/spot`"
operationId: spot
requestBody:
description: The JSON spot object

View File

@@ -7,7 +7,7 @@
/* INTRO/WARNING BOXES */
#intro-box {
.permanently-dismissible-box {
display: none;
}
@@ -43,6 +43,12 @@ div.container {
/* SPOTS/ALERTS PAGES, SETTINGS/STATUS AREAS */
input#filter-dx-call {
max-width: 10em;
margin-right: 1rem;
padding-left: 2em;
}
div.appearing-panel {
display: none;
}
@@ -247,6 +253,10 @@ div.band-spot:hover span.band-spot-info {
margin-left: -1em;
margin-right: -1em;
}
input#filter-dx-call {
max-width: 6em;
margin-right: 0;
}
}
@media (min-width: 992px) {

100
webassets/js/add-spot.js Normal file
View File

@@ -0,0 +1,100 @@
// Method called to add a spot to the server
function addSpot() {
try {
// Save settings (this will save "your call" for future use)
saveSettings();
// Unpack the user's entered values
var dx = $("#dx-call").val().toUpperCase();
var freqStr = $("#freq").val();
var mode = $("#mode").val().toUpperCase();
var dxGrid = $("#dx-grid").val();
var comment = $("#comment").val();
var de = $("#de-call").val().toUpperCase();
var spot = {}
if (dx != "") {
spot["dx_call"] = dx;
} else {
showAddSpotError("A DX callsign is required in order to spot.");
return;
}
if (freqStr != "") {
spot["freq"] = parseFloat(freqStr) * 1000;
} else {
showAddSpotError("A frequency is required in order to spot.");
return;
}
if (mode != "") {
spot["mode"] = mode;
}
if (dxGrid != "") {
spot["dx_grid"] = dxGrid;
}
if (comment != "") {
spot["comment"] = comment;
}
if (de != "") {
spot["de_call"] = de;
} else {
showAddSpotError("A spotter callsign is required in order to spot.");
return;
}
spot["time"] = moment.utc().valueOf() / 1000.0;
$.ajax("/api/v1/spot", {
data : JSON.stringify(spot),
contentType : 'application/json',
type : 'POST',
timeout: 10000,
success: async function (result) {
$("#result-good").html("<button type='button' class='btn btn-success' style='margin-top: 2em;'><i class='fa-solid fa-check'></i> OK</button>");
$("#result-bad").html("");
setTimeout(() => {
$("#result-good").hide();
window.location.replace("/");
}, 1000);
},
error: function (result) {
showAddSpotError(result.responseText.slice(1,-1));
}
});
} catch (error) {
showAddSpotError(error);
}
return false;
}
// Show an "add spot" error.
function showAddSpotError(text) {
$("#result-bad").html("<div class='alert alert-danger alert-dismissible fade show mb-0 mt-4' role='alert'><i class='fa-solid fa-triangle-exclamation'></i> " + text + "<button type='button' class='btn-close' data-bs-dismiss='alert' aria-label='Close'></button></div>");
}
// Force callsign and mode capitalisation
$("#dx-call").change(function () {
$(this).val($(this).val().trim().toUpperCase());
});
$("#de-call").change(function () {
$(this).val($(this).val().trim().toUpperCase());
});
$("#mode").change(function () {
$(this).val($(this).val().trim().toUpperCase());
});
// Display the intro box, unless the user has already dismissed it once.
function displayIntroBox() {
if (localStorage.getItem("add-spot-intro-box-dismissed") == null) {
$("#add-spot-intro-box").show();
}
$("#add-spot-intro-box-dismiss").click(function() {
localStorage.setItem("add-spot-intro-box-dismissed", true);
});
}
// Startup
$(document).ready(function() {
// Load settings from settings storage
loadSettings();
// Display intro box
displayIntroBox();
});

View File

@@ -330,64 +330,6 @@ function userGridUpdated() {
saveSettings();
}
// Method called to add a spot to the server
function addSpot() {
try {
var dx = $("#add-spot-dx-call").val().toUpperCase();
var freqStr = $("#add-spot-freq").val();
var mode = $("#add-spot-mode").val().toUpperCase();
var comment = $("#add-spot-comment").val();
var de = $("#add-spot-de-call").val().toUpperCase();
var spot = {}
if (dx != "") {
spot["dx_call"] = dx;
} else {
showAddSpotError("A DX callsign is required in order to spot.");
return;
}
if (freqStr != "") {
spot["freq"] = parseFloat(freqStr) * 1000;
} else {
showAddSpotError("A frequency is required in order to spot.");
return;
}
if (mode != "") {
spot["mode"] = mode;
}
if (comment != "") {
spot["comment"] = comment;
}
if (de != "") {
spot["de_call"] = de;
}
spot["time"] = moment.utc().valueOf() / 1000.0;
$.ajax("/api/v1/spot", {
data : JSON.stringify(spot),
contentType : 'application/json',
type : 'POST',
timeout: 10000,
success: async function (result) {
$("#post-spot-result-good").html("<button type='button' class='btn btn-success' style='margin-top: 2em;'><i class='fa-solid fa-check'></i> OK</button>");
setTimeout(() => $("#post-spot-result-good").hide(), 2000);
loadSpots();
},
error: function (result) {
showAddSpotError(result.responseText);
}
});
} catch (error) {
showAddSpotError(error);
}
return false;
}
// Show an "add spot" error.
function showAddSpotError(text) {
$("#post-spot-result-bad").html("<div class='alert alert-danger alert-dismissible fade show mb-0 mt-4' role='alert'><i class='fa-solid fa-triangle-exclamation'></i> " + text + "<button type='button' class='btn-close' data-bs-dismiss='alert' aria-label='Close'></button></div>");
}
// React to toggling/closing panels
function toggleFiltersPanel() {
// If we are going to show the filters panel, hide the display and add spot panels
@@ -395,10 +337,6 @@ function toggleFiltersPanel() {
$("#display-area").hide();
$("#display-button").button("toggle");
}
if (!$("#filters-area").is(":visible") && $("#add-spot-area").is(":visible")) {
$("#add-spot-area").hide();
$("#add-spot-button").button("toggle");
}
$("#filters-area").toggle();
}
function closeFiltersPanel() {
@@ -412,10 +350,6 @@ function toggleDisplayPanel() {
$("#filters-area").hide();
$("#filters-button").button("toggle");
}
if (!$("#display-area").is(":visible") && $("#add-spot-area").is(":visible")) {
$("#add-spot-area").hide();
$("#add-spot-button").button("toggle");
}
$("#display-area").toggle();
}
function closeDisplayPanel() {
@@ -423,23 +357,6 @@ function closeDisplayPanel() {
$("#display-area").hide();
}
function toggleAddSpotPanel() {
// If we are going to show the add spot panel, hide the filters and display panels
if (!$("#add-spot-area").is(":visible") && $("#filters-area").is(":visible")) {
$("#filters-area").hide();
$("#filters-button").button("toggle");
}
if (!$("#add-spot-area").is(":visible") && $("#display-area").is(":visible")) {
$("#display-area").hide();
$("#display-button").button("toggle");
}
$("#add-spot-area").toggle();
}
function closeAddSpotPanel() {
$("#add-spot-button").button("toggle");
$("#add-spot-area").hide();
}
// Display the intro box, unless the user has already dismissed it once.
function displayIntroBox() {
if (localStorage.getItem("intro-box-dismissed") == null) {