Implement templating to avoid copy/paste HTML code. #7

This commit is contained in:
Ian Renton
2025-10-02 11:16:38 +01:00
parent 10f8d9b4ed
commit cc1a7a9b8c
17 changed files with 105 additions and 117 deletions

1
.gitignore vendored
View File

@@ -1,4 +1,3 @@
/.idea
/.venv /.venv
__pycache__ __pycache__
*.pyc *.pyc

3
.idea/.gitignore generated vendored Normal file
View File

@@ -0,0 +1,3 @@
# Default ignored files
/shelf/
/workspace.xml

View File

@@ -0,0 +1,10 @@
<component name="InspectionProjectProfileManager">
<profile version="1.0">
<option name="myName" value="Project Default" />
<inspection_tool class="SpellCheckingInspection" enabled="false" level="TYPO" enabled_by_default="false">
<option name="processCode" value="true" />
<option name="processLiterals" value="true" />
<option name="processComments" value="true" />
</inspection_tool>
</profile>
</component>

View File

@@ -0,0 +1,6 @@
<component name="InspectionProjectProfileManager">
<settings>
<option name="USE_PROJECT_PROFILE" value="false" />
<version value="1.0" />
</settings>
</component>

10
.idea/metaspot.iml generated Normal file
View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="PYTHON_MODULE" version="4">
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$">
<excludeFolder url="file://$MODULE_DIR$/.venv" />
</content>
<orderEntry type="jdk" jdkName="Python 3.13 virtualenv at ~/code/spothole/.venv" jdkType="Python SDK" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>

7
.idea/misc.xml generated Normal file
View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="Black">
<option name="sdkName" value="Python 3.13 (metaspot)" />
</component>
<component name="ProjectRootManager" version="2" project-jdk-name="Python 3.13 virtualenv at ~/code/spothole/.venv" project-jdk-type="Python SDK" />
</project>

8
.idea/modules.xml generated Normal file
View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/metaspot.iml" filepath="$PROJECT_DIR$/.idea/metaspot.iml" />
</modules>
</component>
</project>

6
.idea/vcs.xml generated Normal file
View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="" vcs="Git" />
</component>
</project>

View File

@@ -17,7 +17,7 @@ from core.utils import infer_mode_type_from_mode, infer_band_from_freq, infer_co
@dataclass @dataclass
class Spot: class Spot:
# Globally unique identifier for the spot # Globally unique identifier for the spot
guid: str = str(uuid.uuid4()) guid: str = None
# Callsign of the operator that has been spotted # Callsign of the operator that has been spotted
dx_call: str = None dx_call: str = None
# Callsign of the operator that has spotted them # Callsign of the operator that has spotted them
@@ -91,6 +91,9 @@ class Spot:
# Infer missing parameters where possible # Infer missing parameters where possible
def infer_missing(self): def infer_missing(self):
# Always create a GUID
self.guid = str(uuid.uuid4())
# Clean up DX call if it has an SSID or -# from RBN # Clean up DX call if it has an SSID or -# from RBN
if self.dx_call and "-" in self.dx_call: if self.dx_call and "-" in self.dx_call:
self.dx_call = self.dx_call.split("-")[0] self.dx_call = self.dx_call.split("-")[0]

View File

