api.py nachgezogen: Schluessel und Bedienung (gleiche Datei wie im Rechnungstool)
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01V4t48uxDok5rJXhC9bX1Ax
This commit is contained in:
parent
379afbd9c1
commit
7409fafb0b
1 changed files with 171 additions and 10 deletions
181
api.py
181
api.py
|
|
@ -18,11 +18,38 @@ einem Port, den das Betriebssystem aussucht (Port 0). Wo es lauscht, traegt es
|
|||
in die gemeinsame SQLite ein - die ist das Telefonbuch. Wer jemanden erreichen
|
||||
will, schlaegt dort Port und PID nach, prueft die PID und ruft an.
|
||||
|
||||
Zwei Nachrichten reichen:
|
||||
Zwei Nachrichten reichen dafuer:
|
||||
|
||||
{"was": "ping"} -> {"antwort": "hallo, bin noch da"}
|
||||
{"was": "schau_nach"} -> {"antwort": "ok"} (und das Fenster liest nach)
|
||||
|
||||
Dazu kommt eine dritte, die das Fenster BEDIENT - damit ein Pruefstand die
|
||||
Knoepfe druecken kann, ohne dem Menschen am Rechner Maus und Tastatur
|
||||
wegzunehmen:
|
||||
|
||||
{"was": "aktion", "schluessel": "...", "name": "vorschau"} -> {"antwort": {...}}
|
||||
|
||||
DER SCHLUESSEL
|
||||
--------------
|
||||
Wer bedient, muss sich ausweisen - wie mit einem SSH-Schluessel. Beim ersten
|
||||
Start legt das Programm eine Zufallszahl in `api_schluessel.txt` **im
|
||||
gemeinsamen Ordner** ab (dort steht auch das Nummernbuch). Wer diesen Ordner
|
||||
lesen darf, darf sprechen: das Steuerjournal und der Betreiber - sonst niemand.
|
||||
Ohne gueltigen Schluessel wird eine Aktion verworfen, und zwar ohne zu
|
||||
verraten, ob der Name ueberhaupt existiert.
|
||||
|
||||
`ping` und `schau_nach` bleiben schluessellos: sie sagen nur "ich lebe" bzw.
|
||||
"schau in die SQLite" und veraendern nichts. Sonst koennte ein aelteres
|
||||
Steuerjournal nicht mehr anklopfen.
|
||||
|
||||
WAS EINE AKTION DARF
|
||||
--------------------
|
||||
Alles, was einen BELEG SCHREIBT (Rechnung, Angebot, Storno), geht nur im
|
||||
Testlauf (`--test` / `CAMPINGHOF_TEST=1`). Im normalen Betrieb bleiben nur
|
||||
Aktionen, die nichts in die Buchhaltung schreiben: nachsehen, Felder fuellen,
|
||||
Vorschau zeigen. Eine Fernbedienung, die echte Rechnungen ausstellt, hat in
|
||||
einer Buchhaltung nichts zu suchen.
|
||||
|
||||
Der Push traegt keine Daten. Er sagt nur "schau in die SQLite" - dort steht die
|
||||
Meldung ohnehin. Damit gibt es genau EINE Wahrheit, und wenn der Anruf nicht
|
||||
durchgeht (Programm zu, Port belegt), findet das andere die Meldung beim
|
||||
|
|
@ -42,6 +69,8 @@ hinueber.
|
|||
import json
|
||||
import logging
|
||||
import os
|
||||
import queue
|
||||
import secrets
|
||||
import socket
|
||||
import socketserver
|
||||
import sys
|
||||
|
|
@ -50,6 +79,33 @@ import threading
|
|||
log = logging.getLogger("api")
|
||||
|
||||
ANTWORT_HALLO = "hallo, bin noch da"
|
||||
SCHLUESSEL_DATEI = "api_schluessel.txt"
|
||||
|
||||
|
||||
def schluessel_holen(ordner):
|
||||
"""Schluessel aus dem Ordner lesen; ist keiner da, einen anlegen.
|
||||
|
||||
Der Schluessel ist eine 32 Byte lange Zufallszahl in Hex. Er liegt als
|
||||
Datei neben dem Nummernbuch, damit beide Programme denselben lesen - eine
|
||||
Absprache ueber den gemeinsamen Ordner, ohne Server und ohne Passwort, das
|
||||
sich jemand merken muesste.
|
||||
"""
|
||||
if not ordner:
|
||||
return ""
|
||||
pfad = os.path.join(ordner, SCHLUESSEL_DATEI)
|
||||
try:
|
||||
if os.path.exists(pfad):
|
||||
wert = open(pfad, encoding="utf-8").read().strip()
|
||||
if wert:
|
||||
return wert
|
||||
wert = secrets.token_hex(32)
|
||||
with open(pfad, "w", encoding="utf-8") as f:
|
||||
f.write(wert)
|
||||
log.info("neuer API-Schluessel angelegt: %s", pfad)
|
||||
return wert
|
||||
except OSError as e:
|
||||
log.warning("API-Schluessel nicht nutzbar (%s)", e)
|
||||
return ""
|
||||
|
||||
|
||||
class _Handler(socketserver.StreamRequestHandler):
|
||||
|
|
@ -66,14 +122,17 @@ class _Handler(socketserver.StreamRequestHandler):
|
|||
if not zeile:
|
||||
return
|
||||
try:
|
||||
was = str(json.loads(zeile).get("was", ""))
|
||||
nachricht = json.loads(zeile)
|
||||
was = str(nachricht.get("was", ""))
|
||||
except (ValueError, AttributeError):
|
||||
was = ""
|
||||
nachricht, was = {}, ""
|
||||
if was == "ping":
|
||||
antwort = ANTWORT_HALLO
|
||||
elif was == "schau_nach":
|
||||
antwort = "ok"
|
||||
self.server.melden() # Fenster benachrichtigen (threadsicher)
|
||||
elif was == "aktion":
|
||||
antwort = self._aktion(nachricht)
|
||||
else:
|
||||
antwort = "haeh?"
|
||||
try:
|
||||
|
|
@ -82,12 +141,35 @@ class _Handler(socketserver.StreamRequestHandler):
|
|||
pass
|
||||
|
||||
|
||||
def _aktion(self, nachricht):
|
||||
"""Eine Bedienung ausfuehren - nur mit gueltigem Schluessel."""
|
||||
erwartet = getattr(self.server, "schluessel", "")
|
||||
gegeben = str(nachricht.get("schluessel", ""))
|
||||
# compare_digest statt "==": ein Vergleich, der bei der ersten falschen
|
||||
# Stelle abbricht, verraet ueber die Zeit, wie weit man richtig lag.
|
||||
if not erwartet or not secrets.compare_digest(erwartet, gegeben):
|
||||
log.warning("Aktion ohne gueltigen Schluessel abgewiesen")
|
||||
return "kein zutritt"
|
||||
if self.server.aktion is None:
|
||||
return "keine bedienung moeglich"
|
||||
name = str(nachricht.get("name", ""))
|
||||
daten = nachricht.get("daten") or {}
|
||||
log.info("Aktion ueber die API: %s", name)
|
||||
try:
|
||||
return self.server.aktion(name, daten)
|
||||
except Exception as e: # noqa: BLE001 - der Server darf daran nicht sterben
|
||||
log.warning("Aktion %r fehlgeschlagen: %s", name, e)
|
||||
return {"fehler": str(e)}
|
||||
|
||||
|
||||
class _Server(socketserver.ThreadingTCPServer):
|
||||
allow_reuse_address = True
|
||||
daemon_threads = True
|
||||
|
||||
def __init__(self, adresse, bei_push):
|
||||
def __init__(self, adresse, bei_push, aktion=None, schluessel=""):
|
||||
self.melden = bei_push
|
||||
self.aktion = aktion
|
||||
self.schluessel = schluessel
|
||||
super().__init__(adresse, _Handler)
|
||||
|
||||
|
||||
|
|
@ -99,19 +181,30 @@ class MiniApi:
|
|||
und holt sie im Fenster mit after() ab.
|
||||
"""
|
||||
|
||||
def __init__(self, programm, bei_push):
|
||||
def __init__(self, programm, bei_push, aktion=None):
|
||||
self.programm = programm
|
||||
self.bei_push = bei_push
|
||||
self.aktion = aktion # (name, daten) -> Antwort; None = keine Bedienung
|
||||
self.server = None
|
||||
self.faden = None
|
||||
self.port = 0
|
||||
self.schluessel = ""
|
||||
|
||||
def starten(self, buch):
|
||||
"""Server aufmachen und im Telefonbuch (SQLite) eintragen."""
|
||||
def starten(self, buch, ordner=None):
|
||||
"""Server aufmachen und im Telefonbuch (SQLite) eintragen.
|
||||
|
||||
Der Schluessel kommt aus dem Ordner des Nummernbuchs - dort liegt er
|
||||
fuer beide Programme lesbar. Gibt es kein Buch, gibt es keinen
|
||||
Schluessel und damit auch keine Bedienung von aussen.
|
||||
"""
|
||||
if self.server is not None:
|
||||
return self.port
|
||||
if ordner is None and buch is not None:
|
||||
ordner = os.path.dirname(getattr(buch, "pfad", "") or "")
|
||||
self.schluessel = schluessel_holen(ordner)
|
||||
try:
|
||||
self.server = _Server(("127.0.0.1", 0), self.bei_push)
|
||||
self.server = _Server(("127.0.0.1", 0), self.bei_push,
|
||||
aktion=self.aktion, schluessel=self.schluessel)
|
||||
except OSError as e:
|
||||
log.warning("API konnte nicht starten: %s", e)
|
||||
self.server = None
|
||||
|
|
@ -150,12 +243,14 @@ class MiniApi:
|
|||
self.port = 0
|
||||
|
||||
|
||||
def _reden(port, was, zeitgrenze=1.5):
|
||||
def _reden(port, was, zeitgrenze=1.5, **rest):
|
||||
"""Eine Zeile hinschicken und die Antwort lesen. None = niemand da."""
|
||||
nachricht = {"was": was}
|
||||
nachricht.update(rest)
|
||||
try:
|
||||
with socket.create_connection(("127.0.0.1", int(port)), timeout=zeitgrenze) as s:
|
||||
s.settimeout(zeitgrenze)
|
||||
s.sendall((json.dumps({"was": was}) + "\n").encode("utf-8"))
|
||||
s.sendall((json.dumps(nachricht) + chr(10)).encode("utf-8"))
|
||||
roh = s.makefile("r", encoding="utf-8").readline()
|
||||
return json.loads(roh).get("antwort", "")
|
||||
except (OSError, ValueError):
|
||||
|
|
@ -192,3 +287,69 @@ def anklopfen(buch, programm):
|
|||
if not eintrag or not eintrag.get("port"):
|
||||
return False
|
||||
return _reden(eintrag["port"], "schau_nach") == "ok"
|
||||
|
||||
|
||||
def bedienen(buch, programm, name, daten=None, ordner=None, zeitgrenze=15.0):
|
||||
"""Einen Knopf im anderen Programm druecken. None = niemand da / kein Zutritt.
|
||||
|
||||
Fuer Pruefstaende und fuer den Betreiber: damit laesst sich das Fenster
|
||||
bedienen, ohne dem Menschen am Rechner Maus und Tastatur wegzunehmen.
|
||||
|
||||
Der Schluessel kommt aus demselben Ordner wie das Nummernbuch - wer den
|
||||
lesen darf, darf bedienen.
|
||||
"""
|
||||
if buch is None:
|
||||
return None
|
||||
try:
|
||||
eintrag = buch.wer_laeuft(programm)
|
||||
except Exception as e: # noqa: BLE001
|
||||
log.debug("Telefonbuch nicht lesbar: %s", e)
|
||||
return None
|
||||
if not eintrag or not eintrag.get("port"):
|
||||
return None
|
||||
if ordner is None:
|
||||
ordner = os.path.dirname(getattr(buch, "pfad", "") or "")
|
||||
return _reden(eintrag["port"], "aktion", zeitgrenze=zeitgrenze,
|
||||
schluessel=schluessel_holen(ordner), name=name,
|
||||
daten=daten or {})
|
||||
|
||||
|
||||
class Warteschlange:
|
||||
"""Aktionen aus dem Netzfaden in den Fensterfaden bringen - und zurueck.
|
||||
|
||||
Tkinter darf nur aus SEINEM Faden angefasst werden. Der API-Server lauscht
|
||||
aber in einem eigenen. Deshalb legt er die Aktion hier ab und wartet auf das
|
||||
Ergebnis; das Fenster holt sie in seiner after()-Schleife ab, fuehrt sie aus
|
||||
und legt die Antwort zurueck.
|
||||
|
||||
Kommt das Fenster nicht dazu (es haengt in einem Dialog), laeuft die
|
||||
Wartezeit ab und der Anrufer bekommt eine ehrliche Absage statt einer
|
||||
Verbindung, die nie antwortet.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.eingang = queue.Queue()
|
||||
|
||||
def annehmen(self, name, daten, zeitgrenze=12.0):
|
||||
"""Aus dem Netzfaden gerufen: Aktion einreihen und auf das Ergebnis warten."""
|
||||
fertig = threading.Event()
|
||||
auftrag = {"name": name, "daten": daten, "fertig": fertig, "antwort": None}
|
||||
self.eingang.put(auftrag)
|
||||
if not fertig.wait(zeitgrenze):
|
||||
return {"fehler": "das Fenster hat nicht geantwortet"}
|
||||
return auftrag["antwort"]
|
||||
|
||||
def abarbeiten(self, ausfuehren):
|
||||
"""Im Fensterfaden gerufen: alles erledigen, was eingegangen ist."""
|
||||
while True:
|
||||
try:
|
||||
auftrag = self.eingang.get_nowait()
|
||||
except queue.Empty:
|
||||
return
|
||||
try:
|
||||
auftrag["antwort"] = ausfuehren(auftrag["name"], auftrag["daten"])
|
||||
except Exception as e: # noqa: BLE001 - eine Aktion darf das Fenster nicht killen
|
||||
log.warning("Aktion %r fehlgeschlagen: %s", auftrag["name"], e)
|
||||
auftrag["antwort"] = {"fehler": str(e)}
|
||||
finally:
|
||||
auftrag["fertig"].set()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue