fixed sonarqube scan issues
Some checks failed
Sonarqube Scanner / Build and analyze (push) Failing after 22s

This commit is contained in:
Florian Zumpe 2026-06-17 15:12:46 +02:00
parent 2442f8d458
commit 95026aac6e
28 changed files with 973 additions and 184 deletions

11
.coveragerc Normal file
View File

@ -0,0 +1,11 @@
[run]
branch = True
relative_files = True
source = tools/python
[report]
show_missing = True
skip_empty = True
[xml]
output = coverage/python/coverage.xml

4
.gitignore vendored
View File

@ -11,3 +11,7 @@ npm-debug.log*
coverage/ coverage/
.gitea/workflows/ .gitea/workflows/
.coverage
__pycache__/
*.pyc

View File

@ -75,7 +75,7 @@ Die vollständige REST-API ist einschließlich aller Pfad-, Query-, Formular- un
## Python-Werkzeuge ## Python-Werkzeuge
Unter [`tools/python/`](tools/python/README.md) liegen eigenständige Python-3-Skripte zum Anlegen, Ändern, Erweitern, Wiederherstellen und Löschen von Routen, POIs, Bildern und Audiodateien. Sie verwenden ausschließlich die Python-Standardbibliothek. Fehlende Parameter werden interaktiv abgefragt. Antwortet ein vorgeschalteter Passwortschutz mit HTTP 401, fragt die gemeinsame Request-Schicht Benutzername und Passwort ab und wiederholt den Request. Unter [`tools/python/`](tools/python/README.md) liegen eigenständige Python-3-Skripte zum Anlegen, Ändern, Erweitern, Wiederherstellen und Löschen von Routen, POIs, Bildern und Audiodateien. Die Werkzeuge selbst verwenden ausschließlich die Python-Standardbibliothek. Fehlende Parameter werden interaktiv abgefragt. Antwortet ein vorgeschalteter Passwortschutz mit HTTP 401, fragt die gemeinsame Request-Schicht Benutzername und Passwort ab und wiederholt den Request. Fehler werden mit einer verständlichen Zusammenfassung und dem vollständigen Python-Stacktrace auf `stderr` ausgegeben.
## Dateistruktur ## Dateistruktur
@ -105,6 +105,16 @@ Der lokale Proxy lässt `GET`, `HEAD` und `OPTIONS` ohne Anmeldung passieren. F
Der Integrationstest prüft dabei ausdrücklich, dass lesende Requests nicht präventiv authentifiziert werden und dass für `POST`, `PUT` und `DELETE` jeweils zuerst ein `401` und anschließend ein erfolgreicher authentifizierter Request erfolgt. Zusätzlich bleibt ein kleiner, isolierter HTTP-Mock auf einem zufälligen Loopback-Port bestehen, der die 401-Wiederholungslogik der Python-Request-Schicht unabhängig vom vollständigen REST-Test prüft. Der Integrationstest prüft dabei ausdrücklich, dass lesende Requests nicht präventiv authentifiziert werden und dass für `POST`, `PUT` und `DELETE` jeweils zuerst ein `401` und anschließend ein erfolgreicher authentifizierter Request erfolgt. Zusätzlich bleibt ein kleiner, isolierter HTTP-Mock auf einem zufälligen Loopback-Port bestehen, der die 401-Wiederholungslogik der Python-Request-Schicht unabhängig vom vollständigen REST-Test prüft.
Die kombinierte Coverage wird mit folgenden Befehlen erzeugt:
```bash
python3 -m pip install -r requirements-dev.txt
npm ci
npm run test:coverage
```
Dabei entstehen `coverage/js/lcov.info` für JavaScript und `coverage/python/coverage.xml` für die Python-Werkzeuge. Die zugehörigen SonarQube-Pfade stehen in `sonar-project.properties`. Die Coverage-Skripte erzwingen selbst keinen Mindestwert; die Bewertung und die Hinweise zu nicht abgedeckten Stellen bleiben Aufgabe des SonarQube Quality Gates.
## Technische Hinweise ## Technische Hinweise
- Medienpfade werden relativ zu `storage/` gespeichert. So bleibt das Projekt verschiebbar. - Medienpfade werden relativ zu `storage/` gespeichert. So bleibt das Projekt verschiebbar.

4
package-lock.json generated
View File

