Initial commit
This commit is contained in:
commit
3cba772836
1341 changed files with 532924 additions and 0 deletions
823
server.py
Normal file
823
server.py
Normal file
|
|
@ -0,0 +1,823 @@
|
|||
#!/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()
|
||||
Loading…
Add table
Add a link
Reference in a new issue