@@ -5,7 +5,7 @@ from threading import Thread
import bottle import bottle
import pytz import pytz
from bottle import run, response from bottle import run, response, template
from core.config import MAX_SPOT_AGE from core.config import MAX_SPOT_AGE
from core.constants import BANDS, ALL_MODES, MODE_TYPES, SIGS, SOURCES, CONTINENTS from core.constants import BANDS, ALL_MODES, MODE_TYPES, SIGS, SOURCES, CONTINENTS
@@ -26,11 +26,15 @@ class WebServer:
self.thread.daemon = True self.thread.daemon = True
self.status = "Starting" self.status = "Starting"
# Set up routing # Routes for API calls
bottle.get("/api/spots")(self.serve_api_spots) bottle.get("/api/spots")(lambda: self.serve_api(self.get_spot_list_with_filters()))
bottle.get("/api/options")(self.serve_api_options) bottle.get("/api/options")(lambda: self.serve_api(self.get_options()))
bottle.get("/api/status")(self.serve_api_status) bottle.get("/api/status")(lambda: self.serve_api(self.status_data))
bottle.get("/")(self.serve_index) # Routes for templated pages
bottle.get("/")(lambda: self.serve_template('webpage_home'))
bottle.get("/about")(lambda: self.serve_template('webpage_about'))
bottle.get("/apidocs")(lambda: self.serve_template('webpage_apidocs'))
# Default route to serve from "webassets"
bottle.get("/<filepath:path>")(self.serve_static_file) bottle.get("/<filepath:path>")(self.serve_static_file)
# Start the web server # Start the web server
@@ -43,51 +47,29 @@ class WebServer:
self.status = "Waiting" self.status = "Waiting"
run(host='localhost', port=self.port) run(host='localhost', port=self.port)
# Main spots API # Serve a JSON API endpoint
def serve_api_spots(self): def serve_api(self, data):
self.last_api_access_time = datetime.now(pytz.UTC) self.last_api_access_time = datetime.now(pytz.UTC)
self.status = "OK" self.status = "OK"
spots_json = json.dumps(self.get_spot_list_with_filters(bottle.request.query), default=serialize_everything)
response.content_type = 'application/json' response.content_type = 'application/json'
return spots_json return json.dumps(data, default=serialize_everything)
# Options API # Serve a templated page
def serve_api_options(self): def serve_template(self, template_name):
self.last_api_access_time = datetime.now(pytz.UTC)
self.status = "OK"
status_json = json.dumps(self.get_options(), default=serialize_everything)
response.content_type = 'application/json'
return status_json
# Server status API
def serve_api_status(self):
self.last_api_access_time = datetime.now(pytz.UTC)
self.status = "OK"
status_json = json.dumps(self.status_data, default=serialize_everything)
response.content_type = 'application/json'
return status_json
# Serve the home page. This would be accessible as /index.html but we need this workaround to make it available as /
def serve_index(self):
return self.serve_static_file("")
# Serve general static files from "webassets" directory, along with some extra workarounds to make URLs such as
# "/", "/about" and "/apidocs" work.
def serve_static_file(self, filepath):
self.last_page_access_time = datetime.now(pytz.UTC) self.last_page_access_time = datetime.now(pytz.UTC)
self.status = "OK" self.status = "OK"
if filepath == "": return template(template_name)
return bottle.static_file("index.html", root="webassets")
elif filepath == "about": # Serve general static files from "webassets" directory.
return bottle.static_file("about.html", root="webassets") def serve_static_file(self, filepath):
elif filepath == "apidocs": return bottle.static_file(filepath, root="webassets")
return bottle.static_file("index.html", root="webassets/apidocs")
else:
return bottle.static_file(filepath, root="webassets")
# Utility method to apply filters to the overall spot list and return only a subset. Enables query parameters in # Utility method to apply filters to the overall spot list and return only a subset. Enables query parameters in
# the main "spots" GET call. The "query" parameter should be the result of bottle's request.query, and is a MultiDict # the main "spots" GET call.
def get_spot_list_with_filters(self, query): def get_spot_list_with_filters(self):
# Get the query (and the right one, with Bottle magic. This is a MultiDict object
query = bottle.request.query
# Create a shallow copy of the spot list, ordered by spot time. We'll then filter it accordingly. # Create a shallow copy of the spot list, ordered by spot time. We'll then filter it accordingly.
# We can filter by spot time and received time with "since" and "received_since", which take a UNIX timestamp # We can filter by spot time and received time with "since" and "received_since", which take a UNIX timestamp
# in seconds UTC. # in seconds UTC.

12
views/webpage_about.tpl Normal file
View File

@@ -0,0 +1,12 @@
% rebase('webpage_base.tpl')
<div id="info-container" class="mt-4">
<h3>About (S)pothole</h3>
<p>(S)pothole is a utility to aggregate "spots" from amateur radio DX clusters and xOTA spotting sites, and provide an open JSON API as well as a website to browse the data.</p>
<p>While there are several other web-based interfaces to DX clusters, and sites that aggregate spots from various outfoor activity programmes for amateur radio, (S)pothole differentiates itself by supporting a large number of data sources, and by being "API first" rather than just providing a web front-end. This allows other software to be built on top of it.</p>
<p>The API is deliberately well-defined with an <a href="/apidocs/openapi.yml">OpenAPI specification</a> and auto-generated <a href="/apidocs">API documentation</a>. The API delivers spots in a consistent format regardless of the data source, freeing developers from needing to know how each individual data source presents its data.</p>
<p>(S)pothole itself is also open source, Public Domain licenced code that anyone can take and modify. <a href="https://git.ianrenton.com/ian/metaspot/">The source code is here</a>.</p>
<p>Supported data sources include DX Clusters, the Reverse Beacon Network (RBN), the APRS Internet Service (APRS-IS), POTA, SOTA, WWFF, GMA, WWBOTA, HEMA, and Parks 'n' Peaks.</p>
<p>The software was written by <a href="https://ianrenton.com">Ian Renton, MØTRT</a>.</p>
<p><a href="/">&laquo; Back home</a></p>
</div>

View File

@@ -0,0 +1,4 @@
% rebase('webpage_base.tpl')
<redoc spec-url="/apidocs/openapi.yml"></redoc>
<script src="https://cdn.redoc.ly/redoc/latest/bundles/redoc.standalone.js"> </script>

View File