@ -1,12 +1,12 @@
{ {
"name": "wegwichtel-next", "name": "wegwichtel-next",
"version": "0.12.5", "version": "0.12.6",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "wegwichtel-next", "name": "wegwichtel-next",
"version": "0.12.5", "version": "0.12.6",
"hasInstallScript": true, "hasInstallScript": true,
"dependencies": { "dependencies": {
"@file-type/av": "0.2.0", "@file-type/av": "0.2.0",

View File

@ -1,6 +1,6 @@
{ {
"name": "wegwichtel-next", "name": "wegwichtel-next",
"version": "0.12.5", "version": "0.12.6",
"private": true, "private": true,
"description": "GPS-gestützte Lern- und Wanderwege mit GPX, POIs, Bildern und Audioansagen.", "description": "GPS-gestützte Lern- und Wanderwege mit GPX, POIs, Bildern und Audioansagen.",
"type": "module", "type": "module",
@ -13,9 +13,12 @@
"start": "node --env-file-if-exists=.env server.js", "start": "node --env-file-if-exists=.env server.js",
"dev": "node --env-file-if-exists=.env --watch server.js", "dev": "node --env-file-if-exists=.env --watch server.js",
"init-db": "node --env-file-if-exists=.env scripts/init-db.js", "init-db": "node --env-file-if-exists=.env scripts/init-db.js",
"check": "node --check server.js && node --test", "check": "node --check server.js && npm test",
"test": "node --test", "test": "node --test && npm run test:python",
"test:coverage": "c8 --exclude='test/**' --exclude='test-support/**' --reporter=text-summary --reporter=lcov --reports-dir=coverage/js node --test" "test:coverage": "npm run test:coverage:js && npm run test:coverage:python",
"test:python": "python3 -m unittest discover -s test/python -p 'test_*.py'",
"test:coverage:js": "c8 --exclude='test/**' --exclude='test-support/**' --reporter=text-summary --reporter=lcov --reports-dir=coverage/js node --test",
"test:coverage:python": "python3 -m coverage erase && python3 -m coverage run -m unittest discover -s test/python -p 'test_*.py' && python3 -m coverage xml -o coverage/python/coverage.xml && python3 -m coverage report"
}, },
"dependencies": { "dependencies": {
"@file-type/av": "0.2.0", "@file-type/av": "0.2.0",

1
requirements-dev.txt Normal file
View File

@ -0,0 +1 @@
coverage==7.13.3

2
sonar-project.properties Normal file
View File

@ -0,0 +1,2 @@
sonar.javascript.lcov.reportPaths=coverage/js/lcov.info
sonar.python.coverage.reportPaths=coverage/python/coverage.xml

View File

@ -8,10 +8,72 @@ import { detectAv } from '@file-type/av';
import { config } from '../config.js'; import { config } from '../config.js';
import { HttpError } from './errors.js'; import { HttpError } from './errors.js';
function sanitizeFilename(value) {
return String(value).replaceAll(/[^a-zA-Z0-9._-]/g, '_') || 'upload';
}
function stripBase64Padding(value) {
let end = value.length;
while (end > 0 && value[end - 1] === '=') end -= 1;
return value.slice(0, end);
}
function base64FromDataUrl(value, fieldName) {
const dataUrl = value.trim();
const marker = ';base64,';
const markerIndex = dataUrl.indexOf(marker);
if (!dataUrl.startsWith('data:') || markerIndex < 5) {
throw new HttpError(400, `${fieldName}.dataUrl muss eine Base64-Data-URL sein.`);
}
return dataUrl.slice(markerIndex + marker.length);
}
function isXmlNameCharacter(character, first = false) {
const code = character.codePointAt(0);
const letter = (code >= 65 && code <= 90) || (code >= 97 && code <= 122);
if (letter || character === '_') return true;
return !first && ((code >= 48 && code <= 57) || character === '.' || character === '-' || character === ':');
}
function rootElementName(xml) {
let cursor = 0;
while (cursor < xml.length) {
const opening = xml.indexOf('<', cursor);
if (opening < 0 || opening + 1 >= xml.length) return null;
if (xml.startsWith('<!--', opening)) {
const end = xml.indexOf('-->', opening + 4);
if (end < 0) return null;
cursor = end + 3;
continue;
}
if (xml.startsWith('<?', opening)) {
const end = xml.indexOf('?>', opening + 2);
if (end < 0) return null;
cursor = end + 2;
continue;
}
if (xml.startsWith('<!', opening)) {
const end = xml.indexOf('>', opening + 2);
if (end < 0) return null;
cursor = end + 1;
continue;
}
if (xml.startsWith('</', opening)) return null;
let end = opening + 1;
if (!isXmlNameCharacter(xml[end], true)) return null;
end += 1;
while (end < xml.length && isXmlNameCharacter(xml[end])) end += 1;
return xml.slice(opening + 1, end);
}
return null;
}
const storage = multer.diskStorage({ const storage = multer.diskStorage({
destination: path.join(config.storageDir, 'uploads'), destination: path.join(config.storageDir, 'uploads'),
filename(req, file, callback) { filename(req, file, callback) {
const safe = file.originalname.replace(/[^a-zA-Z0-9._-]/g, '_') || 'upload'; const safe = sanitizeFilename(file.originalname);
callback(null, `${Date.now()}-${crypto.randomUUID()}-${safe}`); callback(null, `${Date.now()}-${crypto.randomUUID()}-${safe}`);
} }
}); });
@ -41,14 +103,14 @@ function decodeBase64(value, fieldName) {
throw new HttpError(400, `${fieldName}.base64 muss eine nichtleere Base64-Zeichenkette sein.`); throw new HttpError(400, `${fieldName}.base64 muss eine nichtleere Base64-Zeichenkette sein.`);
} }
const compact = value.replace(/\s+/g, ''); const compact = value.replaceAll(/\s/g, '');
if (!/^[A-Za-z0-9+/]*={0,2}$/.test(compact) || compact.length % 4 === 1) { if (!/^[A-Za-z0-9+/]*={0,2}$/.test(compact) || compact.length % 4 === 1) {
throw new HttpError(400, `${fieldName}.base64 enthält keine gültigen Base64-Daten.`); throw new HttpError(400, `${fieldName}.base64 enthält keine gültigen Base64-Daten.`);
} }
const buffer = Buffer.from(compact, 'base64'); const buffer = Buffer.from(compact, 'base64');
const canonicalInput = compact.replace(/=+$/, ''); const canonicalInput = stripBase64Padding(compact);
const canonicalOutput = buffer.toString('base64').replace(/=+$/, ''); const canonicalOutput = stripBase64Padding(buffer.toString('base64'));
if (canonicalInput !== canonicalOutput) { if (canonicalInput !== canonicalOutput) {
throw new HttpError(400, `${fieldName}.base64 enthält keine gültigen Base64-Daten.`); throw new HttpError(400, `${fieldName}.base64 enthält keine gültigen Base64-Daten.`);
} }
@ -71,11 +133,7 @@ function jsonFileDescriptor(value, fieldName) {
if (typeof value.dataUrl !== 'string') { if (typeof value.dataUrl !== 'string') {
throw new HttpError(400, `${fieldName}.dataUrl muss eine Zeichenkette sein.`); throw new HttpError(400, `${fieldName}.dataUrl muss eine Zeichenkette sein.`);
} }
const match = /^data:[^,]*;base64,(.*)$/s.exec(value.dataUrl.trim()); base64 = base64FromDataUrl(value.dataUrl, fieldName);
if (!match) {
throw new HttpError(400, `${fieldName}.dataUrl muss eine Base64-Data-URL sein.`);
}
base64 = match[1];
} }
return { originalname, buffer: decodeBase64(base64, fieldName) }; return { originalname, buffer: decodeBase64(base64, fieldName) };
@ -89,7 +147,7 @@ function materializeJsonUpload(req, fieldName) {
const destination = path.join(config.storageDir, 'uploads'); const destination = path.join(config.storageDir, 'uploads');
fs.mkdirSync(destination, { recursive: true }); fs.mkdirSync(destination, { recursive: true });
const safe = descriptor.originalname.replace(/[^a-zA-Z0-9._-]/g, '_') || 'upload'; const safe = sanitizeFilename(descriptor.originalname);
const filename = `${Date.now()}-${crypto.randomUUID()}-${safe}`; const filename = `${Date.now()}-${crypto.randomUUID()}-${safe}`;
const filePath = path.join(destination, filename); const filePath = path.join(destination, filename);
fs.writeFileSync(filePath, descriptor.buffer, { flag: 'wx' }); fs.writeFileSync(filePath, descriptor.buffer, { flag: 'wx' });
@ -116,8 +174,11 @@ function detectGpx(file) {
throw new HttpError(415, 'Der Upload ist keine gültig kodierte GPX-Datei.'); throw new HttpError(415, 'Der Upload ist keine gültig kodierte GPX-Datei.');
} }
const start = xml.slice(0, 131072).replace(/^\uFEFF/, ''); const prefix = xml.slice(0, 131072);
if (!/<(?:[A-Za-z_][\w.-]*:)?gpx(?:\s|>)/i.test(start)) { const start = prefix.startsWith('\uFEFF') ? prefix.slice(1) : prefix;
const rootName = rootElementName(start);
const localName = rootName?.split(':').at(-1);
if (localName !== 'gpx') {
throw new HttpError(415, 'Der Upload enthält kein GPX-Dokument.'); throw new HttpError(415, 'Der Upload enthält kein GPX-Dokument.');
} }
return { ext: 'gpx', mime: 'application/gpx+xml' }; return { ext: 'gpx', mime: 'application/gpx+xml' };
@ -146,7 +207,7 @@ async function detectAndValidate(file, kind) {
const rules = ALLOWED_TYPES[kind]; const rules = ALLOWED_TYPES[kind];
const baseMime = String(detected.mime).split(';', 1)[0].toLowerCase(); const baseMime = String(detected.mime).split(';', 1)[0].toLowerCase();
const extension = String(detected.ext).toLowerCase(); const extension = String(detected.ext).toLowerCase();
if (!rules || !rules.mime.has(baseMime) || !rules.ext.has(extension)) { if (!rules?.mime.has(baseMime) || !rules?.ext.has(extension)) {
throw new HttpError(415, kind === 'picture' throw new HttpError(415, kind === 'picture'
? `Nicht unterstütztes Bildformat: ${detected.mime}` ? `Nicht unterstütztes Bildformat: ${detected.mime}`
: `Nicht unterstütztes Audioformat: ${detected.mime}`); : `Nicht unterstütztes Audioformat: ${detected.mime}`);

View File

@ -326,6 +326,25 @@ test('upload inspection handles JSON, GPX, binary validation and cleanup paths',
assert.equal(validGpxReq.file.detectedExtension, 'gpx'); assert.equal(validGpxReq.file.detectedExtension, 'gpx');
storage.removeUpload(validGpxReq.file); storage.removeUpload(validGpxReq.file);
const namespacedGpx = '<?xml version="1.0"?><!-- route --><!DOCTYPE gpx><ns_1-foo.gpx:gpx></ns_1-foo.gpx:gpx>';
const namespacedGpxReq = { body: {}, file: uploadedFile('namespaced.xml', namespacedGpx) };
assert.equal(await invokeUpload(inspectUpload('gpx', 'gpx'), namespacedGpxReq), undefined);
storage.removeUpload(namespacedGpxReq.file);
for (const malformed of [
'plain text',
'<!-- missing end',
'<?xml version="1.0"',
'<!DOCTYPE gpx',
'</gpx>',
'< gpx>'
]) {
const request = { body: {}, file: uploadedFile('malformed.xml', malformed) };
const error = await invokeUpload(inspectUpload('gpx', 'gpx'), request);
assert.equal(error.status, 415);
assert.equal(request.file, undefined);
}
const invalidGpxReq = { body: {}, file: uploadedFile('route.xml', '<xml/>') }; const invalidGpxReq = { body: {}, file: uploadedFile('route.xml', '<xml/>') };
const invalidGpx = await invokeUpload(inspectUpload('gpx', 'gpx'), invalidGpxReq); const invalidGpx = await invokeUpload(inspectUpload('gpx', 'gpx'), invalidGpxReq);
assert.equal(invalidGpx.status, 415); assert.equal(invalidGpx.status, 415);

View File

@ -0,0 +1,175 @@
"""Unit-Tests für alle importierbaren Python-Kommandozeilenwerkzeuge."""
from __future__ import annotations
import importlib
import sys
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
ROOT = Path(__file__).resolve().parents[2]
TOOLS = ROOT / "tools" / "python"
sys.path.insert(0, str(TOOLS))
class RecordingClient:
def __init__(self, get_result=None):
self.calls = []
self.get_result = get_result or {}
def request(self, method, path, **kwargs):
self.calls.append((method, path, kwargs))
if method == "GET":
return dict(self.get_result)
return {"method": method, "path": path}
class CommandTests(unittest.TestCase):
def module(self, name):
return importlib.import_module(name)
def run_with_client(self, name, argv, *, get_result=None, extra_patches=()):
module = self.module(name)
client = RecordingClient(get_result)
patches = [patch.object(module, "client_from_args", return_value=client)]
patches.extend(extra_patches)
for current in patches:
current.start()
self.addCleanup(current.stop)
result = module.main(argv)
return result, client
def test_create_and_append_commands(self):
with tempfile.TemporaryDirectory() as directory:
gpx = Path(directory) / "route.gpx"
picture = Path(directory) / "picture.png"
audio = Path(directory) / "audio.mp3"
for file_path in (gpx, picture, audio):
file_path.write_bytes(b"content")
_, client = self.run_with_client("append_route", ["--route-id", "1", "--gpx", str(gpx)])
self.assertEqual(client.calls[0][0:2], ("POST", "/api/routes/1/append"))
_, client = self.run_with_client(
"create_route",
["--gpx", str(gpx), "--name", "Route", "--slug", "route", "--description", "Text", "--school-name", "Schule"],
)
self.assertEqual(client.calls[0][1], "/api/routes")
self.assertEqual(client.calls[0][2]["fields"]["schoolName"], "Schule")
_, client = self.run_with_client("create_audio", ["--route-id", "1", "--poi-id", "2", "--file", str(audio)])
self.assertIn("audio", client.calls[0][2]["files"])
_, client = self.run_with_client(
"create_picture",
["--route-id", "1", "--poi-id", "2", "--file", str(picture), "--caption", "Bild", "--sequence", "3"],
)
self.assertEqual(client.calls[0][2]["fields"], {"caption": "Bild", "sequence": 3})
_, client = self.run_with_client(
"create_poi",
["--route-id", "1", "--title", "POI", "--description", "Text", "--lat", "50.1", "--lon", "10.2", "--trigger-radius", "25", "--sequence", "0"],
)
self.assertEqual(client.calls[0][2]["json_body"]["sequence"], 0)
self.assertEqual(client.calls[0][2]["json_body"]["description"], "Text")
def test_delete_commands_cover_confirmation_and_requests(self):
cases = [
("delete_route", ["--route-id", "1"], "/api/routes/1"),
("delete_poi", ["--route-id", "1", "--poi-id", "2"], "/api/routes/1/pois/2"),
("delete_picture", ["--route-id", "1", "--poi-id", "2", "--picture-id", "3"], "/api/routes/1/pois/2/pictures/3"),
("delete_audio", ["--route-id", "1", "--poi-id", "2"], "/api/routes/1/pois/2/audio"),
]
for name, argv, path in cases:
with self.subTest(name=name, confirmed=False):
module = self.module(name)
result, client = self.run_with_client(
name,
argv,
extra_patches=(patch.object(module, "confirm", return_value=False),),
)
self.assertEqual(result, {"cancelled": True})
self.assertEqual(client.calls, [])
with self.subTest(name=name, confirmed=True):
module = self.module(name)
_, client = self.run_with_client(
name,
argv,
extra_patches=(patch.object(module, "confirm", return_value=True),),
)
self.assertEqual(client.calls[0][0:2], ("DELETE", path))
def test_restore_and_update_commands(self):
_, client = self.run_with_client("restore_route", ["--route-id", "7"])
self.assertEqual(client.calls[0][0:2], ("POST", "/api/routes/7/restore"))
with tempfile.TemporaryDirectory() as directory:
replacement = Path(directory) / "replacement.bin"
replacement.write_bytes(b"content")
_, client = self.run_with_client(
"update_audio", ["--route-id", "1", "--poi-id", "2", "--file", str(replacement)]
)
self.assertEqual(client.calls[0][0], "PUT")
_, client = self.run_with_client(
"update_picture",
["--route-id", "1", "--poi-id", "2", "--picture-id", "3", "--file", str(replacement)],
get_result={"caption": "Alt", "sequence": 4},
extra_patches=(patch.object(self.module("update_picture"), "ask_with_default", side_effect=lambda label, current, cast=str: current),),
)
self.assertEqual([call[0] for call in client.calls], ["GET", "PUT"])
self.assertIn("files", client.calls[1][2])
module = self.module("update_picture")
_, client = self.run_with_client(
"update_picture",
["--route-id", "1", "--poi-id", "2", "--picture-id", "3"],
get_result={"caption": "Alt", "sequence": 4},
extra_patches=(
patch.object(module, "ask_path", return_value=None),
patch.object(module, "ask_with_default", side_effect=lambda label, current, cast=str: current),
),
)
self.assertIn("json_body", client.calls[1][2])
_, client = self.run_with_client(
"update_route",
["--route-id", "1", "--gpx", str(replacement)],
get_result={"name": "Alt", "description": "Text", "schoolName": "Schule"},
extra_patches=(patch.object(self.module("update_route"), "ask_with_default", side_effect=lambda label, current, cast=str: current),),
)
self.assertIn("files", client.calls[1][2])
module = self.module("update_route")
_, client = self.run_with_client(
"update_route",
["--route-id", "1"],
get_result={"name": "Alt", "description": "Text", "schoolName": "Schule"},
extra_patches=(
patch.object(module, "ask_path", return_value=None),
patch.object(module, "ask_with_default", side_effect=lambda label, current, cast=str: current),
),
)
self.assertIn("json_body", client.calls[1][2])
_, client = self.run_with_client(
"update_poi",
["--route-id", "1", "--poi-id", "2"],
get_result={
"title": "Alt",
"description": "Text",
"lat": 50.0,
"lon": 10.0,
"triggerRadiusM": 25.0,
"sequence": 1,
},
extra_patches=(patch.object(self.module("update_poi"), "ask_with_default", side_effect=lambda label, current, cast=str: current),),
)
self.assertEqual(client.calls[1][2]["json_body"]["title"], "Alt")
if __name__ == "__main__":
unittest.main()

237
test/python/test_common.py Normal file
View File

@ -0,0 +1,237 @@
"""Unit-Tests für die gemeinsame Python-HTTP- und CLI-Schicht."""
from __future__ import annotations
import argparse
import io
import json
import os
import sys
import tempfile
import unittest
from contextlib import redirect_stderr, redirect_stdout
from pathlib import Path
from unittest.mock import MagicMock, patch
from urllib.error import HTTPError, URLError
ROOT = Path(__file__).resolve().parents[2]
TOOLS = ROOT / "tools" / "python"
sys.path.insert(0, str(TOOLS))
import common # noqa: E402
class FakeResponse:
def __init__(self, status=200, body=b"", content_type="application/json"):
self.status = status
self._body = body
self.headers = {"Content-Type": content_type}
def __enter__(self):
return self
def __exit__(self, exc_type, exc, tb):
return False
def read(self):
return self._body
class CommonTests(unittest.TestCase):
def test_environment_credentials_are_trimmed_and_require_both_values(self):
with patch.dict(os.environ, {common.API_USER_ENV: " user ", common.API_PASSWORD_ENV: " secret "}, clear=True):
self.assertEqual(common.credentials_from_environment(), common.Credentials("user", "secret"))
with patch.dict(os.environ, {common.API_USER_ENV: "user", common.API_PASSWORD_ENV: " "}, clear=True):
self.assertIsNone(common.credentials_from_environment())
def test_prompt_credentials_prefers_environment_then_interactive_values(self):
client = common.ApiClient("http://localhost:47145")
with patch.dict(os.environ, {common.API_USER_ENV: " env-user ", common.API_PASSWORD_ENV: " env-pass "}, clear=True):
client.prompt_credentials()
self.assertEqual(client.credentials, common.Credentials("env-user", "env-pass"))
client = common.ApiClient("http://localhost:47145", "default", "old")
with patch.dict(os.environ, {}, clear=True), patch("builtins.input", return_value=""), patch(
"getpass.getpass", return_value=" new-pass "
):
client.prompt_credentials()
self.assertEqual(client.credentials, common.Credentials("default", "new-pass"))
with patch.dict(os.environ, {}, clear=True), patch("builtins.input", return_value=""):
with self.assertRaises(common.ApiError):
common.ApiClient("http://localhost:47145").prompt_credentials()
with patch.dict(os.environ, {}, clear=True), patch("builtins.input", return_value="user"), patch(
"getpass.getpass", return_value=" "
):
with self.assertRaises(common.ApiError):
common.ApiClient("http://localhost:47145").prompt_credentials()
def test_payload_generation_and_multipart_encoding(self):
data, headers = common._request_payload({"ä": "ö"}, None, None)
self.assertEqual(json.loads(data.decode("utf-8")), {"ä": "ö"})
self.assertEqual(headers["Content-Type"], common.APPLICATION_JSON)
data, headers = common._request_payload(None, {"value": 7}, None)
self.assertEqual(json.loads(data), {"value": 7})
self.assertEqual(headers["Accept"], common.APPLICATION_JSON)
data, headers = common._request_payload(None, None, None)
self.assertIsNone(data)
self.assertNotIn("Content-Type", headers)
with tempfile.TemporaryDirectory() as directory:
file_path = Path(directory) / 'a"b.txt'
file_path.write_bytes(b"content")
data, headers = common._request_payload(None, {"skip": None, "caption": "Text"}, {"file": file_path})
self.assertIn(b'filename="a_b.txt"', data)
self.assertIn(b'name="caption"', data)
self.assertNotIn(b'name="skip"', data)
self.assertTrue(headers["Content-Type"].startswith("multipart/form-data; boundary="))
with self.assertRaises(FileNotFoundError):
common.encode_multipart({}, {"file": Path(directory) / "missing.bin"})
def test_response_parsing_and_error_decoding(self):
self.assertIsNone(common.parse_json(b""))
self.assertEqual(common.parse_json(b'{"ok":true}'), {"ok": True})
self.assertIsNone(common.parse_json(b"\xff"))
self.assertIsNone(common.parse_json(b"{"))
self.assertIsNone(common.parse_response(b"", common.APPLICATION_JSON))
self.assertEqual(common.parse_response(b'{"ok":true}', "application/problem+json"), {"ok": True})
self.assertEqual(common.parse_response(b"plain", "text/plain"), "plain")
self.assertEqual(common.parse_response(b"not-json", common.APPLICATION_JSON), "not-json")
self.assertEqual(common.decode_error(b'{"message":"bad"}'), "bad")
self.assertEqual(common.decode_error(b'{"error":"wrong"}'), "wrong")
self.assertIn("value", common.decode_error(b'{"value":1}'))
self.assertEqual(common.decode_error(b" text "), "text")
self.assertEqual(common.decode_error(b""), "Unbekannter API-Fehler")
def test_client_request_success_retry_and_failures(self):
anonymous = common.ApiClient("http://localhost:47145")
self.assertIsNone(anonymous._authorization_header())
self.assertEqual(anonymous._request_headers({"Accept": common.APPLICATION_JSON}), {"Accept": common.APPLICATION_JSON})
client = common.ApiClient("http://localhost:47145", " user ", " pass ")
self.assertEqual(client.base_url, "http://localhost:47145/")
self.assertTrue(client._authorization_header().startswith("Basic "))
self.assertIn("Authorization", client._request_headers({"Accept": common.APPLICATION_JSON}))
with patch.object(common, "urlopen", return_value=FakeResponse(200, b'{"ok":true}')):
self.assertEqual(client.request("GET", "/api/health"), {"ok": True})
with patch.object(common, "urlopen", return_value=FakeResponse(204, b"", "text/plain")):
self.assertIsNone(client.request("DELETE", "/api/item", expected=(204,)))
with patch.object(common, "urlopen", return_value=FakeResponse(202, b'{"queued":true}')):
with self.assertRaises(common.ApiError) as context:
client.request("POST", "/api/item")
self.assertEqual(context.exception.status, 202)
unauthorized = HTTPError(
"http://localhost:47145/api/item",
401,
"Unauthorized",
{},
io.BytesIO(b'{"message":"login"}'),
)
with patch.object(common, "urlopen", side_effect=[unauthorized, FakeResponse(200, b'{"ok":true}')]), patch.object(
client, "prompt_credentials"
) as prompt:
self.assertEqual(client.request("POST", "/api/item"), {"ok": True})
prompt.assert_called_once_with()
forbidden = HTTPError(
"http://localhost:47145/api/item",
403,
"Forbidden",
{},
io.BytesIO(b'{"error":"denied"}'),
)
with patch.object(common, "urlopen", side_effect=forbidden):
with self.assertRaises(common.ApiError) as context:
client.request("POST", "/api/item")
self.assertEqual(context.exception.status, 403)
self.assertEqual(context.exception.payload, {"error": "denied"})
with patch.object(common, "urlopen", side_effect=URLError("offline")):
with self.assertRaisesRegex(RuntimeError, "offline"):
client.request("GET", "/api/health")
def test_argument_and_prompt_helpers(self):
parser = argparse.ArgumentParser()
common.add_common_arguments(parser)
args = parser.parse_args(["--base-url", "http://example.test", "--username", "alice"])
client = common.client_from_args(args)
self.assertEqual(client.base_url, "http://example.test/")
self.assertIsNone(client.credentials)
env_args = argparse.Namespace(base_url=None, username=None)
with patch.dict(os.environ, {"WEGWICHTEL_URL": "http://env.test"}, clear=True):
self.assertEqual(common.client_from_args(env_args).base_url, "http://env.test/")
with patch.dict(os.environ, {}, clear=True):
self.assertEqual(common.client_from_args(env_args).base_url, common.DEFAULT_BASE_URL + "/")
self.assertEqual(common.ask("Zahl", 4, cast=int), 4)
stderr = io.StringIO()
with patch("builtins.input", side_effect=["bad", "5"]), redirect_stderr(stderr):
self.assertEqual(common.ask("Zahl", required=True, cast=int), 5)
self.assertIn("Ungültiger Wert", stderr.getvalue())
with patch("builtins.input", return_value=""):
self.assertIsNone(common.ask("Optional"))
with patch("builtins.input", side_effect=["", "value"]), redirect_stderr(io.StringIO()):
self.assertEqual(common.ask("Pflicht", required=True), "value")
with patch("builtins.input", return_value=""):
self.assertEqual(common.ask_with_default("Wert", 3, int), 3)
with patch("builtins.input", side_effect=["bad", "4"]), redirect_stderr(io.StringIO()):
self.assertEqual(common.ask_with_default("Wert", 3, int), 4)
def test_path_confirmation_output_and_run_error_tracebacks(self):
with tempfile.TemporaryDirectory() as directory:
existing = Path(directory) / "file.txt"
existing.write_text("ok", encoding="utf-8")
self.assertEqual(common.ask_path("Datei", str(existing)), existing.resolve())
with self.assertRaises(FileNotFoundError):
common.ask_path("Datei", str(Path(directory) / "missing"))
with patch("builtins.input", return_value=""):
self.assertIsNone(common.ask_path("Optional", required=False))
with patch("builtins.input", side_effect=["", str(Path(directory) / "missing"), str(existing)]), redirect_stderr(
io.StringIO()
):
self.assertEqual(common.ask_path("Pflicht"), existing.resolve())
with patch("builtins.input", return_value="Ja"):
self.assertTrue(common.confirm("Fortfahren?"))
with patch("builtins.input", return_value="nein"):
self.assertFalse(common.confirm("Fortfahren?"))
stdout = io.StringIO()
with redirect_stdout(stdout):
common.print_result({"ok": True})
common.print_result("text")
self.assertIn('"ok": true', stdout.getvalue())
self.assertTrue(stdout.getvalue().rstrip().endswith("text"))
stdout = io.StringIO()
with redirect_stdout(stdout):
common.run(lambda: {"ok": True})
self.assertIn('"ok": true', stdout.getvalue())
stderr = io.StringIO()
with redirect_stderr(stderr), self.assertRaises(SystemExit) as context:
common.run(lambda: (_ for _ in ()).throw(ValueError("kaputt")))
self.assertEqual(context.exception.code, 1)
self.assertIn("Traceback", stderr.getvalue())
self.assertIn("ValueError: kaputt", stderr.getvalue())
stderr = io.StringIO()
with redirect_stderr(stderr), self.assertRaises(SystemExit) as context:
common.run(lambda: (_ for _ in ()).throw(KeyboardInterrupt()))
self.assertEqual(context.exception.code, 130)
self.assertIn("KeyboardInterrupt", stderr.getvalue())
if __name__ == "__main__":
unittest.main()

View File

@ -44,14 +44,23 @@ test('Python API tools are syntax-valid and contain reusable 401 authentication
await fs.rm(path.join(toolsDir, '__pycache__'), { recursive: true, force: true }); await fs.rm(path.join(toolsDir, '__pycache__'), { recursive: true, force: true });
const common = await read('tools/python/common.py'); const common = await read('tools/python/common.py');
const sonar = await read('sonar-project.properties');
const requirements = await read('requirements-dev.txt');
const pkg = JSON.parse(await read('package.json'));
assert.match(common, /class ApiClient/); assert.match(common, /class ApiClient/);
assert.match(common, /error\.code == 401/); assert.match(common, /error\.code != 401/);
assert.match(common, /MAX_AUTH_ATTEMPTS/);
assert.match(common, /self\.prompt_credentials\(\)/); assert.match(common, /self\.prompt_credentials\(\)/);
assert.match(common, /getpass\.getpass/); assert.match(common, /getpass\.getpass/);
assert.match(common, /os\.getenv\(API_USER_ENV/); assert.match(common, /os\.getenv\(API_USER_ENV/);
assert.match(common, /os\.getenv\(API_PASSWORD_ENV/); assert.match(common, /os\.getenv\(API_PASSWORD_ENV/);
assert.match(common, /def encode_multipart/); assert.match(common, /def encode_multipart/);
assert.match(common, /traceback\.print_exception/);
assert.doesNotMatch(common, /Content-Type:.*application\/octet-stream/); assert.doesNotMatch(common, /Content-Type:.*application\/octet-stream/);
assert.match(pkg.scripts['test:coverage'], /test:coverage:python/);
assert.match(pkg.scripts['test:coverage:python'], /coverage\/python\/coverage\.xml/);
assert.match(sonar, /sonar\.python\.coverage\.reportPaths=coverage\/python\/coverage\.xml/);
assert.match(requirements, /^coverage==/m);
}); });

View File

@ -1,6 +1,6 @@
# Python-Werkzeuge für die Wegwichtel-REST-API # Python-Werkzeuge für die Wegwichtel-REST-API
Die Skripte benötigen nur Python 3 und die Standardbibliothek. Node.js, `curl` und zusätzliche Python-Pakete sind auf dem aufrufenden Rechner nicht erforderlich. Die Skripte benötigen für den regulären Betrieb nur Python 3 und die Standardbibliothek. Node.js, `curl` und zusätzliche Python-Pakete sind auf dem aufrufenden Rechner nicht erforderlich. Nur die automatisierte Coverage-Erzeugung verwendet das in `requirements-dev.txt` festgelegte Paket `coverage`.
## Authentifizierung ## Authentifizierung
@ -64,3 +64,17 @@ python3 tools/python/create_picture.py \
``` ```
Die Multipart-Hilfsfunktion sendet für Dateien absichtlich keinen behaupteten MIME-Typ. Der Server bestimmt das Format aus dem Dateiinhalt. Die Multipart-Hilfsfunktion sendet für Dateien absichtlich keinen behaupteten MIME-Typ. Der Server bestimmt das Format aus dem Dateiinhalt.
## Fehlerausgabe und Tests
Alle Werkzeuge kapseln ihre Aktion in der gemeinsamen `run()`-Funktion. Bei API-, Datei-, Eingabe- oder unerwarteten Laufzeitfehlern werden der Fehlertyp, die Fehlermeldung und der vollständige Python-Stacktrace auf `stderr` ausgegeben. Der Prozess endet anschließend mit Exitcode `1`; ein manueller Abbruch per Tastatur verwendet Exitcode `130`.
Die Python-Tests und der Cobertura-kompatible Bericht werden über die NPM-Skripte ausgeführt:
```bash
python3 -m pip install -r requirements-dev.txt
npm run test:python
npm run test:coverage:python
```
Der Bericht liegt anschließend unter `coverage/python/coverage.xml` und wird über `sonar.python.coverage.reportPaths` von SonarQube eingelesen.

View File

@ -1,14 +1,27 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""Hängt GPX-Punkte an eine vorhandene Route an."""
import argparse import argparse
from collections.abc import Sequence
from common import add_common_arguments, ask, ask_path, client_from_args, run from common import add_common_arguments, ask, ask_path, client_from_args, run
parser = argparse.ArgumentParser(description="GPX-Punkte an eine Route anhängen")
add_common_arguments(parser)
parser.add_argument("--route-id", type=int) def build_parser() -> argparse.ArgumentParser:
parser.add_argument("--gpx") parser = argparse.ArgumentParser(description="GPX-Punkte an eine Route anhängen")
args = parser.parse_args() add_common_arguments(parser)
def action(): parser.add_argument("--route-id", type=int)
parser.add_argument("--gpx")
return parser
def main(argv: Sequence[str] | None = None):
args = build_parser().parse_args(argv)
client = client_from_args(args) client = client_from_args(args)
route_id = ask("Route-ID", args.route_id, required=True, cast=int) route_id = ask("Route-ID", args.route_id, required=True, cast=int)
gpx = ask_path("GPX-Datei", args.gpx) gpx = ask_path("GPX-Datei", args.gpx)
return client.request("POST", f"/api/routes/{route_id}/append", files={"gpx": gpx}) return client.request("POST", f"/api/routes/{route_id}/append", files={"gpx": gpx})
run(action)
if __name__ == "__main__":
run(main)

View File

@ -9,6 +9,7 @@ import getpass
import json import json
import os import os
import sys import sys
import traceback
import uuid import uuid
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path from pathlib import Path
@ -20,23 +21,30 @@ from urllib.request import Request, urlopen
DEFAULT_BASE_URL = "https://wegwichtel.de" DEFAULT_BASE_URL = "https://wegwichtel.de"
API_USER_ENV = "API_USER" API_USER_ENV = "API_USER"
API_PASSWORD_ENV = "API_PASSWORD" API_PASSWORD_ENV = "API_PASSWORD"
APPLICATION_JSON = "application/json"
MAX_AUTH_ATTEMPTS = 3
REQUEST_TIMEOUT_SECONDS = 120
class ApiError(RuntimeError): class ApiError(RuntimeError):
"""Fehlerantwort der REST-API einschließlich HTTP-Status und Nutzdaten."""
def __init__(self, status: int, message: str, payload: Any = None): def __init__(self, status: int, message: str, payload: Any = None):
super().__init__(f"HTTP {status}: {message}") super().__init__(f"HTTP {status}: {message}")
self.status = status self.status = status
self.payload = payload self.payload = payload
@dataclass @dataclass(frozen=True)
class Credentials: class Credentials:
"""Zugangsdaten für eine nach HTTP 401 angeforderte Basic-Authentifizierung."""
username: str username: str
password: str password: str
def credentials_from_environment() -> Credentials | None: def credentials_from_environment() -> Credentials | None:
"""Liest CI-Credentials, ohne einen Request vor dem ersten HTTP 401 zu authentifizieren.""" """Liest CI-Credentials, ohne den ersten Request vorab zu authentifizieren."""
username = os.getenv(API_USER_ENV, "").strip() username = os.getenv(API_USER_ENV, "").strip()
password = os.getenv(API_PASSWORD_ENV, "").strip() password = os.getenv(API_PASSWORD_ENV, "").strip()
if username and password: if username and password:
@ -44,12 +52,35 @@ def credentials_from_environment() -> Credentials | None:
return None return None
def _json_bytes(value: dict[str, Any]) -> bytes:
return json.dumps(value, ensure_ascii=False).encode("utf-8")
def _request_payload(
json_body: dict[str, Any] | None,
fields: dict[str, Any] | None,
files: dict[str, Path] | None,
) -> tuple[bytes | None, dict[str, str]]:
headers = {"Accept": APPLICATION_JSON}
if files:
data, content_type = encode_multipart(fields or {}, files)
headers["Content-Type"] = content_type
return data, headers
if json_body is not None:
headers["Content-Type"] = APPLICATION_JSON
return _json_bytes(json_body), headers
if fields:
headers["Content-Type"] = APPLICATION_JSON
return _json_bytes(fields), headers
return None, headers
class ApiClient: class ApiClient:
"""Kleine HTTP-Schicht mit dynamischer Basic-Auth-Nachfrage bei 401.""" """Kleine HTTP-Schicht mit dynamischer Basic-Auth-Nachfrage bei HTTP 401."""
def __init__(self, base_url: str, username: str | None = None, password: str | None = None): def __init__(self, base_url: str, username: str | None = None, password: str | None = None):
self.base_url = base_url.rstrip("/") + "/" self.base_url = base_url.rstrip("/") + "/"
self.credentials = Credentials(username, password) if username and password else None self.credentials = Credentials(username.strip(), password.strip()) if username and password else None
def prompt_credentials(self) -> None: def prompt_credentials(self) -> None:
environment_credentials = credentials_from_environment() environment_credentials = credentials_from_environment()
@ -57,21 +88,60 @@ class ApiClient:
self.credentials = environment_credentials self.credentials = environment_credentials
return return
default_user = self.credentials.username if self.credentials else os.getenv(API_USER_ENV, "") default_user = self.credentials.username if self.credentials else os.getenv(API_USER_ENV, "").strip()
username = input(f"Benutzername{f' [{default_user}]' if default_user else ''}: ").strip() or default_user suffix = f" [{default_user}]" if default_user else ""
username = input(f"Benutzername{suffix}: ").strip() or default_user
if not username: if not username:
raise ApiError(401, "Für den geschützten API-Zugriff ist ein Benutzername erforderlich.") raise ApiError(401, "Für den geschützten API-Zugriff ist ein Benutzername erforderlich.")
password = getpass.getpass("Passwort: ")
password = getpass.getpass("Passwort: ").strip()
if not password:
raise ApiError(401, "Für den geschützten API-Zugriff ist ein Passwort erforderlich.")
self.credentials = Credentials(username, password) self.credentials = Credentials(username, password)
def _authorization_header(self) -> str | None: def _authorization_header(self) -> str | None:
if not self.credentials: if not self.credentials:
return None return None
token = base64.b64encode( value = f"{self.credentials.username}:{self.credentials.password}".encode("utf-8")
f"{self.credentials.username}:{self.credentials.password}".encode("utf-8") token = base64.b64encode(value).decode("ascii")
).decode("ascii")
return f"Basic {token}" return f"Basic {token}"
def _request_headers(self, headers: dict[str, str]) -> dict[str, str]:
request_headers = dict(headers)
authorization = self._authorization_header()
if authorization:
request_headers["Authorization"] = authorization
return request_headers
def _perform_request(
self,
method: str,
url: str,
data: bytes | None,
headers: dict[str, str],
expected: tuple[int, ...],
) -> Any:
request = Request(
url,
data=data,
headers=self._request_headers(headers),
method=method.upper(),
)
with urlopen(request, timeout=REQUEST_TIMEOUT_SECONDS) as response:
raw = response.read()
if response.status not in expected:
raise ApiError(response.status, decode_error(raw), parse_json(raw))
return parse_response(raw, response.headers.get("Content-Type", ""))
def _handle_http_error(self, error: HTTPError, attempt: int) -> bool:
if error.code != 401 or attempt >= MAX_AUTH_ATTEMPTS:
return False
error.read()
error.close()
print("Die API verlangt Zugangsdaten (HTTP 401).", file=sys.stderr)
self.prompt_credentials()
return True
def request( def request(
self, self,
method: str, method: str,
@ -82,42 +152,18 @@ class ApiClient:
files: dict[str, Path] | None = None, files: dict[str, Path] | None = None,
expected: tuple[int, ...] = (200, 201), expected: tuple[int, ...] = (200, 201),
) -> Any: ) -> Any:
data: bytes | None = None data, headers = _request_payload(json_body, fields, files)
headers = {"Accept": "application/json"}
if files:
data, content_type = encode_multipart(fields or {}, files)
headers["Content-Type"] = content_type
elif json_body is not None:
data = json.dumps(json_body, ensure_ascii=False).encode("utf-8")
headers["Content-Type"] = "application/json"
elif fields:
data = json.dumps(fields, ensure_ascii=False).encode("utf-8")
headers["Content-Type"] = "application/json"
url = urljoin(self.base_url, path.lstrip("/")) url = urljoin(self.base_url, path.lstrip("/"))
attempts = 0
while True:
request_headers = dict(headers)
authorization = self._authorization_header()
if authorization:
request_headers["Authorization"] = authorization
request = Request(url, data=data, headers=request_headers, method=method.upper()) attempt = 0
while True:
try: try:
with urlopen(request, timeout=120) as response: return self._perform_request(method, url, data, headers, expected)
raw = response.read()
status = response.status
if status not in expected:
raise ApiError(status, decode_error(raw), parse_json(raw))
return parse_response(raw, response.headers.get("Content-Type", ""))
except HTTPError as error: except HTTPError as error:
raw = error.read() if self._handle_http_error(error, attempt):
if error.code == 401 and attempts < 3: attempt += 1
print("Die API verlangt Zugangsdaten (HTTP 401).", file=sys.stderr)
self.prompt_credentials()
attempts += 1
continue continue
raw = error.read()
raise ApiError(error.code, decode_error(raw), parse_json(raw)) from error raise ApiError(error.code, decode_error(raw), parse_json(raw)) from error
except URLError as error: except URLError as error:
raise RuntimeError(f"API nicht erreichbar: {error.reason}") from error raise RuntimeError(f"API nicht erreichbar: {error.reason}") from error
@ -247,9 +293,18 @@ def print_result(result: Any) -> None:
print(json.dumps(result, ensure_ascii=False, indent=2) if not isinstance(result, str) else result) print(json.dumps(result, ensure_ascii=False, indent=2) if not isinstance(result, str) else result)
def _print_failure(error: BaseException) -> None:
print(f"Fehler: {type(error).__name__}: {error}", file=sys.stderr)
traceback.print_exception(type(error), error, error.__traceback__, file=sys.stderr)
def run(action: Callable[[], Any]) -> None: def run(action: Callable[[], Any]) -> None:
"""Führt eine Werkzeugaktion aus und gibt Fehler samt vollständigem Stacktrace aus."""
try: try:
print_result(action()) print_result(action())
except (ApiError, RuntimeError, FileNotFoundError, ValueError) as error: except KeyboardInterrupt as error:
print(f"Fehler: {error}", file=sys.stderr) _print_failure(error)
raise SystemExit(130) from error
except Exception as error:
_print_failure(error)
raise SystemExit(1) from error raise SystemExit(1) from error

View File

@ -1,16 +1,29 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""Lädt eine Audiodatei zu einem POI hoch."""
import argparse import argparse
from collections.abc import Sequence
from common import add_common_arguments, ask, ask_path, client_from_args, run from common import add_common_arguments, ask, ask_path, client_from_args, run
parser = argparse.ArgumentParser(description="Audiodatei zu einem POI hochladen")
add_common_arguments(parser)
parser.add_argument("--route-id", type=int) def build_parser() -> argparse.ArgumentParser:
parser.add_argument("--poi-id", type=int) parser = argparse.ArgumentParser(description="Audiodatei zu einem POI hochladen")
parser.add_argument("--file") add_common_arguments(parser)
args = parser.parse_args() parser.add_argument("--route-id", type=int)
def action(): parser.add_argument("--poi-id", type=int)
parser.add_argument("--file")
return parser
def main(argv: Sequence[str] | None = None):
args = build_parser().parse_args(argv)
client = client_from_args(args) client = client_from_args(args)
route_id = ask("Route-ID", args.route_id, required=True, cast=int) route_id = ask("Route-ID", args.route_id, required=True, cast=int)
poi_id = ask("POI-ID", args.poi_id, required=True, cast=int) poi_id = ask("POI-ID", args.poi_id, required=True, cast=int)
audio = ask_path("Audiodatei", args.file) audio = ask_path("Audiodatei", args.file)
return client.request("POST", f"/api/routes/{route_id}/pois/{poi_id}/audio", files={"audio": audio}) return client.request("POST", f"/api/routes/{route_id}/pois/{poi_id}/audio", files={"audio": audio})
run(action)
if __name__ == "__main__":
run(main)

View File

@ -1,15 +1,25 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""Lädt ein Bild zu einem POI hoch."""
import argparse import argparse
from collections.abc import Sequence
from common import add_common_arguments, ask, ask_path, client_from_args, run from common import add_common_arguments, ask, ask_path, client_from_args, run
parser = argparse.ArgumentParser(description="Bild zu einem POI hochladen")
add_common_arguments(parser)
parser.add_argument("--route-id", type=int) def build_parser() -> argparse.ArgumentParser:
parser.add_argument("--poi-id", type=int) parser = argparse.ArgumentParser(description="Bild zu einem POI hochladen")
parser.add_argument("--file") add_common_arguments(parser)
parser.add_argument("--caption") parser.add_argument("--route-id", type=int)
parser.add_argument("--sequence", type=int) parser.add_argument("--poi-id", type=int)
args = parser.parse_args() parser.add_argument("--file")
def action(): parser.add_argument("--caption")
parser.add_argument("--sequence", type=int)
return parser
def main(argv: Sequence[str] | None = None):
args = build_parser().parse_args(argv)
client = client_from_args(args) client = client_from_args(args)
route_id = ask("Route-ID", args.route_id, required=True, cast=int) route_id = ask("Route-ID", args.route_id, required=True, cast=int)
poi_id = ask("POI-ID", args.poi_id, required=True, cast=int) poi_id = ask("POI-ID", args.poi_id, required=True, cast=int)
@ -18,5 +28,13 @@ def action():
"caption": ask("Bildbeschreibung", args.caption), "caption": ask("Bildbeschreibung", args.caption),
"sequence": ask("Reihenfolge (leer: automatisch)", args.sequence, cast=int), "sequence": ask("Reihenfolge (leer: automatisch)", args.sequence, cast=int),
} }
return client.request("POST", f"/api/routes/{route_id}/pois/{poi_id}/pictures", fields=fields, files={"picture": picture}) return client.request(
run(action) "POST",
f"/api/routes/{route_id}/pois/{poi_id}/pictures",
fields=fields,
files={"picture": picture},
)
if __name__ == "__main__":
run(main)

View File

@ -1,15 +1,27 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""Legt einen POI für eine Route an."""
import argparse import argparse
from collections.abc import Sequence
from common import add_common_arguments, ask, client_from_args, run from common import add_common_arguments, ask, client_from_args, run
parser = argparse.ArgumentParser(description="POI anlegen")
add_common_arguments(parser)
for name, kwargs in [ def build_parser() -> argparse.ArgumentParser:
("--route-id", {"type": int}), ("--title", {}), ("--description", {}), parser = argparse.ArgumentParser(description="POI anlegen")
("--lat", {"type": float}), ("--lon", {"type": float}), add_common_arguments(parser)
("--trigger-radius", {"type": float}), ("--sequence", {"type": int}) parser.add_argument("--route-id", type=int)
]: parser.add_argument(name, **kwargs) parser.add_argument("--title")
args = parser.parse_args() parser.add_argument("--description")
def action(): parser.add_argument("--lat", type=float)
parser.add_argument("--lon", type=float)
parser.add_argument("--trigger-radius", type=float)
parser.add_argument("--sequence", type=int)
return parser
def main(argv: Sequence[str] | None = None):
args = build_parser().parse_args(argv)
client = client_from_args(args) client = client_from_args(args)
route_id = ask("Route-ID", args.route_id, required=True, cast=int) route_id = ask("Route-ID", args.route_id, required=True, cast=int)
body = { body = {
@ -20,5 +32,9 @@ def action():
"triggerRadiusM": ask("Auslöseradius in Metern", args.trigger_radius, cast=float), "triggerRadiusM": ask("Auslöseradius in Metern", args.trigger_radius, cast=float),
"sequence": ask("Reihenfolge", args.sequence, cast=int), "sequence": ask("Reihenfolge", args.sequence, cast=int),
} }
return client.request("POST", f"/api/routes/{route_id}/pois", json_body={k:v for k,v in body.items() if v is not None}) payload = {key: value for key, value in body.items() if value is not None}
run(action) return client.request("POST", f"/api/routes/{route_id}/pois", json_body=payload)
if __name__ == "__main__":
run(main)

View File

@ -1,17 +1,25 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""Legt eine Route aus einer GPX-Datei an."""
import argparse import argparse
from collections.abc import Sequence
from common import add_common_arguments, ask, ask_path, client_from_args, run from common import add_common_arguments, ask, ask_path, client_from_args, run
parser = argparse.ArgumentParser(description="Neue Wegwichtel-Route anlegen")
add_common_arguments(parser)
parser.add_argument("--gpx")
parser.add_argument("--name")
parser.add_argument("--slug")
parser.add_argument("--description")
parser.add_argument("--school-name")
args = parser.parse_args()
def action(): def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description="Neue Wegwichtel-Route anlegen")
add_common_arguments(parser)
parser.add_argument("--gpx")
parser.add_argument("--name")
parser.add_argument("--slug")
parser.add_argument("--description")
parser.add_argument("--school-name")
return parser
def main(argv: Sequence[str] | None = None):
args = build_parser().parse_args(argv)
client = client_from_args(args) client = client_from_args(args)
gpx = ask_path("GPX-Datei", args.gpx) gpx = ask_path("GPX-Datei", args.gpx)
fields = { fields = {
@ -21,4 +29,7 @@ def action():
"schoolName": ask("Schule", args.school_name), "schoolName": ask("Schule", args.school_name),
} }
return client.request("POST", "/api/routes", fields=fields, files={"gpx": gpx}) return client.request("POST", "/api/routes", fields=fields, files={"gpx": gpx})
run(action)
if __name__ == "__main__":
run(main)

View File

@ -1,16 +1,29 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""Löscht die Audiodatei eines POIs."""
import argparse import argparse
from collections.abc import Sequence
from common import add_common_arguments, ask, client_from_args, confirm, run from common import add_common_arguments, ask, client_from_args, confirm, run
parser = argparse.ArgumentParser(description="Audiodatei eines POIs löschen")
add_common_arguments(parser)
parser.add_argument("--route-id", type=int) def build_parser() -> argparse.ArgumentParser:
parser.add_argument("--poi-id", type=int) parser = argparse.ArgumentParser(description="Audiodatei eines POIs löschen")
args = parser.parse_args() add_common_arguments(parser)
def action(): parser.add_argument("--route-id", type=int)
parser.add_argument("--poi-id", type=int)
return parser
def main(argv: Sequence[str] | None = None):
args = build_parser().parse_args(argv)
client = client_from_args(args) client = client_from_args(args)
route_id = ask("Route-ID", args.route_id, required=True, cast=int) route_id = ask("Route-ID", args.route_id, required=True, cast=int)
poi_id = ask("POI-ID", args.poi_id, required=True, cast=int) poi_id = ask("POI-ID", args.poi_id, required=True, cast=int)
if not confirm(f"Audio für POI {poi_id} löschen?"): if not confirm(f"Audio für POI {poi_id} löschen?"):
return {"cancelled": True} return {"cancelled": True}
return client.request("DELETE", f"/api/routes/{route_id}/pois/{poi_id}/audio") return client.request("DELETE", f"/api/routes/{route_id}/pois/{poi_id}/audio")
run(action)
if __name__ == "__main__":
run(main)

View File

@ -1,13 +1,23 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""Löscht ein Bild eines POIs."""
import argparse import argparse
from collections.abc import Sequence
from common import add_common_arguments, ask, client_from_args, confirm, run from common import add_common_arguments, ask, client_from_args, confirm, run
parser = argparse.ArgumentParser(description="Ein Bild löschen")
add_common_arguments(parser)
parser.add_argument("--route-id", type=int) def build_parser() -> argparse.ArgumentParser:
parser.add_argument("--poi-id", type=int) parser = argparse.ArgumentParser(description="Ein Bild löschen")
parser.add_argument("--picture-id", type=int) add_common_arguments(parser)
args = parser.parse_args() parser.add_argument("--route-id", type=int)
def action(): parser.add_argument("--poi-id", type=int)
parser.add_argument("--picture-id", type=int)
return parser
def main(argv: Sequence[str] | None = None):
args = build_parser().parse_args(argv)
client = client_from_args(args) client = client_from_args(args)
route_id = ask("Route-ID", args.route_id, required=True, cast=int) route_id = ask("Route-ID", args.route_id, required=True, cast=int)
poi_id = ask("POI-ID", args.poi_id, required=True, cast=int) poi_id = ask("POI-ID", args.poi_id, required=True, cast=int)
@ -15,4 +25,7 @@ def action():
if not confirm(f"Bild {picture_id} löschen?"): if not confirm(f"Bild {picture_id} löschen?"):
return {"cancelled": True} return {"cancelled": True}
return client.request("DELETE", f"/api/routes/{route_id}/pois/{poi_id}/pictures/{picture_id}") return client.request("DELETE", f"/api/routes/{route_id}/pois/{poi_id}/pictures/{picture_id}")
run(action)
if __name__ == "__main__":
run(main)

View File

@ -1,16 +1,29 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""Löscht einen POI einschließlich seiner Medien."""
import argparse import argparse
from collections.abc import Sequence
from common import add_common_arguments, ask, client_from_args, confirm, run from common import add_common_arguments, ask, client_from_args, confirm, run
parser = argparse.ArgumentParser(description="POI einschließlich Medien löschen")
add_common_arguments(parser)
parser.add_argument("--route-id", type=int) def build_parser() -> argparse.ArgumentParser:
parser.add_argument("--poi-id", type=int) parser = argparse.ArgumentParser(description="POI einschließlich Medien löschen")
args = parser.parse_args() add_common_arguments(parser)
def action(): parser.add_argument("--route-id", type=int)
parser.add_argument("--poi-id", type=int)
return parser
def main(argv: Sequence[str] | None = None):
args = build_parser().parse_args(argv)
client = client_from_args(args) client = client_from_args(args)
route_id = ask("Route-ID", args.route_id, required=True, cast=int) route_id = ask("Route-ID", args.route_id, required=True, cast=int)
poi_id = ask("POI-ID", args.poi_id, required=True, cast=int) poi_id = ask("POI-ID", args.poi_id, required=True, cast=int)
if not confirm(f"POI {poi_id} einschließlich Bilder und Audio löschen?"): if not confirm(f"POI {poi_id} einschließlich Bilder und Audio löschen?"):
return {"cancelled": True} return {"cancelled": True}
return client.request("DELETE", f"/api/routes/{route_id}/pois/{poi_id}") return client.request("DELETE", f"/api/routes/{route_id}/pois/{poi_id}")
run(action)
if __name__ == "__main__":
run(main)

View File

@ -1,14 +1,27 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""Verschiebt eine Route in den Papierkorb."""
import argparse import argparse
from collections.abc import Sequence
from common import add_common_arguments, ask, client_from_args, confirm, run from common import add_common_arguments, ask, client_from_args, confirm, run
parser = argparse.ArgumentParser(description="Route weich löschen")
add_common_arguments(parser)
parser.add_argument("--route-id", type=int) def build_parser() -> argparse.ArgumentParser:
args = parser.parse_args() parser = argparse.ArgumentParser(description="Route weich löschen")
def action(): add_common_arguments(parser)
parser.add_argument("--route-id", type=int)
return parser
def main(argv: Sequence[str] | None = None):
args = build_parser().parse_args(argv)
client = client_from_args(args) client = client_from_args(args)
route_id = ask("Route-ID", args.route_id, required=True, cast=int) route_id = ask("Route-ID", args.route_id, required=True, cast=int)
if not confirm(f"Route {route_id} in den Papierkorb verschieben?"): if not confirm(f"Route {route_id} in den Papierkorb verschieben?"):
return {"cancelled": True} return {"cancelled": True}
return client.request("DELETE", f"/api/routes/{route_id}") return client.request("DELETE", f"/api/routes/{route_id}")
run(action)
if __name__ == "__main__":
run(main)

View File

@ -1,12 +1,25 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""Stellt eine gelöschte Route wieder her."""
import argparse import argparse
from collections.abc import Sequence
from common import add_common_arguments, ask, client_from_args, run from common import add_common_arguments, ask, client_from_args, run
parser = argparse.ArgumentParser(description="Gelöschte Route wiederherstellen")
add_common_arguments(parser)
parser.add_argument("--route-id", type=int) def build_parser() -> argparse.ArgumentParser:
args = parser.parse_args() parser = argparse.ArgumentParser(description="Gelöschte Route wiederherstellen")
def action(): add_common_arguments(parser)
parser.add_argument("--route-id", type=int)
return parser
def main(argv: Sequence[str] | None = None):
args = build_parser().parse_args(argv)
client = client_from_args(args) client = client_from_args(args)
route_id = ask("Route-ID", args.route_id, required=True, cast=int) route_id = ask("Route-ID", args.route_id, required=True, cast=int)
return client.request("POST", f"/api/routes/{route_id}/restore", json_body={}) return client.request("POST", f"/api/routes/{route_id}/restore", json_body={})
run(action)
if __name__ == "__main__":
run(main)

View File

@ -1,16 +1,29 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""Ersetzt die Audiodatei eines POIs."""
import argparse import argparse
from collections.abc import Sequence
from common import add_common_arguments, ask, ask_path, client_from_args, run from common import add_common_arguments, ask, ask_path, client_from_args, run
parser = argparse.ArgumentParser(description="Audiodatei eines POIs ersetzen")
add_common_arguments(parser)
parser.add_argument("--route-id", type=int) def build_parser() -> argparse.ArgumentParser:
parser.add_argument("--poi-id", type=int) parser = argparse.ArgumentParser(description="Audiodatei eines POIs ersetzen")
parser.add_argument("--file") add_common_arguments(parser)
args = parser.parse_args() parser.add_argument("--route-id", type=int)
def action(): parser.add_argument("--poi-id", type=int)
parser.add_argument("--file")
return parser
def main(argv: Sequence[str] | None = None):
args = build_parser().parse_args(argv)
client = client_from_args(args) client = client_from_args(args)
route_id = ask("Route-ID", args.route_id, required=True, cast=int) route_id = ask("Route-ID", args.route_id, required=True, cast=int)
poi_id = ask("POI-ID", args.poi_id, required=True, cast=int) poi_id = ask("POI-ID", args.poi_id, required=True, cast=int)
audio = ask_path("Neue Audiodatei", args.file) audio = ask_path("Neue Audiodatei", args.file)
return client.request("PUT", f"/api/routes/{route_id}/pois/{poi_id}/audio", files={"audio": audio}) return client.request("PUT", f"/api/routes/{route_id}/pois/{poi_id}/audio", files={"audio": audio})
run(action)
if __name__ == "__main__":
run(main)

View File

@ -1,26 +1,39 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""Ändert Bildmetadaten und ersetzt optional die Bilddatei."""
import argparse import argparse
from collections.abc import Sequence
from common import add_common_arguments, ask, ask_path, ask_with_default, client_from_args, run from common import add_common_arguments, ask, ask_path, ask_with_default, client_from_args, run
parser = argparse.ArgumentParser(description="Bilddatei oder Bildmetadaten ändern")
add_common_arguments(parser)
parser.add_argument("--route-id", type=int) def build_parser() -> argparse.ArgumentParser:
parser.add_argument("--poi-id", type=int) parser = argparse.ArgumentParser(description="Bilddatei oder Bildmetadaten ändern")
parser.add_argument("--picture-id", type=int) add_common_arguments(parser)
parser.add_argument("--file") parser.add_argument("--route-id", type=int)
args = parser.parse_args() parser.add_argument("--poi-id", type=int)
def action(): parser.add_argument("--picture-id", type=int)
parser.add_argument("--file")
return parser
def main(argv: Sequence[str] | None = None):
args = build_parser().parse_args(argv)
client = client_from_args(args) client = client_from_args(args)
route_id = ask("Route-ID", args.route_id, required=True, cast=int) route_id = ask("Route-ID", args.route_id, required=True, cast=int)
poi_id = ask("POI-ID", args.poi_id, required=True, cast=int) poi_id = ask("POI-ID", args.poi_id, required=True, cast=int)
picture_id = ask("Bild-ID", args.picture_id, required=True, cast=int) picture_id = ask("Bild-ID", args.picture_id, required=True, cast=int)
current = client.request("GET", f"/api/routes/{route_id}/pois/{poi_id}/pictures/{picture_id}?metadata=true") path = f"/api/routes/{route_id}/pois/{poi_id}/pictures/{picture_id}"
current = client.request("GET", f"{path}?metadata=true")
fields = { fields = {
"caption": ask_with_default("Bildbeschreibung", current.get("caption", "")), "caption": ask_with_default("Bildbeschreibung", current.get("caption", "")),
"sequence": ask_with_default("Reihenfolge", current.get("sequence", 0), int), "sequence": ask_with_default("Reihenfolge", current.get("sequence", 0), int),
} }
picture = ask_path("Neue Bilddatei (leer: unverändert)", args.file, required=False) picture = ask_path("Neue Bilddatei (leer: unverändert)", args.file, required=False)
path = f"/api/routes/{route_id}/pois/{poi_id}/pictures/{picture_id}"
if picture: if picture:
return client.request("PUT", path, fields=fields, files={"picture": picture}) return client.request("PUT", path, fields=fields, files={"picture": picture})
return client.request("PUT", path, json_body=fields) return client.request("PUT", path, json_body=fields)
run(action)
if __name__ == "__main__":
run(main)

View File

@ -1,16 +1,27 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""Ändert die Metadaten eines POIs."""
import argparse import argparse
from collections.abc import Sequence
from common import add_common_arguments, ask, ask_with_default, client_from_args, run from common import add_common_arguments, ask, ask_with_default, client_from_args, run
parser = argparse.ArgumentParser(description="POI ändern")
add_common_arguments(parser)
parser.add_argument("--route-id", type=int) def build_parser() -> argparse.ArgumentParser:
parser.add_argument("--poi-id", type=int) parser = argparse.ArgumentParser(description="POI ändern")
args = parser.parse_args() add_common_arguments(parser)
def action(): parser.add_argument("--route-id", type=int)
parser.add_argument("--poi-id", type=int)
return parser
def main(argv: Sequence[str] | None = None):
args = build_parser().parse_args(argv)
client = client_from_args(args) client = client_from_args(args)
route_id = ask("Route-ID", args.route_id, required=True, cast=int) route_id = ask("Route-ID", args.route_id, required=True, cast=int)
poi_id = ask("POI-ID", args.poi_id, required=True, cast=int) poi_id = ask("POI-ID", args.poi_id, required=True, cast=int)
current = client.request("GET", f"/api/routes/{route_id}/pois/{poi_id}") path = f"/api/routes/{route_id}/pois/{poi_id}"
current = client.request("GET", path)
body = { body = {
"title": ask_with_default("Titel", current.get("title", "")), "title": ask_with_default("Titel", current.get("title", "")),
"description": ask_with_default("Beschreibung", current.get("description", "")), "description": ask_with_default("Beschreibung", current.get("description", "")),
@ -19,5 +30,8 @@ def action():
"triggerRadiusM": ask_with_default("Auslöseradius in Metern", current.get("triggerRadiusM"), float), "triggerRadiusM": ask_with_default("Auslöseradius in Metern", current.get("triggerRadiusM"), float),
"sequence": ask_with_default("Reihenfolge", current.get("sequence"), int), "sequence": ask_with_default("Reihenfolge", current.get("sequence"), int),
} }
return client.request("PUT", f"/api/routes/{route_id}/pois/{poi_id}", json_body=body) return client.request("PUT", path, json_body=body)
run(action)
if __name__ == "__main__":
run(main)

View File

@ -1,17 +1,26 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""Ändert Routendaten und ersetzt optional die GPX-Datei."""
import argparse import argparse
from collections.abc import Sequence
from common import add_common_arguments, ask, ask_path, ask_with_default, client_from_args, run from common import add_common_arguments, ask, ask_path, ask_with_default, client_from_args, run
parser = argparse.ArgumentParser(description="Wegwichtel-Route ändern")
add_common_arguments(parser)
parser.add_argument("--route-id", type=int)
parser.add_argument("--gpx")
args = parser.parse_args()
def action(): def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description="Wegwichtel-Route ändern")
add_common_arguments(parser)
parser.add_argument("--route-id", type=int)
parser.add_argument("--gpx")
return parser
def main(argv: Sequence[str] | None = None):
args = build_parser().parse_args(argv)
client = client_from_args(args) client = client_from_args(args)
route_id = ask("Route-ID", args.route_id, required=True, cast=int) route_id = ask("Route-ID", args.route_id, required=True, cast=int)
current = client.request("GET", f"/api/routes/{route_id}") path = f"/api/routes/{route_id}"
current = client.request("GET", path)
fields = { fields = {
"name": ask_with_default("Name", current.get("name", "")), "name": ask_with_default("Name", current.get("name", "")),
"description": ask_with_default("Beschreibung", current.get("description", "")), "description": ask_with_default("Beschreibung", current.get("description", "")),
@ -19,6 +28,9 @@ def action():
} }
gpx = ask_path("Neue GPX-Datei (leer: unverändert)", args.gpx, required=False) gpx = ask_path("Neue GPX-Datei (leer: unverändert)", args.gpx, required=False)
if gpx: if gpx:
return client.request("PUT", f"/api/routes/{route_id}", fields=fields, files={"gpx": gpx}) return client.request("PUT", path, fields=fields, files={"gpx": gpx})
return client.request("PUT", f"/api/routes/{route_id}", json_body=fields) return client.request("PUT", path, json_body=fields)
run(action)
if __name__ == "__main__":
run(main)