823 lines
32 KiB
Python
823 lines
32 KiB
Python
#!/usr/bin/env python3
|
||
"""
|
||
Pseudonymgenerator – lokaler Server mit formr-API-Integration
|
||
Starten: python3 server.py → http://localhost:8080
|
||
DB-Datei: pseudonyme.db (im selben Ordner)
|
||
Beenden: Ctrl+C
|
||
"""
|
||
import http.server
|
||
import sqlite3
|
||
import json
|
||
import re
|
||
import ssl
|
||
import urllib.request
|
||
import urllib.parse
|
||
import urllib.error
|
||
import logging
|
||
import subprocess
|
||
import shutil
|
||
import socket
|
||
import threading
|
||
import time
|
||
from datetime import datetime, timezone
|
||
from pathlib import Path
|
||
|
||
logging.basicConfig(
|
||
level=logging.WARNING,
|
||
format='%(asctime)s [%(levelname)s] %(message)s',
|
||
datefmt='%H:%M:%S'
|
||
)
|
||
|
||
PORT = 8080
|
||
DIR = Path(__file__).resolve().parent
|
||
DB = DIR / "pseudonyme.db"
|
||
|
||
SHINY_PORT_START = 8100
|
||
SHINY_LOG_DIR = DIR / "_shiny_logs"
|
||
shiny_procs: dict[str, dict] = {} # app_name -> {"proc", "port", "ready", "exited", "exit_code"}
|
||
shiny_lock = threading.Lock()
|
||
|
||
VERIFY_CACHE_TTL = 300 # Sekunden - formr-Verify-Ergebnisse werden pro (Run, Code) so lange gecacht
|
||
verify_cache: dict[tuple, dict] = {} # (run_name, code) -> {"ts", "found", "ended", "error"}
|
||
verify_cache_lock = threading.Lock()
|
||
|
||
|
||
def init_db():
|
||
"""Einmalig beim Start: Schema anlegen + Migrationen. get_db() macht das NICHT
|
||
mehr bei jedem Request - das war unnoetige Arbeit auf dem Hot Path."""
|
||
con = sqlite3.connect(DB)
|
||
con.executescript("""
|
||
CREATE TABLE IF NOT EXISTS eintraege (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
chiffre TEXT NOT NULL DEFAULT '',
|
||
pseudonym TEXT NOT NULL,
|
||
datum TEXT NOT NULL,
|
||
instrument TEXT NOT NULL DEFAULT '',
|
||
notiz TEXT NOT NULL DEFAULT '',
|
||
archived INTEGER NOT NULL DEFAULT 0
|
||
);
|
||
CREATE TABLE IF NOT EXISTS config (
|
||
key TEXT PRIMARY KEY,
|
||
value TEXT NOT NULL DEFAULT ''
|
||
);
|
||
CREATE TABLE IF NOT EXISTS runs (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
name TEXT NOT NULL UNIQUE,
|
||
label TEXT NOT NULL DEFAULT '',
|
||
kategorie TEXT NOT NULL DEFAULT ''
|
||
);
|
||
CREATE TABLE IF NOT EXISTS eintraege_runs (
|
||
eintrag_id INTEGER NOT NULL,
|
||
run_id INTEGER NOT NULL,
|
||
registered_at TEXT NOT NULL,
|
||
ended_at TEXT NOT NULL DEFAULT '',
|
||
PRIMARY KEY (eintrag_id, run_id)
|
||
);
|
||
""")
|
||
# Migration: kategorie-Spalte nachrüsten falls DB älter
|
||
try:
|
||
con.execute("ALTER TABLE runs ADD COLUMN kategorie TEXT NOT NULL DEFAULT ''")
|
||
con.commit()
|
||
except sqlite3.OperationalError:
|
||
pass # Spalte existiert bereits
|
||
# Migration: shiny_app-Spalte nachrüsten falls DB älter
|
||
try:
|
||
con.execute("ALTER TABLE runs ADD COLUMN shiny_app TEXT NOT NULL DEFAULT ''")
|
||
con.commit()
|
||
except sqlite3.OperationalError:
|
||
pass # Spalte existiert bereits
|
||
# Migration: ended_at-Spalte nachrüsten falls DB älter
|
||
try:
|
||
con.execute("ALTER TABLE eintraege_runs ADD COLUMN ended_at TEXT NOT NULL DEFAULT ''")
|
||
con.commit()
|
||
except sqlite3.OperationalError:
|
||
pass # Spalte existiert bereits
|
||
# Migration: archived-Spalte nachrüsten falls DB älter
|
||
try:
|
||
con.execute("ALTER TABLE eintraege ADD COLUMN archived INTEGER NOT NULL DEFAULT 0")
|
||
con.commit()
|
||
except sqlite3.OperationalError:
|
||
pass # Spalte existiert bereits
|
||
con.close()
|
||
|
||
|
||
def get_db():
|
||
con = sqlite3.connect(DB)
|
||
con.row_factory = sqlite3.Row
|
||
return con
|
||
|
||
|
||
def get_eintrag(con, eintrag_id):
|
||
return con.execute("SELECT * FROM eintraege WHERE id=?", [eintrag_id]).fetchone()
|
||
|
||
|
||
def cfg_get(con, key, default=''):
|
||
row = con.execute("SELECT value FROM config WHERE key=?", [key]).fetchone()
|
||
return row['value'] if row else default
|
||
|
||
|
||
def now_utc():
|
||
return datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M:%S')
|
||
|
||
|
||
# ------------------------------------------------------------- Shiny-Apps --
|
||
|
||
def _r_version_key(path):
|
||
m = re.search(r'R-(\d+)\.(\d+)\.(\d+)', str(path))
|
||
return tuple(int(x) for x in m.groups()) if m else (0, 0, 0)
|
||
|
||
|
||
def find_rscript():
|
||
found = shutil.which("Rscript") or shutil.which("Rscript.exe")
|
||
if found:
|
||
return found
|
||
# Windows-Fallback: uebliche Installationsorte, falls R nicht im PATH steht
|
||
for base in (Path(r"C:\Program Files\R"), Path(r"C:\Program Files (x86)\R")):
|
||
if not base.is_dir():
|
||
continue
|
||
candidates = list(base.glob("R-*/bin/x64/Rscript.exe")) + list(base.glob("R-*/bin/Rscript.exe"))
|
||
if candidates:
|
||
return str(max(candidates, key=_r_version_key))
|
||
return None
|
||
|
||
|
||
def port_offen(port, timeout=0.5):
|
||
try:
|
||
with socket.create_connection(("127.0.0.1", port), timeout):
|
||
return True
|
||
except OSError:
|
||
return False
|
||
|
||
|
||
def list_shiny_apps():
|
||
"""Ordnernamen neben server.py, die eine app.R enthalten."""
|
||
return sorted(
|
||
e.name for e in DIR.iterdir()
|
||
if e.is_dir() and (e / "app.R").exists()
|
||
)
|
||
|
||
|
||
def allocate_port(app_name):
|
||
with shiny_lock:
|
||
if app_name in shiny_procs:
|
||
return shiny_procs[app_name]['port']
|
||
used = {v['port'] for v in shiny_procs.values()}
|
||
port = SHINY_PORT_START
|
||
while port in used or port_offen(port):
|
||
port += 1
|
||
return port
|
||
|
||
|
||
def shiny_log_path(app_name):
|
||
return SHINY_LOG_DIR / f"{app_name}.log"
|
||
|
||
|
||
def read_shiny_log(app_name, max_bytes=20000):
|
||
path = shiny_log_path(app_name)
|
||
if not path.exists():
|
||
return ''
|
||
data = path.read_bytes()
|
||
if len(data) > max_bytes:
|
||
data = data[-max_bytes:]
|
||
return data.decode('utf-8', errors='replace')
|
||
|
||
|
||
def start_shiny(app_name):
|
||
with shiny_lock:
|
||
existing = shiny_procs.get(app_name)
|
||
if existing and existing['proc'].poll() is None:
|
||
return existing, None
|
||
|
||
rscript = find_rscript()
|
||
if not rscript:
|
||
return None, "Rscript nicht im PATH gefunden"
|
||
|
||
app_dir = DIR / app_name
|
||
if not (app_dir / "app.R").exists():
|
||
return None, f"Kein app.R in {app_name}"
|
||
|
||
port = allocate_port(app_name)
|
||
SHINY_LOG_DIR.mkdir(exist_ok=True)
|
||
logf = open(shiny_log_path(app_name), "w")
|
||
proc = subprocess.Popen(
|
||
[rscript, "-e", f"shiny::runApp('.', port={port}, launch.browser=FALSE)"],
|
||
cwd=app_dir,
|
||
stdout=logf,
|
||
stderr=subprocess.STDOUT,
|
||
)
|
||
logf.close() # Kind-Prozess haelt eigenes Duplikat des Filedescriptors
|
||
state = {"proc": proc, "port": port, "ready": False, "exited": False, "exit_code": None}
|
||
with shiny_lock:
|
||
shiny_procs[app_name] = state
|
||
|
||
def warte_auf_ready():
|
||
for _ in range(60):
|
||
if proc.poll() is not None:
|
||
state["exited"] = True
|
||
state["exit_code"] = proc.returncode
|
||
return
|
||
if port_offen(port):
|
||
state["ready"] = True
|
||
return
|
||
time.sleep(0.5)
|
||
# Timeout: weder bereit noch beendet - haengt fest, ebenfalls als Fehler markieren
|
||
if not state["ready"]:
|
||
state["exited"] = proc.poll() is not None
|
||
threading.Thread(target=warte_auf_ready, daemon=True).start()
|
||
return state, None
|
||
|
||
|
||
def stop_shiny(app_name):
|
||
with shiny_lock:
|
||
state = shiny_procs.pop(app_name, None)
|
||
if not state:
|
||
return False
|
||
proc = state['proc']
|
||
if proc.poll() is None:
|
||
proc.terminate()
|
||
try:
|
||
proc.wait(timeout=5)
|
||
except subprocess.TimeoutExpired:
|
||
proc.kill()
|
||
return True
|
||
|
||
|
||
def shiny_state_json(state):
|
||
return {'port': state['port'], 'ready': state['ready'],
|
||
'exited': state['exited'], 'exit_code': state['exit_code']}
|
||
|
||
|
||
def formr_get_token(api_url, client_id, client_secret):
|
||
ctx = ssl.create_default_context()
|
||
token_url = f"{api_url}/oauth/access_token"
|
||
logging.debug("TOKEN POST %s client_id=%s", token_url, client_id)
|
||
data = urllib.parse.urlencode({
|
||
'grant_type': 'client_credentials',
|
||
'client_id': client_id,
|
||
'client_secret': client_secret,
|
||
}).encode()
|
||
req = urllib.request.Request(
|
||
token_url, data=data,
|
||
headers={'Content-Type': 'application/x-www-form-urlencoded'}
|
||
)
|
||
with urllib.request.urlopen(req, context=ctx, timeout=15) as r:
|
||
raw = r.read()
|
||
logging.debug("TOKEN %s body=%s", r.status, raw[:200])
|
||
token = json.loads(raw)['access_token']
|
||
logging.debug("TOKEN OK (len=%d)", len(token))
|
||
return token
|
||
|
||
|
||
def formr_register(api_url, client_id, client_secret, run_name, code, token=None):
|
||
"""Returns (created, already_existed, error).
|
||
created=True: neu angelegt.
|
||
already_existed=True: 400 'No sessions were created' → Session war schon da.
|
||
error: Fehlermeldung bei echtem Fehler, sonst None."""
|
||
ctx = ssl.create_default_context()
|
||
try:
|
||
if token is None:
|
||
token = formr_get_token(api_url, client_id, client_secret)
|
||
|
||
# Session anlegen
|
||
session_url = f"{api_url}/v1/runs/{run_name}/sessions"
|
||
body = json.dumps({'code': code}).encode()
|
||
logging.debug("SESSION POST %s code=%s…%s", session_url, code[:8], code[-4:])
|
||
req2 = urllib.request.Request(
|
||
session_url, data=body,
|
||
headers={
|
||
'Content-Type': 'application/json',
|
||
'Authorization': f'Bearer {token}'
|
||
}
|
||
)
|
||
with urllib.request.urlopen(req2, context=ctx, timeout=15) as r:
|
||
raw2 = r.read()
|
||
logging.debug("SESSION %s body=%s", r.status, raw2[:200])
|
||
return True, False, None
|
||
except urllib.error.HTTPError as e:
|
||
txt = e.read().decode('utf-8', errors='replace')[:500]
|
||
logging.error("HTTPError %s url=%s body=%s", e.code, e.url, txt)
|
||
if e.code == 400 and 'No sessions were created' in txt:
|
||
# Session existiert bereits auf formr
|
||
logging.warning("SESSION already exists on formr for run=%s", run_name)
|
||
return False, True, None
|
||
return False, False, f"HTTP {e.code}: {txt}"
|
||
except Exception as e:
|
||
logging.exception("formr_register exception")
|
||
return False, False, str(e)[:300]
|
||
|
||
|
||
def formr_verify_session(api_url, client_id, client_secret, run_name, code, token=None):
|
||
"""Returns (found, ended, error).
|
||
found: True/False/None (None = check failed).
|
||
ended: True if session is marked ended, False if active, None if unknown.
|
||
error: error string on failure, else None."""
|
||
ctx = ssl.create_default_context()
|
||
try:
|
||
if token is None:
|
||
token = formr_get_token(api_url, client_id, client_secret)
|
||
|
||
# GET /v1/runs/{name}/sessions/{code} → 200 with session object, or 404
|
||
session_url = (f"{api_url}/v1/runs/{urllib.parse.quote(run_name, safe='')}"
|
||
f"/sessions/{urllib.parse.quote(code, safe='')}")
|
||
logging.debug("VERIFY GET %s", session_url)
|
||
req2 = urllib.request.Request(
|
||
session_url,
|
||
headers={'Authorization': f'Bearer {token}'}
|
||
)
|
||
try:
|
||
with urllib.request.urlopen(req2, context=ctx, timeout=15) as r:
|
||
raw = r.read()
|
||
logging.debug("VERIFY %s body=%s", r.status, raw[:200])
|
||
session = json.loads(raw)
|
||
ended_at = session.get('ended') or None # datetime string or None
|
||
return True, ended_at, None
|
||
except urllib.error.HTTPError as inner:
|
||
if inner.code == 404:
|
||
return False, None, None
|
||
raise
|
||
except urllib.error.HTTPError as e:
|
||
txt = e.read().decode('utf-8', errors='replace')[:300]
|
||
logging.warning("VERIFY HTTPError %s body=%s", e.code, txt)
|
||
return None, None, f"HTTP {e.code}: {txt}"
|
||
except Exception as e:
|
||
logging.exception("formr_verify_session exception")
|
||
return None, None, str(e)[:200]
|
||
|
||
|
||
def verify_with_cache(api_url, client_id, client_secret, run_name, code, force=False, token=None):
|
||
"""Wie formr_verify_session, aber pro (run_name, code) fuer VERIFY_CACHE_TTL Sekunden
|
||
gecacht, damit haeufiges Neuladen der Tabelle nicht bei jedem Klick die formr-API trifft."""
|
||
key = (run_name, code)
|
||
now = time.time()
|
||
if not force:
|
||
with verify_cache_lock:
|
||
cached = verify_cache.get(key)
|
||
if cached and (now - cached['ts']) < VERIFY_CACHE_TTL:
|
||
return cached['found'], cached['ended'], cached['error']
|
||
found, ended, err = formr_verify_session(api_url, client_id, client_secret, run_name, code, token=token)
|
||
with verify_cache_lock:
|
||
verify_cache[key] = {'ts': now, 'found': found, 'ended': ended, 'error': err}
|
||
return found, ended, err
|
||
|
||
|
||
def persist_ended(con, eintrag_id, run_id, ended_at):
|
||
con.execute(
|
||
"UPDATE eintraege_runs SET ended_at=? WHERE eintrag_id=? AND run_id=?",
|
||
[ended_at or '', eintrag_id, run_id]
|
||
)
|
||
|
||
|
||
def verify_cached(con, eintrag_id, run_id, api_url, client_id, client_secret, run_name, code,
|
||
force=False, token=None):
|
||
"""Ein Cache mit zwei Ebenen statt zwei getrennter Mechanismen: eine bereits als
|
||
'ended' bekannte Session ist bei formr final und wird nur noch aus der DB gelesen
|
||
(nie wieder live, auch nicht mit force) - sonst greift verify_with_cache's TTL-Cache.
|
||
Persistiert ended_at automatisch, sobald bekannt. Der Aufrufer committet."""
|
||
er = con.execute(
|
||
"SELECT ended_at FROM eintraege_runs WHERE eintrag_id=? AND run_id=?",
|
||
[eintrag_id, run_id]
|
||
).fetchone()
|
||
if er and er['ended_at']:
|
||
return True, er['ended_at'], None
|
||
if not (api_url and client_id and client_secret):
|
||
return None, None, 'API nicht konfiguriert'
|
||
found, ended_at, err = verify_with_cache(
|
||
api_url, client_id, client_secret, run_name, code, force=force, token=token)
|
||
if ended_at:
|
||
persist_ended(con, eintrag_id, run_id, ended_at)
|
||
return found, ended_at, err
|
||
|
||
|
||
def entry_with_runs(con, row):
|
||
d = dict(row)
|
||
runs = con.execute(
|
||
"""SELECT r.id, r.name, r.label, r.kategorie, r.shiny_app, er.registered_at, er.ended_at
|
||
FROM runs r
|
||
JOIN eintraege_runs er ON er.run_id = r.id
|
||
WHERE er.eintrag_id = ?
|
||
ORDER BY r.kategorie, r.label, r.name""",
|
||
[d['id']]
|
||
).fetchall()
|
||
d['runs'] = [dict(r) for r in runs]
|
||
return d
|
||
|
||
|
||
def do_register(con, eintrag_id, pseudonym, run_ids):
|
||
"""Registers pseudonym in given run_ids. Returns {run_id_str: {ok, verified?, error?}}."""
|
||
api_url = cfg_get(con, 'api_url')
|
||
client_id = cfg_get(con, 'client_id')
|
||
client_secret = cfg_get(con, 'client_secret')
|
||
if not (api_url and client_id and client_secret):
|
||
return {str(rid): {'ok': False, 'error': 'API nicht konfiguriert'} for rid in run_ids}
|
||
|
||
try:
|
||
token = formr_get_token(api_url, client_id, client_secret)
|
||
except Exception as e:
|
||
err = str(e)[:300]
|
||
return {str(rid): {'ok': False, 'error': err} for rid in run_ids}
|
||
|
||
ph = ','.join('?' for _ in run_ids)
|
||
runs = con.execute(f"SELECT * FROM runs WHERE id IN ({ph})", run_ids).fetchall()
|
||
results = {}
|
||
instrument_set = False
|
||
for run in runs:
|
||
created, already_existed, err = formr_register(
|
||
api_url, client_id, client_secret, run['name'], pseudonym, token=token)
|
||
if created or already_existed:
|
||
con.execute(
|
||
"INSERT OR IGNORE INTO eintraege_runs(eintrag_id,run_id,registered_at,ended_at) VALUES(?,?,?,?)",
|
||
[eintrag_id, run['id'], now_utc(), '']
|
||
)
|
||
verified, ended_at, _ = verify_cached(
|
||
con, eintrag_id, run['id'], api_url, client_id, client_secret,
|
||
run['name'], pseudonym, force=True, token=token)
|
||
results[str(run['id'])] = {'ok': True, 'verified': verified, 'ended': ended_at,
|
||
'already_existed': already_existed}
|
||
# Instrument (jetzt kein manuelles Feld mehr) einmalig aus dem ersten
|
||
# erfolgreichen Run befuellen, damit Export/DB nicht leer bleiben
|
||
if not instrument_set:
|
||
eintrag = get_eintrag(con, eintrag_id)
|
||
if eintrag and not eintrag['instrument']:
|
||
con.execute("UPDATE eintraege SET instrument=? WHERE id=?",
|
||
[run['label'] or run['name'], eintrag_id])
|
||
instrument_set = True
|
||
else:
|
||
results[str(run['id'])] = {'ok': False, 'error': err}
|
||
con.commit()
|
||
return results
|
||
|
||
|
||
class Handler(http.server.BaseHTTPRequestHandler):
|
||
def log_message(self, fmt, *args): pass
|
||
|
||
def send_json(self, data, status=200):
|
||
body = json.dumps(data, ensure_ascii=False).encode()
|
||
try:
|
||
self.send_response(status)
|
||
self.send_header("Content-Type", "application/json; charset=utf-8")
|
||
self.send_header("Content-Length", str(len(body)))
|
||
self.end_headers()
|
||
self.wfile.write(body)
|
||
except (BrokenPipeError, ConnectionResetError):
|
||
pass
|
||
|
||
def read_body(self):
|
||
n = int(self.headers.get("Content-Length", 0))
|
||
return json.loads(self.rfile.read(n)) if n else {}
|
||
|
||
# ------------------------------------------------------------------ GET --
|
||
|
||
def do_GET(self):
|
||
p = urllib.parse.urlparse(self.path)
|
||
qs = urllib.parse.parse_qs(p.query)
|
||
path = p.path
|
||
m_verify = re.match(r'^/api/eintraege/(\d+)/verify$', path)
|
||
|
||
if path in ('/', '/pseudonym-generator.html'):
|
||
data = (DIR / "pseudonym-generator.html").read_bytes()
|
||
try:
|
||
self.send_response(200)
|
||
self.send_header("Content-Type", "text/html; charset=utf-8")
|
||
self.send_header("Content-Length", str(len(data)))
|
||
self.end_headers()
|
||
self.wfile.write(data)
|
||
except (BrokenPipeError, ConnectionResetError):
|
||
pass
|
||
|
||
elif path == '/faveicon.ico':
|
||
ico = (DIR / "faveicon.ico")
|
||
if ico.exists():
|
||
data = ico.read_bytes()
|
||
try:
|
||
self.send_response(200)
|
||
self.send_header("Content-Type", "image/x-icon")
|
||
self.send_header("Content-Length", str(len(data)))
|
||
self.send_header("Cache-Control", "max-age=86400")
|
||
self.end_headers()
|
||
self.wfile.write(data)
|
||
except (BrokenPipeError, ConnectionResetError):
|
||
pass
|
||
else:
|
||
self.send_response(404); self.end_headers()
|
||
|
||
elif path == '/api/config':
|
||
con = get_db()
|
||
self.send_json({
|
||
'api_url': cfg_get(con, 'api_url'),
|
||
'client_id': cfg_get(con, 'client_id'),
|
||
'has_secret': bool(cfg_get(con, 'client_secret')),
|
||
})
|
||
con.close()
|
||
|
||
elif path == '/api/runs':
|
||
con = get_db()
|
||
rows = con.execute("SELECT * FROM runs ORDER BY kategorie, label, name").fetchall()
|
||
con.close()
|
||
self.send_json([dict(r) for r in rows])
|
||
|
||
elif path == '/api/shiny/apps':
|
||
self.send_json(list_shiny_apps())
|
||
|
||
elif path == '/api/shiny/status':
|
||
result = {}
|
||
with shiny_lock:
|
||
for name, s in shiny_procs.items():
|
||
if s['proc'].poll() is not None and not s['exited']:
|
||
s['exited'] = True
|
||
s['exit_code'] = s['proc'].returncode
|
||
result[name] = shiny_state_json(s)
|
||
self.send_json(result)
|
||
|
||
elif path == '/api/shiny/log':
|
||
app_name = qs.get('app', [None])[0]
|
||
if not app_name:
|
||
self.send_response(400); self.end_headers(); return
|
||
body = read_shiny_log(app_name).encode('utf-8')
|
||
self.send_response(200)
|
||
self.send_header("Content-Type", "text/plain; charset=utf-8")
|
||
self.send_header("Content-Length", str(len(body)))
|
||
self.end_headers()
|
||
self.wfile.write(body)
|
||
|
||
elif path == '/api/eintraege':
|
||
con = get_db()
|
||
q = qs.get('q', [None])[0]
|
||
archived = qs.get('archived', ['0'])[0] == '1'
|
||
if q:
|
||
like = f'%{q.upper()}%'
|
||
rows = con.execute(
|
||
"SELECT * FROM eintraege WHERE archived=? AND (UPPER(chiffre) LIKE ? OR UPPER(notiz) LIKE ?) "
|
||
"ORDER BY chiffre, id",
|
||
[1 if archived else 0, like, like]
|
||
).fetchall()
|
||
else:
|
||
rows = con.execute(
|
||
"SELECT * FROM eintraege WHERE archived=? ORDER BY chiffre, id",
|
||
[1 if archived else 0]
|
||
).fetchall()
|
||
result = [entry_with_runs(con, r) for r in rows]
|
||
con.close()
|
||
self.send_json(result)
|
||
|
||
elif path == '/api/eintraege/counts':
|
||
con = get_db()
|
||
active = con.execute("SELECT COUNT(*) FROM eintraege WHERE archived=0").fetchone()[0]
|
||
archived = con.execute("SELECT COUNT(*) FROM eintraege WHERE archived=1").fetchone()[0]
|
||
con.close()
|
||
self.send_json({'active': active, 'archived': archived})
|
||
|
||
elif path == '/api/export/db':
|
||
data = DB.read_bytes()
|
||
self.send_response(200)
|
||
self.send_header("Content-Type", "application/octet-stream")
|
||
self.send_header("Content-Disposition", 'attachment; filename="pseudonyme.db"')
|
||
self.send_header("Content-Length", str(len(data)))
|
||
self.end_headers()
|
||
self.wfile.write(data)
|
||
|
||
elif path == '/api/export/json':
|
||
con = get_db()
|
||
rows = con.execute(
|
||
"SELECT chiffre,pseudonym,datum,instrument,notiz FROM eintraege ORDER BY chiffre, id"
|
||
).fetchall()
|
||
con.close()
|
||
body = json.dumps([dict(r) for r in rows], ensure_ascii=False, indent=2).encode()
|
||
self.send_response(200)
|
||
self.send_header("Content-Type", "application/json; charset=utf-8")
|
||
self.send_header("Content-Disposition", 'attachment; filename="pseudonyme.json"')
|
||
self.send_header("Content-Length", str(len(body)))
|
||
self.end_headers()
|
||
self.wfile.write(body)
|
||
|
||
elif m_verify:
|
||
eintrag_id = int(m_verify.group(1))
|
||
run_id_str = qs.get('run_id', [None])[0]
|
||
force = qs.get('force', ['0'])[0] == '1'
|
||
if not run_id_str:
|
||
self.send_json({'error': 'run_id required'}, 400); return
|
||
con = get_db()
|
||
row = get_eintrag(con, eintrag_id)
|
||
run = con.execute("SELECT * FROM runs WHERE id=?", [int(run_id_str)]).fetchone()
|
||
if not row or not run:
|
||
con.close(); self.send_json({'error': 'nicht gefunden'}, 404); return
|
||
api_url = cfg_get(con, 'api_url')
|
||
client_id = cfg_get(con, 'client_id')
|
||
client_secret = cfg_get(con, 'client_secret')
|
||
found, ended_at, err = verify_cached(
|
||
con, eintrag_id, run['id'], api_url, client_id, client_secret,
|
||
run['name'], row['pseudonym'], force=force)
|
||
con.commit()
|
||
con.close()
|
||
self.send_json({'found': found, 'ended': ended_at, 'error': err})
|
||
|
||
else:
|
||
self.send_response(404); self.end_headers()
|
||
|
||
# ----------------------------------------------------------------- POST --
|
||
|
||
def do_POST(self):
|
||
path = self.path
|
||
m_register = re.match(r'^/api/eintraege/(\d+)/register$', path)
|
||
|
||
# Konfiguration speichern
|
||
if path == '/api/config':
|
||
d = self.read_body()
|
||
con = get_db()
|
||
for key in ('api_url', 'client_id', 'client_secret'):
|
||
if key in d:
|
||
val = d[key].strip()
|
||
if key == 'api_url':
|
||
val = val.rstrip('/')
|
||
con.execute(
|
||
"INSERT INTO config(key,value) VALUES(?,?) "
|
||
"ON CONFLICT(key) DO UPDATE SET value=excluded.value",
|
||
[key, val]
|
||
)
|
||
con.commit()
|
||
con.close()
|
||
self.send_json({'ok': True})
|
||
|
||
# Run hinzufügen
|
||
elif path == '/api/runs':
|
||
d = self.read_body()
|
||
name = d.get('name', '').strip()
|
||
label = d.get('label', '').strip()
|
||
kategorie = d.get('kategorie', '').strip()
|
||
shiny_app = d.get('shiny_app', '').strip()
|
||
if not name:
|
||
self.send_json({'error': 'name required'}, 400); return
|
||
if shiny_app and shiny_app not in list_shiny_apps():
|
||
self.send_json({'error': f'Unbekannte Shiny-App: {shiny_app}'}, 400); return
|
||
con = get_db()
|
||
try:
|
||
cur = con.execute(
|
||
"INSERT INTO runs(name,label,kategorie,shiny_app) VALUES(?,?,?,?)",
|
||
[name, label, kategorie, shiny_app]
|
||
)
|
||
con.commit()
|
||
row = con.execute("SELECT * FROM runs WHERE id=?", [cur.lastrowid]).fetchone()
|
||
con.close()
|
||
self.send_json(dict(row), 201)
|
||
except sqlite3.IntegrityError:
|
||
con.close()
|
||
self.send_json({'error': f'Run "{name}" existiert bereits'}, 409)
|
||
|
||
# Shiny-App starten
|
||
elif path == '/api/shiny/start':
|
||
d = self.read_body()
|
||
app_name = d.get('app', '').strip()
|
||
if not app_name:
|
||
self.send_json({'error': 'app required'}, 400); return
|
||
if app_name not in list_shiny_apps():
|
||
self.send_json({'error': f'Unbekannte App: {app_name}'}, 404); return
|
||
state, err = start_shiny(app_name)
|
||
if err:
|
||
self.send_json({'error': err}, 500); return
|
||
self.send_json({'app': app_name, **shiny_state_json(state)})
|
||
|
||
# Shiny-App stoppen
|
||
elif path == '/api/shiny/stop':
|
||
d = self.read_body()
|
||
app_name = d.get('app', '').strip()
|
||
ok = stop_shiny(app_name)
|
||
self.send_json({'ok': ok})
|
||
|
||
# Neuen Eintrag anlegen (+ optional in Runs eintragen)
|
||
elif path == '/api/eintraege':
|
||
d = self.read_body()
|
||
run_ids = [int(x) for x in d.get('run_ids', [])]
|
||
con = get_db()
|
||
cur = con.execute(
|
||
"INSERT INTO eintraege (chiffre,pseudonym,datum,instrument,notiz) VALUES (?,?,?,?,?)",
|
||
[d.get('chiffre', ''), d['pseudonym'], d['datum'],
|
||
d.get('instrument', ''), d.get('notiz', '')]
|
||
)
|
||
eintrag_id = cur.lastrowid
|
||
con.commit()
|
||
reg_results = do_register(con, eintrag_id, d['pseudonym'], run_ids) if run_ids else {}
|
||
result = entry_with_runs(con, get_eintrag(con, eintrag_id))
|
||
con.close()
|
||
result['reg_results'] = reg_results
|
||
self.send_json(result, 201)
|
||
|
||
# Eintrag nachträglich in Runs eintragen
|
||
elif m_register:
|
||
eintrag_id = int(m_register.group(1))
|
||
d = self.read_body()
|
||
run_ids = [int(x) for x in d.get('run_ids', [])]
|
||
con = get_db()
|
||
row = get_eintrag(con, eintrag_id)
|
||
if not row:
|
||
con.close(); self.send_json({'error': 'nicht gefunden'}, 404); return
|
||
reg_results = do_register(con, eintrag_id, row['pseudonym'], run_ids)
|
||
result = entry_with_runs(con, get_eintrag(con, eintrag_id))
|
||
con.close()
|
||
result['reg_results'] = reg_results
|
||
self.send_json(result)
|
||
|
||
# JSON importieren
|
||
elif path == '/api/import/json':
|
||
d = self.read_body()
|
||
entries = d.get('entries', [])
|
||
replace = d.get('replace', False)
|
||
con = get_db()
|
||
if replace:
|
||
con.execute("DELETE FROM eintraege")
|
||
for e in entries:
|
||
con.execute(
|
||
"INSERT INTO eintraege (chiffre,pseudonym,datum,instrument,notiz) VALUES (?,?,?,?,?)",
|
||
[e.get('chiffre', ''), e.get('pseudonym', ''), e.get('datum', ''),
|
||
e.get('instrument', ''), e.get('notiz', '')]
|
||
)
|
||
con.commit()
|
||
count = con.execute("SELECT COUNT(*) FROM eintraege").fetchone()[0]
|
||
con.close()
|
||
self.send_json({'count': count})
|
||
|
||
else:
|
||
self.send_response(404); self.end_headers()
|
||
|
||
# ------------------------------------------------------------------ PUT --
|
||
|
||
def do_PUT(self):
|
||
m_e = re.match(r'^/api/eintraege/(\d+)$', self.path)
|
||
m_r = re.match(r'^/api/runs/(\d+)$', self.path)
|
||
|
||
if m_e:
|
||
row_id = int(m_e.group(1))
|
||
d = self.read_body()
|
||
allowed = {'chiffre', 'datum', 'instrument', 'notiz', 'archived'}
|
||
updates = {k: v for k, v in d.items() if k in allowed}
|
||
if 'archived' in updates:
|
||
updates['archived'] = 1 if updates['archived'] else 0
|
||
if not updates:
|
||
self.send_json({}); return
|
||
con = get_db()
|
||
sets = ', '.join(f"{k}=?" for k in updates)
|
||
con.execute(f"UPDATE eintraege SET {sets} WHERE id=?", [*updates.values(), row_id])
|
||
con.commit()
|
||
row = get_eintrag(con, row_id)
|
||
result = entry_with_runs(con, row) if row else {}
|
||
con.close()
|
||
self.send_json(result)
|
||
|
||
elif m_r:
|
||
run_id = int(m_r.group(1))
|
||
d = self.read_body()
|
||
allowed = {'label', 'kategorie', 'shiny_app'}
|
||
updates = {k: v.strip() for k, v in d.items() if k in allowed and isinstance(v, str)}
|
||
if 'shiny_app' in updates and updates['shiny_app'] and updates['shiny_app'] not in list_shiny_apps():
|
||
self.send_json({'error': f"Unbekannte Shiny-App: {updates['shiny_app']}"}, 400); return
|
||
if not updates:
|
||
self.send_json({}); return
|
||
con = get_db()
|
||
sets = ', '.join(f"{k}=?" for k in updates)
|
||
con.execute(f"UPDATE runs SET {sets} WHERE id=?", [*updates.values(), run_id])
|
||
con.commit()
|
||
row = con.execute("SELECT * FROM runs WHERE id=?", [run_id]).fetchone()
|
||
con.close()
|
||
self.send_json(dict(row) if row else {})
|
||
|
||
else:
|
||
self.send_response(404); self.end_headers()
|
||
|
||
# --------------------------------------------------------------- DELETE --
|
||
|
||
def do_DELETE(self):
|
||
m_e = re.match(r'^/api/eintraege/(\d+)$', self.path)
|
||
m_r = re.match(r'^/api/runs/(\d+)$', self.path)
|
||
|
||
if m_e:
|
||
row_id = int(m_e.group(1))
|
||
con = get_db()
|
||
row = get_eintrag(con, row_id)
|
||
if not row:
|
||
con.close(); self.send_json({'error': 'nicht gefunden'}, 404); return
|
||
if not row['archived']:
|
||
con.close()
|
||
self.send_json({'error': 'Nur archivierte Einträge können endgültig gelöscht werden'}, 400)
|
||
return
|
||
con.execute("DELETE FROM eintraege_runs WHERE eintrag_id=?", [row_id])
|
||
con.execute("DELETE FROM eintraege WHERE id=?", [row_id])
|
||
con.commit(); con.close()
|
||
self.send_json({'ok': True})
|
||
|
||
elif m_r:
|
||
run_id = int(m_r.group(1))
|
||
con = get_db()
|
||
con.execute("DELETE FROM eintraege_runs WHERE run_id=?", [run_id])
|
||
con.execute("DELETE FROM runs WHERE id=?", [run_id])
|
||
con.commit(); con.close()
|
||
self.send_json({'ok': True})
|
||
|
||
else:
|
||
self.send_response(404); self.end_headers()
|
||
|
||
|
||
if __name__ == '__main__':
|
||
init_db()
|
||
print(f"\nDiagnostik → http://localhost:{PORT}")
|
||
print(f"DB-Datei → {DB}")
|
||
print("Beenden: Ctrl+C\n")
|
||
httpd = http.server.HTTPServer(('127.0.0.1', PORT), Handler)
|
||
httpd.serve_forever()
|