fixed sonarqube scan issues
Some checks failed
Sonarqube Scanner / Build and analyze (push) Failing after 22s
Some checks failed
Sonarqube Scanner / Build and analyze (push) Failing after 22s
This commit is contained in:
parent
2442f8d458
commit
95026aac6e
11
.coveragerc
Normal file
11
.coveragerc
Normal 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
4
.gitignore
vendored
@ -11,3 +11,7 @@ npm-debug.log*
|
|||||||
coverage/
|
coverage/
|
||||||
|
|
||||||
.gitea/workflows/
|
.gitea/workflows/
|
||||||
|
|
||||||
|
.coverage
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
|||||||
12
README.md
12
README.md
@ -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
4
package-lock.json
generated
@ -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",
|
||||||
|
|||||||
11
package.json
11
package.json
@ -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
1
requirements-dev.txt
Normal file
@ -0,0 +1 @@
|
|||||||
|
coverage==7.13.3
|
||||||
2
sonar-project.properties
Normal file
2
sonar-project.properties
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
sonar.javascript.lcov.reportPaths=coverage/js/lcov.info
|
||||||
|
sonar.python.coverage.reportPaths=coverage/python/coverage.xml
|
||||||
@ -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}`);
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
175
test/python/test_commands.py
Normal file
175
test/python/test_commands.py
Normal 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
237
test/python/test_common.py
Normal 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()
|
||||||
@ -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);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -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.
|
||||||
|
|||||||
@ -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
|
||||||
|
|
||||||
|
|
||||||
|
def build_parser() -> argparse.ArgumentParser:
|
||||||
parser = argparse.ArgumentParser(description="GPX-Punkte an eine Route anhängen")
|
parser = argparse.ArgumentParser(description="GPX-Punkte an eine Route anhängen")
|
||||||
add_common_arguments(parser)
|
add_common_arguments(parser)
|
||||||
parser.add_argument("--route-id", type=int)
|
parser.add_argument("--route-id", type=int)
|
||||||
parser.add_argument("--gpx")
|
parser.add_argument("--gpx")
|
||||||
args = parser.parse_args()
|
return parser
|
||||||
def action():
|
|
||||||
|
|
||||||
|
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)
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
|
||||||
|
|
||||||
|
def build_parser() -> argparse.ArgumentParser:
|
||||||
parser = argparse.ArgumentParser(description="Audiodatei zu einem POI hochladen")
|
parser = argparse.ArgumentParser(description="Audiodatei zu einem POI hochladen")
|
||||||
add_common_arguments(parser)
|
add_common_arguments(parser)
|
||||||
parser.add_argument("--route-id", type=int)
|
parser.add_argument("--route-id", type=int)
|
||||||
parser.add_argument("--poi-id", type=int)
|
parser.add_argument("--poi-id", type=int)
|
||||||
parser.add_argument("--file")
|
parser.add_argument("--file")
|
||||||
args = parser.parse_args()
|
return parser
|
||||||
def action():
|
|
||||||
|
|
||||||
|
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)
|
||||||
|
|||||||
@ -1,6 +1,13 @@
|
|||||||
#!/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
|
||||||
|
|
||||||
|
|
||||||
|
def build_parser() -> argparse.ArgumentParser:
|
||||||
parser = argparse.ArgumentParser(description="Bild zu einem POI hochladen")
|
parser = argparse.ArgumentParser(description="Bild zu einem POI hochladen")
|
||||||
add_common_arguments(parser)
|
add_common_arguments(parser)
|
||||||
parser.add_argument("--route-id", type=int)
|
parser.add_argument("--route-id", type=int)
|
||||||
@ -8,8 +15,11 @@ parser.add_argument("--poi-id", type=int)
|
|||||||
parser.add_argument("--file")
|
parser.add_argument("--file")
|
||||||
parser.add_argument("--caption")
|
parser.add_argument("--caption")
|
||||||
parser.add_argument("--sequence", type=int)
|
parser.add_argument("--sequence", type=int)
|
||||||
args = parser.parse_args()
|
return parser
|
||||||
def action():
|
|
||||||
|
|
||||||
|
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)
|
||||||
|
|||||||
@ -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
|
||||||
|
|
||||||
|
|
||||||
|
def build_parser() -> argparse.ArgumentParser:
|
||||||
parser = argparse.ArgumentParser(description="POI anlegen")
|
parser = argparse.ArgumentParser(description="POI anlegen")
|
||||||
add_common_arguments(parser)
|
add_common_arguments(parser)
|
||||||
for name, kwargs in [
|
parser.add_argument("--route-id", type=int)
|
||||||
("--route-id", {"type": int}), ("--title", {}), ("--description", {}),
|
parser.add_argument("--title")
|
||||||
("--lat", {"type": float}), ("--lon", {"type": float}),
|
parser.add_argument("--description")
|
||||||
("--trigger-radius", {"type": float}), ("--sequence", {"type": int})
|
parser.add_argument("--lat", type=float)
|
||||||
]: parser.add_argument(name, **kwargs)
|
parser.add_argument("--lon", type=float)
|
||||||
args = parser.parse_args()
|
parser.add_argument("--trigger-radius", type=float)
|
||||||
def action():
|
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)
|
||||||
|
|||||||
@ -1,7 +1,13 @@
|
|||||||
#!/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
|
||||||
|
|
||||||
|
|
||||||
|
def build_parser() -> argparse.ArgumentParser:
|
||||||
parser = argparse.ArgumentParser(description="Neue Wegwichtel-Route anlegen")
|
parser = argparse.ArgumentParser(description="Neue Wegwichtel-Route anlegen")
|
||||||
add_common_arguments(parser)
|
add_common_arguments(parser)
|
||||||
parser.add_argument("--gpx")
|
parser.add_argument("--gpx")
|
||||||
@ -9,9 +15,11 @@ parser.add_argument("--name")
|
|||||||
parser.add_argument("--slug")
|
parser.add_argument("--slug")
|
||||||
parser.add_argument("--description")
|
parser.add_argument("--description")
|
||||||
parser.add_argument("--school-name")
|
parser.add_argument("--school-name")
|
||||||
args = parser.parse_args()
|
return parser
|
||||||
|
|
||||||
def action():
|
|
||||||
|
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)
|
||||||
|
|||||||
@ -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
|
||||||
|
|
||||||
|
|
||||||
|
def build_parser() -> argparse.ArgumentParser:
|
||||||
parser = argparse.ArgumentParser(description="Audiodatei eines POIs löschen")
|
parser = argparse.ArgumentParser(description="Audiodatei eines POIs löschen")
|
||||||
add_common_arguments(parser)
|
add_common_arguments(parser)
|
||||||
parser.add_argument("--route-id", type=int)
|
parser.add_argument("--route-id", type=int)
|
||||||
parser.add_argument("--poi-id", type=int)
|
parser.add_argument("--poi-id", type=int)
|
||||||
args = parser.parse_args()
|
return parser
|
||||||
def action():
|
|
||||||
|
|
||||||
|
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)
|
||||||
|
|||||||
@ -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
|
||||||
|
|
||||||
|
|
||||||
|
def build_parser() -> argparse.ArgumentParser:
|
||||||
parser = argparse.ArgumentParser(description="Ein Bild löschen")
|
parser = argparse.ArgumentParser(description="Ein Bild löschen")
|
||||||
add_common_arguments(parser)
|
add_common_arguments(parser)
|
||||||
parser.add_argument("--route-id", type=int)
|
parser.add_argument("--route-id", type=int)
|
||||||
parser.add_argument("--poi-id", type=int)
|
parser.add_argument("--poi-id", type=int)
|
||||||
parser.add_argument("--picture-id", type=int)
|
parser.add_argument("--picture-id", type=int)
|
||||||
args = parser.parse_args()
|
return parser
|
||||||
def action():
|
|
||||||
|
|
||||||
|
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)
|
||||||
|
|||||||
@ -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
|
||||||
|
|
||||||
|
|
||||||
|
def build_parser() -> argparse.ArgumentParser:
|
||||||
parser = argparse.ArgumentParser(description="POI einschließlich Medien löschen")
|
parser = argparse.ArgumentParser(description="POI einschließlich Medien löschen")
|
||||||
add_common_arguments(parser)
|
add_common_arguments(parser)
|
||||||
parser.add_argument("--route-id", type=int)
|
parser.add_argument("--route-id", type=int)
|
||||||
parser.add_argument("--poi-id", type=int)
|
parser.add_argument("--poi-id", type=int)
|
||||||
args = parser.parse_args()
|
return parser
|
||||||
def action():
|
|
||||||
|
|
||||||
|
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)
|
||||||
|
|||||||
@ -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
|
||||||
|
|
||||||
|
|
||||||
|
def build_parser() -> argparse.ArgumentParser:
|
||||||
parser = argparse.ArgumentParser(description="Route weich löschen")
|
parser = argparse.ArgumentParser(description="Route weich löschen")
|
||||||
add_common_arguments(parser)
|
add_common_arguments(parser)
|
||||||
parser.add_argument("--route-id", type=int)
|
parser.add_argument("--route-id", type=int)
|
||||||
args = parser.parse_args()
|
return parser
|
||||||
def action():
|
|
||||||
|
|
||||||
|
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)
|
||||||
|
|||||||
@ -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
|
||||||
|
|
||||||
|
|
||||||
|
def build_parser() -> argparse.ArgumentParser:
|
||||||
parser = argparse.ArgumentParser(description="Gelöschte Route wiederherstellen")
|
parser = argparse.ArgumentParser(description="Gelöschte Route wiederherstellen")
|
||||||
add_common_arguments(parser)
|
add_common_arguments(parser)
|
||||||
parser.add_argument("--route-id", type=int)
|
parser.add_argument("--route-id", type=int)
|
||||||
args = parser.parse_args()
|
return parser
|
||||||
def action():
|
|
||||||
|
|
||||||
|
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)
|
||||||
|
|||||||
@ -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
|
||||||
|
|
||||||
|
|
||||||
|
def build_parser() -> argparse.ArgumentParser:
|
||||||
parser = argparse.ArgumentParser(description="Audiodatei eines POIs ersetzen")
|
parser = argparse.ArgumentParser(description="Audiodatei eines POIs ersetzen")
|
||||||
add_common_arguments(parser)
|
add_common_arguments(parser)
|
||||||
parser.add_argument("--route-id", type=int)
|
parser.add_argument("--route-id", type=int)
|
||||||
parser.add_argument("--poi-id", type=int)
|
parser.add_argument("--poi-id", type=int)
|
||||||
parser.add_argument("--file")
|
parser.add_argument("--file")
|
||||||
args = parser.parse_args()
|
return parser
|
||||||
def action():
|
|
||||||
|
|
||||||
|
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)
|
||||||
|
|||||||
@ -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
|
||||||
|
|
||||||
|
|
||||||
|
def build_parser() -> argparse.ArgumentParser:
|
||||||
parser = argparse.ArgumentParser(description="Bilddatei oder Bildmetadaten ändern")
|
parser = argparse.ArgumentParser(description="Bilddatei oder Bildmetadaten ändern")
|
||||||
add_common_arguments(parser)
|
add_common_arguments(parser)
|
||||||
parser.add_argument("--route-id", type=int)
|
parser.add_argument("--route-id", type=int)
|
||||||
parser.add_argument("--poi-id", type=int)
|
parser.add_argument("--poi-id", type=int)
|
||||||
parser.add_argument("--picture-id", type=int)
|
parser.add_argument("--picture-id", type=int)
|
||||||
parser.add_argument("--file")
|
parser.add_argument("--file")
|
||||||
args = parser.parse_args()
|
return parser
|
||||||
def action():
|
|
||||||
|
|
||||||
|
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)
|
||||||
|
|||||||
@ -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
|
||||||
|
|
||||||
|
|
||||||
|
def build_parser() -> argparse.ArgumentParser:
|
||||||
parser = argparse.ArgumentParser(description="POI ändern")
|
parser = argparse.ArgumentParser(description="POI ändern")
|
||||||
add_common_arguments(parser)
|
add_common_arguments(parser)
|
||||||
parser.add_argument("--route-id", type=int)
|
parser.add_argument("--route-id", type=int)
|
||||||
parser.add_argument("--poi-id", type=int)
|
parser.add_argument("--poi-id", type=int)
|
||||||
args = parser.parse_args()
|
return parser
|
||||||
def action():
|
|
||||||
|
|
||||||
|
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)
|
||||||
|
|||||||
@ -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
|
||||||
|
|
||||||
|
|
||||||
|
def build_parser() -> argparse.ArgumentParser:
|
||||||
parser = argparse.ArgumentParser(description="Wegwichtel-Route ändern")
|
parser = argparse.ArgumentParser(description="Wegwichtel-Route ändern")
|
||||||
add_common_arguments(parser)
|
add_common_arguments(parser)
|
||||||
parser.add_argument("--route-id", type=int)
|
parser.add_argument("--route-id", type=int)
|
||||||
parser.add_argument("--gpx")
|
parser.add_argument("--gpx")
|
||||||
args = parser.parse_args()
|
return parser
|
||||||
|
|
||||||
def action():
|
|
||||||
|
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)
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user