@@ -21,12 +21,12 @@
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.8/dist/js/bootstrap.bundle.min.js" <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.8/dist/js/bootstrap.bundle.min.js"
integrity="sha384-FKyoEForCGlyvwx9Hj09JcYn3nv7wiPVlz7YYwJrWVcXK/BmnVDxM+D2scQbITxI" integrity="sha384-FKyoEForCGlyvwx9Hj09JcYn3nv7wiPVlz7YYwJrWVcXK/BmnVDxM+D2scQbITxI"
crossorigin="anonymous"></script> crossorigin="anonymous"></script>
<script src="js/code.js"></script> <script src="/js/code.js"></script>
</head> </head>
<body> <body>
<div class="container"> <div class="container">
<nav class="navbar navbar-expand-lg navbar-light justify-content-between border-bottom" style="background-color: white;"> <nav class="navbar navbar-expand-lg navbar-light justify-content-between p-0 border-bottom" style="background-color: white;">
<a class="navbar-brand" href="/"> <a class="navbar-brand" href="/">
<img src="/img/logo.png" width="192" height="60" alt="Spothole"> <img src="/img/logo.png" width="192" height="60" alt="Spothole">
</a> </a>
@@ -38,10 +38,7 @@
<main> <main>
<div id="table-container"> {{!base}}
<p>Latest spots as of XXXX. Updating in XXX seconds...</p>
<div id="table-container-inner"></div>
</div>
</main> </main>
</div> </div>

6
views/webpage_home.tpl Normal file
View File

@@ -0,0 +1,6 @@
% rebase('webpage_base.tpl')
<div class="mt-3">
<p>Latest spots as of XXXX. Updating in XXX seconds...</p>
<div id="table-container"></div>
</div>

View File

@@ -1,46 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>(S)pothole</title>
<link rel="stylesheet" href="css/style.css" type="text/css">
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.8/dist/css/bootstrap.min.css" rel="stylesheet"
integrity="sha384-sRIl4kxILFvY47J16cr9ZwB07vP4J8+LH7qKQnuqkuIAvNWLzeN8tE5YBujZqJLB" crossorigin="anonymous">
<link rel="icon" type="image/png" href="img/icon-512.png">
<link rel="alternate icon" type="image/png" href="img/icon-192.png">
<link rel="alternate icon" type="image/png" href="img/icon-32.png">
<link rel="alternate icon" type="image/png" href="img/icon-16.png">
<link rel="alternate icon" type="image/x-icon" href="img/favicon.ico">
</head>
<body>
<div class="container">
<nav class="navbar navbar-expand-lg navbar-light justify-content-between border-bottom" style="background-color: white;">
<a class="navbar-brand" href="/">
<img src="/img/logo.png" width="192" height="60" alt="Spothole">
</a>
<ul class="nav nav-pills">
<li class="nav-item"><a href="/about" class="nav-link">About</a></li>
<li class="nav-item"><a href="/apidocs" class="nav-link">API</a></li>
</ul>
</nav>
<main>
<div id="info-container">
<h3>About (S)pothole</h3>
<p>(S)pothole is a utility to aggregate "spots" from amateur radio DX clusters and xOTA spotting sites, and provide an open JSON API as well as a website to browse the data.</p>
<p>While there are several other web-based interfaces to DX clusters, and sites that aggregate spots from various outfoor activity programmes for amateur radio, (S)pothole differentiates itself by supporting a large number of data sources, and by being "API first" rather than just providing a web front-end. This allows other software to be built on top of it.</p>
<p>The API is deliberately well-defined with an <a href="/apidocs/openapi.yml">OpenAPI specification</a> and auto-generated <a href="/apidocs">API documentation</a>. The API delivers spots in a consistent format regardless of the data source, freeing developers from needing to know how each individual data source presents its data.</p>
<p>(S)pothole itself is also open source, Public Domain licenced code that anyone can take and modify. <a href="https://git.ianrenton.com/ian/metaspot/">The source code is here</a>.</p>
<p>Supported data sources include DX Clusters, the Reverse Beacon Network (RBN), the APRS Internet Service (APRS-IS), POTA, SOTA, WWFF, GMA, WWBOTA, HEMA, and Parks 'n' Peaks.</p>
<p>The software was written by <a href="https://ianrenton.com">Ian Renton, MØTRT</a>.</p>
<p><a href="/">&laquo; Back home</a></p>
</div>
</main>
</div>
</body>
</html>

View File

@@ -1,19 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>API Documentation</title>
</head>
<body> <nav class="navbar navbar-expand-lg navbar-light justify-content-between border-bottom" style="background-color: white;">
<a class="navbar-brand" href="/">
<img src="/img/logo.png" width="192" height="60" alt="Spothole">
</a>
<ul class="nav nav-pills">
<li class="nav-item"><a href="/about" class="nav-link">About</a></li>
<li class="nav-item"><a href="/apidocs" class="nav-link">API</a></li>
</ul>
</nav>
<redoc spec-url="/apidocs/openapi.yml"></redoc>
<script src="https://cdn.redoc.ly/redoc/latest/bundles/redoc.standalone.js"> </script>
</body>
</html>

View File

@@ -17,7 +17,7 @@ $.getJSON('/api/spots', function(jsonData) {
table.find('tbody').append($tr); table.find('tbody').append($tr);
}); });
$('#table-container-inner').html(table); $('#table-container').html(table);
}); });
function escapeHtml(str) { function escapeHtml(str) {