Initial commit

This commit is contained in:
Jonas Karneboge 2026-09-22 18:35:43 +02:00
commit 3cba772836
1341 changed files with 532924 additions and 0 deletions

823
server.py Normal file
View 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()