Files
2026-05-21 13:57:27 +02:00

1559 lines
67 KiB
Python

import os
import json
import threading
import time
import sqlite3
import logging
import shutil
import requests
import whisper
import re
import uuid
import base64
import traceback
import gc
import py_compile
import subprocess
from datetime import datetime, timedelta
from flask import Flask, render_template, request, redirect, url_for, flash, send_from_directory, session
# --- SETUP ---
app = Flask(__name__)
app.secret_key = "geheim_schluessel_bitte_aendern"
CONFIG_FILE = "config.json"
DB_FILE = "voicemails.db"
DOWNLOAD_DIR = "recordings"
TEMP_DIR = "temp_uploads"
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
TEMPLATES_DIR = os.path.join(BASE_DIR, "templates")
# Logging Setup
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(levelname)s - %(message)s',
handlers=[
logging.FileHandler("app.log", encoding='utf-8'),
logging.StreamHandler()
]
)
for d in [DOWNLOAD_DIR, TEMP_DIR, TEMPLATES_DIR]:
if not os.path.exists(d):
os.makedirs(d)
def load_config():
defaults = {
"placetel_api_token": "",
"check_interval_minutes": 10,
"delete_recordings_after_days": 7,
"standard_export_path": "/opt/comhub/share/Export",
"fax_outbox_path": "/opt/comhub/share/FaxOut",
"fax_from_number": "",
"fax_email": "",
"admin_pin": "1234",
"admin_password": "6hJUE#MM",
"review_pin_enabled": "false",
"review_pin": "0000",
"module_voicemail": "true",
"module_transcription": "true",
"module_fax": "true",
"module_gdt": "false",
"module_docs": "true",
"gdt_import_path": "/opt/comhub/share/GDT/Import",
"gdt_export_path": "/opt/comhub/share/GDT/Export",
"gdt_sender_id": "COMHUB",
"gdt_receiver_id": "PVS",
"monitored_folder_path": "/opt/comhub/share/Dokumente/Import",
"allow_retranscription": "false"
}
if not os.path.exists(CONFIG_FILE):
return defaults
with open(CONFIG_FILE, 'r') as f:
data = json.load(f)
if "check_interval_seconds" in data and "check_interval_minutes" not in data:
data["check_interval_minutes"] = max(1, data["check_interval_seconds"] // 60)
del data["check_interval_seconds"]
if "fax_export_path" in data and "standard_export_path" not in data:
data["standard_export_path"] = data["fax_export_path"]
del data["fax_export_path"]
if "gdt_enabled" in data and "module_gdt" not in data:
data["module_gdt"] = data["gdt_enabled"]
if "folder_monitoring_enabled" in data and "module_docs" not in data:
data["module_docs"] = data["folder_monitoring_enabled"]
path_keys = ["standard_export_path", "fax_outbox_path", "gdt_import_path", "gdt_export_path", "monitored_folder_path"]
for key in path_keys:
if key in data and data[key].startswith("C:/"):
data[key] = defaults[key]
for key, val in defaults.items():
if key not in data:
data[key] = val
return data
def save_config(new_config):
with open(CONFIG_FILE, 'w') as f:
json.dump(new_config, f, indent=4)
config = load_config()
if not os.path.exists(config["fax_outbox_path"]):
try: os.makedirs(config["fax_outbox_path"])
except: pass
# --- DATENBANK ---
def get_db_connection():
conn = sqlite3.connect(DB_FILE, timeout=15.0)
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA journal_mode=WAL;")
return conn
def init_db():
conn = get_db_connection()
conn.execute("""
CREATE TABLE IF NOT EXISTS voicemails (
id TEXT PRIMARY KEY,
audio_path TEXT,
transcription TEXT,
status TEXT DEFAULT 'Neu',
received_at TEXT,
is_archived INTEGER DEFAULT 0,
sender TEXT
)
""")
conn.execute("""
CREATE TABLE IF NOT EXISTS faxes (
id TEXT PRIMARY KEY,
sender TEXT,
file_path TEXT,
status TEXT DEFAULT 'Neu',
received_at TEXT
)
""")
conn.execute("""
CREATE TABLE IF NOT EXISTS faxes_out (
id TEXT PRIMARY KEY,
file_path TEXT,
recipient TEXT,
note TEXT,
status TEXT DEFAULT 'Entwurf',
created_at TEXT
)
""")
conn.execute("""
CREATE TABLE IF NOT EXISTS contacts (
id TEXT PRIMARY KEY,
first_name TEXT,
last_name TEXT,
number TEXT,
company TEXT,
fax_number TEXT,
patient_id TEXT
)
""")
try: conn.execute("ALTER TABLE voicemails ADD COLUMN sender TEXT")
except: pass
try: conn.execute("ALTER TABLE faxes_out ADD COLUMN note TEXT")
except: pass
try: conn.execute("ALTER TABLE contacts ADD COLUMN fax_number TEXT")
except: pass
try: conn.execute("ALTER TABLE faxes_out ADD COLUMN placetel_id TEXT")
except: pass
try: conn.execute("ALTER TABLE contacts ADD COLUMN patient_id TEXT")
except: pass
try: conn.execute("ALTER TABLE faxes ADD COLUMN source TEXT DEFAULT 'api'")
except: pass
try: conn.execute("ALTER TABLE faxes ADD COLUMN title TEXT")
except: pass
conn.commit()
conn.close()
init_db()
# --- HELPER ---
def normalize_number(num):
if not num: return ""
s = str(num).strip()
s = re.sub(r'[^0-9+]', '', s)
if s.startswith("+49"): s = "0" + s[3:]
elif s.startswith("0049"): s = "0" + s[4:]
return re.sub(r'[^0-9]', '', s)
def format_german_date(date_str):
if not date_str: return ""
try:
dt = datetime.strptime(date_str, "%Y-%m-%d %H:%M")
return dt.strftime("%d-%m-%y %H:%M")
except:
return date_str
def extract_pdf_title(filepath, default_sender):
"""Hybrider PDF-Scanner (PyPDF2 für digitale Dokumente, Tesseract-OCR für Faxe)"""
text = ""
# 1. Versuch: Normalen Text extrahieren (schnell)
try:
import PyPDF2
with open(filepath, 'rb') as f:
reader = PyPDF2.PdfReader(f)
if len(reader.pages) > 0:
text = reader.pages[0].extract_text()
except ImportError:
logging.warning("PyPDF2 nicht installiert.")
except Exception as e:
logging.error(f"Fehler bei PyPDF2 Textauslesung: {e}")
# 2. Versuch: Wenn der Text extrem kurz ist (meist bei Fax-Bildern), nutze OCR
if not text or len(text.strip()) < 20:
try:
from pdf2image import convert_from_path
import pytesseract
logging.info(f"Führe OCR auf Dokument {filepath} aus...")
images = convert_from_path(filepath, first_page=1, last_page=1)
if images:
# Nutze deutsches Sprachpaket, falls installiert
text = pytesseract.image_to_string(images[0], lang='deu')
except ImportError:
logging.warning("OCR (pdf2image/pytesseract) nicht installiert. Überspringe Bild-Erkennung.")
except Exception as e:
logging.error(f"Fehler bei OCR-Verarbeitung: {e}")
# Fallback, falls absolut kein Text gefunden wurde
if not text or not text.strip():
return f"Dokument von {default_sender}"
# 3. Text analysieren und cleveren Titel bauen
text_upper = text.upper()
doc_type = "Dokument"
# Typische medizinische/geschäftliche Dokumenten-Arten
keywords = {
"BEFUND": "Befund",
"ARZTBRIEF": "Arztbrief",
"RECHNUNG": "Rechnung",
"ÜBERWEISUNG": "Überweisung",
"LABOR": "Labor",
"REZEPT": "Rezept",
"VERORDNUNG": "Verordnung",
"ENTLASSUNG": "Entlassungsbericht",
"AU-BESCHEINIGUNG": "AU-Bescheinigung",
"ARBEITSUNFÄHIGKEIT": "AU-Bescheinigung",
"HEILMITTEL": "Heilmittel-Verordnung",
"ANTRAG": "Antrag"
}
for kw, readable in keywords.items():
if kw in text_upper:
doc_type = readable
break
# Versuch, den Namen/eine Betreffzeile zu extrahieren (erste sinnvolle Zeile ohne Rauschen)
lines = [line.strip() for line in text.split('\n') if line.strip()]
# Filtere Zeilen raus, die nur aus Zahlen/Sonderzeichen bestehen
valid_lines = [l for l in lines if len(re.sub(r'[^a-zA-ZäöüÄÖÜß]', '', l)) > 4]
title_suffix = default_sender
if valid_lines:
for line in valid_lines[:5]: # Prüfe die ersten 5 validen Zeilen
clean_line = re.sub(r'[^a-zA-Z0-9äöüÄÖÜß \-,\.]', '', line).strip()
# Ignoriere Zeilen, die exakt wie das Keyword heißen oder zu kurz sind
if len(clean_line) > 5 and clean_line.upper() not in keywords:
title_suffix = clean_line[:40]
break
return f"{doc_type} - {title_suffix}"
def get_contact_lookup():
conn = get_db_connection()
rows = conn.execute("SELECT * FROM contacts").fetchall()
conn.close()
lookup = {}
for row in rows:
r_dict = dict(row)
clean_num = normalize_number(r_dict.get('number', ''))
clean_fax = normalize_number(r_dict.get('fax_number', ''))
name = f"{r_dict.get('first_name', '')} {r_dict.get('last_name', '')}".strip()
if r_dict.get('company'): name += f" ({r_dict.get('company')})"
if r_dict.get('patient_id'): name = f"[{r_dict.get('patient_id')}] " + name
if clean_num: lookup[clean_num] = name
if clean_fax: lookup[clean_fax] = name
return lookup
def resolve_faxes_with_names(faxes_rows):
contact_map = get_contact_lookup()
results = []
for fax in faxes_rows:
f = dict(fax)
f['received_formatted'] = format_german_date(f.get('received_at', ''))
f['title'] = f.get('title') or "Eingehendes Dokument"
sender_clean = normalize_number(f.get('sender', ''))
f['resolved_name'] = f.get('sender', '')
if sender_clean in contact_map:
f['resolved_name'] = contact_map[sender_clean]
else:
for c_num, c_name in contact_map.items():
if len(c_num) > 6 and (sender_clean.endswith(c_num) or c_num.endswith(sender_clean)):
f['resolved_name'] = c_name
break
results.append(f)
return results
def resolve_outbox_with_names(faxes_out_rows):
contact_map = get_contact_lookup()
results = []
for out in faxes_out_rows:
o = dict(out)
o['created_formatted'] = format_german_date(o.get('created_at', ''))
recip_clean = normalize_number(o.get('recipient', ''))
o['resolved_name'] = o.get('recipient', '')
if recip_clean in contact_map:
o['resolved_name'] = contact_map[recip_clean]
else:
for c_num, c_name in contact_map.items():
if len(c_num) > 6 and (recip_clean.endswith(c_num) or c_num.endswith(recip_clean)):
o['resolved_name'] = c_name
break
results.append(o)
return results
# --- CONTEXT PROCESSOR ---
@app.context_processor
def inject_globals():
counts = dict(cnt_vm=0, cnt_fax=0, cnt_rev=0, cnt_out=0, cnt_doc=0)
try:
conn = get_db_connection()
counts['cnt_vm'] = conn.execute("SELECT count(*) FROM voicemails WHERE status='Neu' AND is_archived=0").fetchone()[0]
counts['cnt_fax'] = conn.execute("SELECT count(*) FROM faxes WHERE status='Neu' AND IFNULL(source, 'api') = 'api'").fetchone()[0]
counts['cnt_doc'] = conn.execute("SELECT count(*) FROM faxes WHERE status='Neu' AND source = 'folder'").fetchone()[0]
counts['cnt_rev'] = conn.execute("SELECT count(*) FROM faxes WHERE status='Prüfung'").fetchone()[0]
counts['cnt_out'] = conn.execute("SELECT count(*) FROM faxes_out WHERE status='Entwurf'").fetchone()[0]
conn.close()
except: pass
is_auth = session.get('authenticated', False)
return dict(**counts, is_authenticated=is_auth, config=config)
# --- BACKGROUND WORKER ---
class BackgroundWorker:
def __init__(self):
self.is_running = False
self.sync_lock = threading.Lock()
self.stop_event = threading.Event()
self.update_config()
def update_config(self):
self.interval = int(config.get("check_interval_minutes", 10))
self.retention = int(config.get("delete_recordings_after_days", 7))
self.api_token = config.get("placetel_api_token", "")
self.module_voicemail = str(config.get("module_voicemail", "true")).lower() == "true"
self.module_transcription = str(config.get("module_transcription", "true")).lower() == "true"
self.module_fax = str(config.get("module_fax", "true")).lower() == "true"
self.module_gdt = str(config.get("module_gdt", "false")).lower() == "true"
self.module_docs = str(config.get("module_docs", "true")).lower() == "true"
self.outbox_path = config.get("fax_outbox_path", "/opt/comhub/share/FaxOut")
self.gdt_import_path = config.get("gdt_import_path", "/opt/comhub/share/GDT/Import")
self.monitored_folder_path = config.get("monitored_folder_path", "/opt/comhub/share/Dokumente/Import")
if self.module_fax and not os.path.exists(self.outbox_path):
try: os.makedirs(self.outbox_path)
except: pass
if self.module_gdt and not os.path.exists(self.gdt_import_path):
try: os.makedirs(self.gdt_import_path)
except: pass
if self.module_docs and not os.path.exists(self.monitored_folder_path):
try: os.makedirs(self.monitored_folder_path)
except: pass
def start_scheduler(self):
logging.info("Start Scheduler...")
thread = threading.Thread(target=self._loop, daemon=True)
thread.start()
def _loop(self):
last_sync = 0
while not self.stop_event.is_set():
if self.module_fax: self._process_outbox()
if self.module_gdt: self._process_gdt_import()
if self.module_docs: self._process_monitored_folder()
if self.module_transcription: self._process_transcriptions()
now = time.time()
if now - last_sync >= (self.interval * 60):
if self.api_token:
self._process_unified_sync()
self._process_contacts()
if self.module_fax: self._process_outbox_status()
self._cleanup()
last_sync = now
for _ in range(5):
if self.stop_event.is_set(): break
time.sleep(1)
def sync_now(self):
if self.sync_lock.locked():
return "läuft bereits"
threading.Thread(target=self._run_once, daemon=True).start()
return "gestartet"
def _run_once(self):
if self.module_gdt: self._process_gdt_import()
if self.module_docs: self._process_monitored_folder()
if self.api_token:
self._process_unified_sync()
self._process_contacts()
if self.module_fax: self._process_outbox_status()
def _process_transcriptions(self):
conn = get_db_connection()
pending = conn.execute("SELECT id, audio_path FROM voicemails WHERE transcription = '[Transkription wird vorbereitet...]'").fetchall()
conn.close()
if not pending:
return
logging.info(f"{len(pending)} Voicemail(s) warten auf Transkription.")
local_whisper_model = None
try:
for vm in pending:
conn = get_db_connection()
conn.execute("UPDATE voicemails SET transcription = '[Transkription läuft...]' WHERE id = ?", (vm['id'],))
conn.commit()
conn.close()
lpath = os.path.join(DOWNLOAD_DIR, vm['audio_path'])
txt = ""
if not os.path.exists(lpath) or os.path.getsize(lpath) < 1024:
logging.warning(f"Audiodatei {vm['audio_path']} ist defekt oder leer.")
txt = "[Fehler: Audiodatei defekt oder leer]"
else:
try:
if not local_whisper_model:
logging.info("Lade Whisper Modell in den Arbeitsspeicher...")
model_dir = os.path.join(BASE_DIR, "models")
os.makedirs(model_dir, exist_ok=True)
local_whisper_model = whisper.load_model("turbo", download_root=model_dir)
res = local_whisper_model.transcribe(
lpath,
fp16=False,
language="de",
initial_prompt="Hallo, dies ist eine Sprachnachricht. Bitte rufen Sie mich zurück.",
condition_on_previous_text=False,
beam_size=5,
no_speech_threshold=0.6
)
txt = res["text"].strip()
if not txt:
txt = "[Keine Sprache erkannt]"
except Exception as ex:
logging.error(f"Whisper Transkriptionsfehler für Datei {lpath}: {ex}\n{traceback.format_exc()}")
txt = "[Fehler bei der Transkription]"
conn = get_db_connection()
conn.execute("UPDATE voicemails SET transcription = ? WHERE id = ?", (txt, vm['id']))
conn.commit()
conn.close()
logging.info(f"Transkription für {vm['id']} abgeschlossen.")
finally:
if local_whisper_model is not None:
del local_whisper_model
gc.collect()
logging.info("Whisper Modell aus dem Arbeitsspeicher entfernt.")
def _process_monitored_folder(self):
if not os.path.exists(self.monitored_folder_path): return
try:
for filename in os.listdir(self.monitored_folder_path):
if filename.lower().endswith(".pdf"):
src = os.path.join(self.monitored_folder_path, filename)
try:
initial_size = os.path.getsize(src)
if initial_size == 0: continue
time.sleep(1.0)
if os.path.getsize(src) != initial_size: continue
os.rename(src, src)
with open(src, 'rb') as check_f:
if not check_f.read(5).startswith(b'%PDF-'): continue
except OSError: continue
except Exception: continue
doc_id = uuid.uuid4().hex[:12]
safe_name = f"doc_{doc_id}.pdf"
dst = os.path.join(DOWNLOAD_DIR, safe_name)
try:
shutil.move(src, dst)
# Generiere dynamischen Titel via OCR/PyPDF2
doc_title = extract_pdf_title(dst, "Ordner-Import")
conn = get_db_connection()
date_s = datetime.now().strftime("%Y-%m-%d %H:%M")
conn.execute("INSERT INTO faxes (id, sender, file_path, status, received_at, source, title) VALUES (?, ?, ?, 'Neu', ?, 'folder', ?)",
(doc_id, filename, safe_name, date_s, doc_title))
conn.commit()
conn.close()
logging.info(f"Neues Dokument aus Ordner importiert: {filename}")
except PermissionError: pass
except Exception as e:
logging.error(f"Ordner Überwachung Fehler: {e}")
def _process_gdt_import(self):
if not os.path.exists(self.gdt_import_path): return
try:
for filename in os.listdir(self.gdt_import_path):
if filename.lower().endswith(('.gdt', '.bdt')):
filepath = os.path.join(self.gdt_import_path, filename)
try:
initial_size = os.path.getsize(filepath)
if initial_size == 0: continue
time.sleep(0.5)
if os.path.getsize(filepath) != initial_size: continue
os.rename(filepath, filepath)
except Exception:
continue
patient_data = {}
try:
with open(filepath, 'r', encoding='cp437') as f:
for line in f:
if len(line) > 7:
field = line[3:7]
val = line[7:].strip()
if field == "3000": patient_data['id'] = val
elif field == "3101": patient_data['last_name'] = val
elif field == "3102": patient_data['first_name'] = val
except Exception as e:
logging.error(f"Fehler beim Lesen der GDT Datei {filename}: {e}")
continue
if 'id' in patient_data:
conn = get_db_connection()
exists = conn.execute("SELECT id FROM contacts WHERE patient_id = ?", (patient_data['id'],)).fetchone()
fname = patient_data.get('first_name', '')
lname = patient_data.get('last_name', '')
if exists:
conn.execute("UPDATE contacts SET first_name = ?, last_name = ? WHERE patient_id = ?", (fname, lname, patient_data['id']))
else:
c_id = f"gdt_{uuid.uuid4().hex[:12]}"
conn.execute("INSERT INTO contacts (id, first_name, last_name, patient_id) VALUES (?, ?, ?, ?)", (c_id, fname, lname, patient_data['id']))
conn.commit()
conn.close()
logging.info(f"GDT Patient importiert/aktualisiert: {patient_data['id']}")
try: os.remove(filepath)
except: pass
except Exception as e:
logging.error(f"GDT Import Prozess Fehler: {e}")
def _process_outbox(self):
if not os.path.exists(self.outbox_path): return
try:
for filename in os.listdir(self.outbox_path):
if filename.lower().endswith(".pdf"):
src = os.path.join(self.outbox_path, filename)
try:
initial_size = os.path.getsize(src)
if initial_size == 0: continue
time.sleep(1.0)
if os.path.getsize(src) != initial_size: continue
os.rename(src, src)
with open(src, 'rb') as check_f:
if not check_f.read(5).startswith(b'%PDF-'): continue
except OSError: continue
except Exception as e: continue
out_id = uuid.uuid4().hex[:12]
safe_name = f"out_{out_id}.pdf"
dst = os.path.join(DOWNLOAD_DIR, safe_name)
try:
shutil.move(src, dst)
conn = get_db_connection()
date_s = datetime.now().strftime("%Y-%m-%d %H:%M")
conn.execute("INSERT INTO faxes_out (id, file_path, status, created_at) VALUES (?, ?, 'Entwurf', ?)", (out_id, safe_name, date_s))
conn.commit()
conn.close()
logging.info(f"Neues Fax für Ausgang importiert: {safe_name}")
except PermissionError: pass
except Exception as e:
logging.error(f"Outbox Check Fehler: {e}")
def _process_outbox_status(self):
try:
conn = get_db_connection()
pending = conn.execute("SELECT id, placetel_id FROM faxes_out WHERE status = 'In Arbeit' AND placetel_id IS NOT NULL").fetchall()
if not pending:
conn.close()
return
headers = {"Authorization": f"Bearer {self.api_token}", "Accept": "application/json"}
for p in pending:
url = f"https://api.placetel.de/v2/faxes/{p['placetel_id']}"
try:
r = requests.get(url, headers=headers)
if r.status_code == 200:
data = r.json()
api_status = data.get('status', '').lower()
new_status = 'In Arbeit'
if api_status in ['sent', 'delivered', 'success']:
new_status = 'Erfolgreich'
elif api_status in ['failed', 'error', 'canceled', 'aborted']:
new_status = 'Fehlgeschlagen'
if new_status != 'In Arbeit':
conn.execute("UPDATE faxes_out SET status = ? WHERE id = ?", (new_status, p['id']))
except Exception as e:
logging.error(f"Outbox Status Fetch Error for {p['placetel_id']}: {e}")
conn.commit()
conn.close()
except Exception as e:
logging.error(f"Outbox Sync Error: {e}")
def _cleanup(self):
if self.retention <= 0: return
try:
cutoff = (datetime.now() - timedelta(days=self.retention)).strftime("%Y-%m-%d %H:%M")
conn = get_db_connection()
archived_vms = conn.execute("SELECT id, audio_path FROM voicemails WHERE received_at < ? AND is_archived = 1", (cutoff,)).fetchall()
for row in archived_vms:
if row['audio_path']:
p = os.path.join(DOWNLOAD_DIR, row['audio_path'])
if os.path.exists(p):
try: os.remove(p)
except: pass
conn.execute("DELETE FROM voicemails WHERE id = ?", (row['id'],))
out_rows = conn.execute("SELECT id, file_path FROM faxes_out WHERE created_at < ? AND status IN ('Gesendet', 'Erfolgreich', 'Fehlgeschlagen')", (cutoff,)).fetchall()
for row in out_rows:
if row['file_path']:
p = os.path.join(DOWNLOAD_DIR, row['file_path'])
if os.path.exists(p):
try: os.remove(p)
except: pass
conn.execute("DELETE FROM faxes_out WHERE id = ?", (row['id'],))
conn.commit()
conn.close()
except Exception as e:
logging.error(f"Cleanup Fehler: {e}")
def _download_file(self, url, local_path):
try:
logging.info(f"Versuche Download von URL: {url}")
headers = {"User-Agent": "Mozilla/5.0"}
r = requests.get(url, stream=True, headers=headers, timeout=10)
if r.status_code == 200:
with open(local_path, 'wb') as f:
for chunk in r.iter_content(1024): f.write(chunk)
return True
else:
logging.error(f"Download fehlgeschlagen (Status {r.status_code}) für URL: {url}")
return False
except Exception as e:
logging.error(f"Download blockiert für URL {url}: {e}")
return False
def _process_contacts(self):
try:
url = "https://api.placetel.de/v2/contacts"
headers = {"Authorization": f"Bearer {self.api_token}", "Accept": "application/json"}
resp = requests.get(url, headers=headers, params={"per_page": 100})
if resp.status_code == 200:
contacts = resp.json()
if isinstance(contacts, dict): contacts = contacts.get('data', [])
conn = get_db_connection()
local_data = conn.execute("SELECT id, patient_id FROM contacts WHERE patient_id IS NOT NULL").fetchall()
pat_map = {row['id']: row['patient_id'] for row in local_data}
conn.execute("DELETE FROM contacts WHERE id NOT LIKE 'gdt_%'")
for c in contacts:
c_id = str(c.get('id'))
fname = c.get('first_name', '')
lname = c.get('last_name', '')
company = c.get('company', '')
number = c.get('phone_work') or c.get('mobile_work') or c.get('phone_home') or ''
fax_number = c.get('fax') or c.get('fax_work') or ''
p_id = pat_map.get(c_id, None)
if not number and not fax_number: continue
exists = conn.execute("SELECT 1 FROM contacts WHERE id = ?", (c_id,)).fetchone()
if exists:
conn.execute("UPDATE contacts SET first_name=?, last_name=?, number=?, company=?, fax_number=? WHERE id=?", (fname, lname, number, company, fax_number, c_id))
else:
conn.execute("INSERT INTO contacts (id, first_name, last_name, number, company, fax_number, patient_id) VALUES (?, ?, ?, ?, ?, ?, ?)", (c_id, fname, lname, number, company, fax_number, p_id))
conn.commit()
conn.close()
except Exception as e: logging.error(f"Kontakt Sync Fehler: {e}")
def _process_unified_sync(self):
if not self.sync_lock.acquire(blocking=False):
logging.info("API Sync wird übersprungen (Prozess läuft bereits).")
return
try:
headers = {"Authorization": f"Bearer {self.api_token}", "Accept": "application/json"}
date_from = (datetime.now() - timedelta(days=90)).strftime("%Y-%m-%d")
new_stuff = 0
# CALLS
if self.module_voicemail:
call_url = "https://api.placetel.de/v2/calls"
resp_calls = requests.get(call_url, headers=headers, params={"date_from": date_from, "per_page": 100, "order": "desc"})
if resp_calls.status_code == 200:
call_items = resp_calls.json()
if isinstance(call_items, dict): call_items = call_items.get('data', [])
for item in call_items:
c_id = str(item.get('id'))
c_type = str(item.get('type')).lower()
file_url = item.get('file_url') or item.get('recording_url') or item.get('pdf_file_url')
if not file_url: continue
if c_type == 'voicemail':
conn = get_db_connection()
exists = conn.execute("SELECT 1 FROM voicemails WHERE id = ?", (c_id,)).fetchone()
conn.close()
if exists: continue
date_s = item.get('created_at', datetime.now().strftime("%Y-%m-%d %H:%M"))
sender = str(item.get('from', 'Unbekannt')).replace("sip:", "").split("@")[0].replace('"', '')
if sender == 'Anonymous': sender = str(item.get('peer', 'Unbekannt')).replace("sip:", "").split("@")[0].replace('"', '')
fname = f"vm_{c_id}.mp3"
lpath = os.path.join(DOWNLOAD_DIR, fname)
if self._download_file(file_url, lpath):
txt_status = "[Transkription wird vorbereitet...]" if self.module_transcription else "[Transkription deaktiviert]"
conn = get_db_connection()
conn.execute("INSERT INTO voicemails (id, audio_path, transcription, received_at, sender) VALUES (?, ?, ?, ?, ?)",
(c_id, fname, txt_status, date_s, sender))
conn.commit()
conn.close()
new_stuff += 1
# FAXES
if self.module_fax:
fax_url = "https://api.placetel.de/v2/faxes"
resp_fax = requests.get(fax_url, headers=headers, params={"date_from": date_from, "per_page": 50, "order": "desc"})
if resp_fax.status_code == 200:
fax_items = resp_fax.json()
if isinstance(fax_items, dict): fax_items = fax_items.get('data', [])
for item in fax_items:
f_id = str(item.get('id'))
f_type = str(item.get('type')).lower()
if 'in' not in f_type: continue
conn = get_db_connection()
exists = conn.execute("SELECT 1 FROM faxes WHERE id = ?", (f_id,)).fetchone()
conn.close()
if exists: continue
pdf_url = item.get('file') or item.get('pdf_file_url') or item.get('file_url')
if not pdf_url:
try:
det_r = requests.get(f"{fax_url}/{f_id}", headers=headers)
if det_r.status_code == 200:
det_data = det_r.json()
pdf_url = det_data.get('file') or det_data.get('pdf_file_url')
except: pass
if not pdf_url: continue
sender = item.get('from_number') or item.get('sender_number') or 'Unbekannt'
date_s = item.get('created_at', datetime.now().strftime("%Y-%m-%d %H:%M"))
fname = f"fax_{f_id}.pdf"
lpath = os.path.join(DOWNLOAD_DIR, fname)
if self._download_file(pdf_url, lpath):
# Titel dynamisch generieren
doc_title = extract_pdf_title(lpath, sender)
conn = get_db_connection()
conn.execute("INSERT INTO faxes (id, sender, file_path, status, received_at, source, title) VALUES (?, ?, ?, 'Neu', ?, 'api', ?)",
(f_id, sender, fname, date_s, doc_title))
conn.commit()
conn.close()
new_stuff += 1
if new_stuff > 0: logging.info(f"Sync abgeschlossen: {new_stuff} neue Elemente.")
except Exception as e:
logging.error(f"Sync Exception: {e}")
finally:
self.sync_lock.release()
worker = BackgroundWorker()
# --- ROUTEN ---
@app.route('/')
def index():
open_settings = request.args.get('open_settings') == 'true'
if config.get('module_voicemail', 'true') == 'true':
return redirect(url_for('voicemail_inbox', open_settings=open_settings))
elif config.get('module_fax', 'true') == 'true':
return redirect(url_for('fax_inbox', open_settings=open_settings))
elif config.get('module_docs', 'true') == 'true':
return redirect(url_for('doc_inbox', open_settings=open_settings))
else:
return redirect(url_for('contact_page', open_settings=open_settings))
# --- AUTH ---
@app.route('/login', methods=['POST'])
def login():
username = request.form.get('username')
password = request.form.get('password')
admin_pw = config.get("admin_password", "6hJUE#MM")
if username == "admin" and password == admin_pw:
session['authenticated'] = True
session['user'] = "admin"
flash("Willkommen in COM|hub, Admin!", "success")
return redirect(url_for('index', open_settings='true'))
else:
flash("Zugriff verweigert.", "danger")
return redirect(request.referrer or url_for('index'))
@app.route('/logout')
def logout():
session.pop('authenticated', None)
session.pop('user', None)
session.pop('review_unlocked', None)
flash("Sitzung beendet.", "info")
return redirect(url_for('index'))
# --- CONTENT ---
@app.route('/voicemails/inbox')
def voicemail_inbox():
conn = get_db_connection()
voicemails = conn.execute("SELECT * FROM voicemails WHERE is_archived = 0 ORDER BY received_at DESC").fetchall()
contacts = conn.execute("SELECT * FROM contacts ORDER BY last_name, first_name").fetchall()
conn.close()
lookup = get_contact_lookup()
vm_list = []
for vm in voicemails:
v = dict(vm)
v['received_formatted'] = format_german_date(v.get('received_at', ''))
sender_raw = v.get('sender', '')
v['resolved_name'] = sender_raw
if sender_raw:
clean = normalize_number(sender_raw)
if clean in lookup: v['resolved_name'] = lookup[clean]
vm_list.append(v)
return render_template('index.html', active_tab="vm_inbox", voicemails=vm_list, contacts=contacts, config=config, open_settings=request.args.get('open_settings'))
@app.route('/voicemails/archive')
def voicemail_archive():
conn = get_db_connection()
voicemails = conn.execute("SELECT * FROM voicemails WHERE is_archived = 1 ORDER BY received_at DESC").fetchall()
contacts = conn.execute("SELECT * FROM contacts ORDER BY last_name, first_name").fetchall()
conn.close()
lookup = get_contact_lookup()
vm_list = []
for vm in voicemails:
v = dict(vm)
v['received_formatted'] = format_german_date(v.get('received_at', ''))
sender_raw = v.get('sender', '')
v['resolved_name'] = sender_raw
if sender_raw:
clean = normalize_number(sender_raw)
if clean in lookup: v['resolved_name'] = lookup[clean]
vm_list.append(v)
return render_template('index.html', active_tab="vm_archive", voicemails=vm_list, contacts=contacts, config=config, open_settings=request.args.get('open_settings'))
@app.route('/voicemails/retranscribe/<v_id>')
def retranscribe_voicemail(v_id):
if config.get('allow_retranscription', 'false') != 'true' or config.get('module_transcription', 'true') != 'true':
flash("Transkription ist deaktiviert.", "danger")
return redirect(request.referrer)
conn = get_db_connection()
conn.execute("UPDATE voicemails SET transcription = '[Transkription wird vorbereitet...]' WHERE id = ?", (v_id,))
conn.commit()
conn.close()
flash("Neu-Transkription eingereiht. Das Ergebnis erscheint in Kürze.", "info")
return redirect(request.referrer)
@app.route('/faxes/inbox')
def fax_inbox():
conn = get_db_connection()
faxes = conn.execute("SELECT * FROM faxes WHERE status = 'Neu' AND IFNULL(source, 'api') = 'api' ORDER BY received_at DESC").fetchall()
contacts = conn.execute("SELECT * FROM contacts ORDER BY last_name, first_name").fetchall()
conn.close()
faxes_resolved = resolve_faxes_with_names(faxes)
return render_template('index.html', active_tab="fax_inbox", faxes=faxes_resolved, contacts=contacts, config=config, open_settings=request.args.get('open_settings'))
@app.route('/documents/inbox')
def doc_inbox():
conn = get_db_connection()
faxes = conn.execute("SELECT * FROM faxes WHERE status = 'Neu' AND source = 'folder' ORDER BY received_at DESC").fetchall()
contacts = conn.execute("SELECT * FROM contacts ORDER BY last_name, first_name").fetchall()
conn.close()
faxes_resolved = resolve_faxes_with_names(faxes)
return render_template('index.html', active_tab="doc_inbox", faxes=faxes_resolved, contacts=contacts, config=config, open_settings=request.args.get('open_settings'))
@app.route('/faxes/review')
def fax_review():
conn = get_db_connection()
faxes = conn.execute("SELECT * FROM faxes WHERE status = 'Prüfung' ORDER BY received_at DESC").fetchall()
contacts = conn.execute("SELECT * FROM contacts ORDER BY last_name, first_name").fetchall()
conn.close()
faxes_resolved = resolve_faxes_with_names(faxes)
is_locked = False
if str(config.get('review_pin_enabled', 'false')).lower() == 'true' and not session.get('review_unlocked'):
is_locked = True
return render_template('index.html', active_tab="fax_review", faxes=faxes_resolved, contacts=contacts, config=config, open_settings=request.args.get('open_settings'), is_locked=is_locked)
@app.route('/faxes/outbox')
def fax_outbox():
conn = get_db_connection()
faxes_out = conn.execute("SELECT * FROM faxes_out ORDER BY created_at DESC").fetchall()
contacts = conn.execute("SELECT * FROM contacts ORDER BY last_name, first_name").fetchall()
conn.close()
faxes_out_resolved = resolve_outbox_with_names(faxes_out)
return render_template('index.html', active_tab="fax_outbox", faxes_out=faxes_out_resolved, contacts=contacts, config=config, open_settings=request.args.get('open_settings'))
@app.route('/faxes/review/unlock', methods=['POST'])
def unlock_review():
pin = request.form.get('pin')
if pin == config.get('review_pin', '0000'):
session['review_unlocked'] = True
flash("Prüfliste erfolgreich entsperrt.", "success")
else:
flash("Falscher PIN!", "danger")
return redirect(url_for('fax_review'))
@app.route('/faxes/review/lock')
def lock_review():
session.pop('review_unlocked', None)
flash("Prüfliste wurde wieder gesperrt.", "info")
return redirect(url_for('fax_review'))
# --- ADRESSBUCH ---
@app.route('/contacts')
def contact_page():
conn = get_db_connection()
contacts = conn.execute("SELECT * FROM contacts ORDER BY last_name, first_name").fetchall()
conn.close()
return render_template('index.html', active_tab="contacts", contacts=contacts, config=config, open_settings=request.args.get('open_settings'))
@app.route('/contacts/add', methods=['POST'])
def contact_add():
first_name = request.form.get('first_name')
last_name = request.form.get('last_name')
company = request.form.get('company')
number = request.form.get('number', '')
fax_number = request.form.get('fax_number', '')
patient_id = request.form.get('patient_id', '')
if not number and not fax_number and not patient_id:
flash("Rufnummer, Faxnummer oder Patienten-ID ist Pflicht!", "danger")
return redirect(url_for('contact_page'))
try:
url = "https://api.placetel.de/v2/contacts"
headers = {"Authorization": f"Bearer {config.get('placetel_api_token')}", "Content-Type": "application/json"}
payload = {"first_name": first_name, "last_name": last_name, "company": company, "phone_work": number, "fax": fax_number}
resp = requests.post(url, headers=headers, json=payload)
if resp.status_code == 200 or resp.status_code == 201:
if patient_id:
data = resp.json()
c_id = str(data.get('id', ''))
conn = get_db_connection()
conn.execute("UPDATE contacts SET patient_id = ? WHERE id = ?", (patient_id, c_id))
conn.commit()
conn.close()
flash("Kontakt erstellt.", "success")
threading.Thread(target=worker._process_contacts).start()
else:
flash(f"API Fehler: {resp.text}", "danger")
except Exception as e:
flash(f"Fehler: {e}", "danger")
return redirect(url_for('contact_page'))
@app.route('/contacts/edit', methods=['POST'])
def contact_edit():
c_id = request.form.get('id')
first_name = request.form.get('first_name')
last_name = request.form.get('last_name')
company = request.form.get('company')
number = request.form.get('number', '')
fax_number = request.form.get('fax_number', '')
patient_id = request.form.get('patient_id', '')
if not c_id:
flash("Fehler: ID fehlt.", "danger")
return redirect(url_for('contact_page'))
try:
if not str(c_id).startswith('gdt_'): # API Kontakt
url = f"https://api.placetel.de/v2/contacts/{c_id}"
headers = {"Authorization": f"Bearer {config.get('placetel_api_token')}", "Content-Type": "application/json"}
payload = {"first_name": first_name, "last_name": last_name, "company": company, "phone_work": number, "fax": fax_number}
resp = requests.put(url, headers=headers, json=payload)
if resp.status_code == 200:
conn = get_db_connection()
conn.execute("UPDATE contacts SET patient_id = ? WHERE id = ?", (patient_id, c_id))
conn.commit()
conn.close()
flash("Kontakt aktualisiert.", "success")
threading.Thread(target=worker._process_contacts).start()
else:
flash(f"Fehler beim Update: {resp.text}", "danger")
else: # Lokaler Kontakt
conn = get_db_connection()
conn.execute("UPDATE contacts SET first_name=?, last_name=?, company=?, number=?, fax_number=?, patient_id=? WHERE id=?",
(first_name, last_name, company, number, fax_number, patient_id, c_id))
conn.commit()
conn.close()
flash("Lokaler Kontakt aktualisiert.", "success")
except Exception as e:
flash(f"Systemfehler: {e}", "danger")
return redirect(url_for('contact_page'))
@app.route('/contacts/delete', methods=['POST'])
def contact_delete():
c_id = request.form.get('id')
pin = request.form.get('pin')
if pin != config.get("admin_pin", "1234"):
flash("Falscher PIN!", "danger")
return redirect(url_for('contact_page'))
try:
if not str(c_id).startswith('gdt_'): # API Kontakt
url = f"https://api.placetel.de/v2/contacts/{c_id}"
headers = {"Authorization": f"Bearer {config.get('placetel_api_token')}"}
resp = requests.delete(url, headers=headers)
if resp.status_code == 200 or resp.status_code == 204:
conn = get_db_connection()
conn.execute("DELETE FROM contacts WHERE id = ?", (c_id,))
conn.commit()
conn.close()
flash("Kontakt gelöscht.", "success")
else:
flash(f"Löschen fehlgeschlagen: {resp.text}", "danger")
else: # Lokaler Kontakt
conn = get_db_connection()
conn.execute("DELETE FROM contacts WHERE id = ?", (c_id,))
conn.commit()
conn.close()
flash("Lokaler Kontakt gelöscht.", "success")
except Exception as e:
flash(f"Fehler: {e}", "danger")
return redirect(url_for('contact_page'))
@app.route('/api/live_queue')
def api_live_queue():
api_token = config.get("placetel_api_token")
if not api_token or config.get("module_voicemail", "true") != "true": return {"calls": []}
try:
url = "https://api.placetel.de/v2/call_center_calls"
headers = {"Authorization": f"Bearer {api_token}", "Accept": "application/json"}
resp = requests.get(url, headers=headers, timeout=5)
if resp.status_code != 200: return {"calls": []}
calls = resp.json()
if isinstance(calls, dict): calls = calls.get('data', [])
lookup = get_contact_lookup()
live_calls = []
for c in calls:
caller = c.get('from', c.get('caller_id', 'Unbekannt'))
clean_caller = normalize_number(caller)
name = caller
if clean_caller in lookup:
name = lookup[clean_caller]
else:
for num, cname in lookup.items():
if len(num) > 6 and (clean_caller.endswith(num) or num.endswith(clean_caller)):
name = cname
break
wait_time_sec = 0
created_at = c.get('created_at')
if created_at:
try:
dt_str = created_at.replace('Z', '').split('+')[0]
if '.' in dt_str: dt_str = dt_str.split('.')[0]
dt_obj = datetime.strptime(dt_str, "%Y-%m-%dT%H:%M:%S")
wait_time_sec = int((datetime.utcnow() - dt_obj).total_seconds())
if wait_time_sec < 0: wait_time_sec = 0
except: pass
live_calls.append({"id": c.get('id', str(time.time())), "caller": caller, "name": name, "wait_time": wait_time_sec})
return {"calls": live_calls}
except Exception as e:
logging.error(f"Live Queue Error: {e}")
return {"calls": []}
@app.route('/api/counts')
def api_counts():
counts = dict(cnt_vm=0, cnt_fax=0, cnt_rev=0, cnt_out=0, cnt_doc=0)
try:
conn = get_db_connection()
counts['cnt_vm'] = conn.execute("SELECT count(*) FROM voicemails WHERE status='Neu' AND is_archived=0").fetchone()[0]
counts['cnt_fax'] = conn.execute("SELECT count(*) FROM faxes WHERE status='Neu' AND IFNULL(source, 'api') = 'api'").fetchone()[0]
counts['cnt_doc'] = conn.execute("SELECT count(*) FROM faxes WHERE status='Neu' AND source = 'folder'").fetchone()[0]
counts['cnt_rev'] = conn.execute("SELECT count(*) FROM faxes WHERE status='Prüfung'").fetchone()[0]
counts['cnt_out'] = conn.execute("SELECT count(*) FROM faxes_out WHERE status='Entwurf'").fetchone()[0]
conn.close()
except: pass
return counts
# --- ACTIONS ---
@app.route('/fax/action/<f_id>/<action>')
def fax_action(f_id, action):
conn = get_db_connection()
fax = conn.execute("SELECT * FROM faxes WHERE id = ?", (f_id,)).fetchone()
if not fax:
conn.close()
return "Not found", 404
if action == 'to_review':
conn.execute("UPDATE faxes SET status = 'Prüfung' WHERE id = ?", (f_id,))
conn.commit()
flash("Dokument zur Prüfliste verschoben.", "info")
conn.close()
return redirect(request.referrer)
@app.route('/fax/approve_export', methods=['POST'])
def fax_approve_export():
f_id = request.form.get('fax_id')
raw_name = request.form.get('filename', '').strip()
conn = get_db_connection()
fax = conn.execute("SELECT * FROM faxes WHERE id = ?", (f_id,)).fetchone()
if not fax:
conn.close()
flash("Dokument nicht gefunden.", "danger")
return redirect(request.referrer)
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
safe_sender = "".join(x for x in str(fax['sender']) if x.isalnum() or x in ['.', '-'])
if raw_name:
safe_name = re.sub(r'[^a-zA-Z0-9_\-äöüÄÖÜß ]', '_', raw_name).strip('_')
if not safe_name: safe_name = f"{timestamp}_{safe_sender}"
else:
safe_name = f"{timestamp}_{safe_sender}"
if not safe_name.lower().endswith(".pdf"):
safe_name += ".pdf"
export_path = config.get("standard_export_path", "/opt/comhub/share/Export")
try:
os.makedirs(export_path, exist_ok=True)
source_file = os.path.join(DOWNLOAD_DIR, fax['file_path'])
dest_path = os.path.join(export_path, safe_name)
if os.path.exists(source_file):
if os.path.exists(dest_path):
safe_name = f"{timestamp}_{safe_name}"
dest_path = os.path.join(export_path, safe_name)
shutil.move(source_file, dest_path)
conn.execute("UPDATE faxes SET status = 'Freigegeben' WHERE id = ?", (f_id,))
conn.commit()
flash(f"Erfolgreich exportiert: {safe_name}", "success")
else:
flash("Originaldatei nicht mehr vorhanden.", "danger")
except Exception as e:
flash(f"Fehler: {e}", "danger")
conn.close()
return redirect(request.referrer)
@app.route('/fax/export_gdt', methods=['POST'])
def fax_export_gdt():
f_id = request.form.get('fax_id')
patient_id = request.form.get('patient_id')
custom_filename = request.form.get('custom_filename', '').strip()
if not f_id or not patient_id:
flash("Fehlende Daten für GDT Export.", "danger")
return redirect(request.referrer)
conn = get_db_connection()
fax = conn.execute("SELECT * FROM faxes WHERE id = ?", (f_id,)).fetchone()
if not fax:
conn.close()
flash("Dokument nicht gefunden.", "danger")
return redirect(request.referrer)
gdt_out_dir = config.get("gdt_export_path", "/opt/comhub/share/GDT/Export")
try:
os.makedirs(gdt_out_dir, exist_ok=True)
source_file = os.path.join(DOWNLOAD_DIR, fax['file_path'])
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
document_title = "Eingehendes Dokument"
if custom_filename:
document_title = custom_filename
safe_custom = re.sub(r'[^a-zA-Z0-9_\-]', '_', custom_filename).strip('_')
if not safe_custom:
safe_custom = f"{timestamp}_Pat_{patient_id}"
safe_name = f"{safe_custom}.pdf"
else:
safe_name = f"{timestamp}_Pat_{patient_id}.pdf"
dest_path = os.path.join(gdt_out_dir, safe_name)
if os.path.exists(dest_path):
safe_name = f"{timestamp}_{safe_name}"
dest_path = os.path.join(gdt_out_dir, safe_name)
absolute_pdf_path = os.path.abspath(dest_path)
if os.path.exists(source_file):
shutil.move(source_file, dest_path)
def make_gdt_line(field, val):
val_str = str(val)
line_content = f"{field}{val_str}\r\n"
byte_len = len(line_content.encode('cp437')) + 3
return f"{byte_len:03d}{line_content}"
base_lines = [
make_gdt_line("8000", "6310"),
make_gdt_line("8315", config.get("gdt_receiver_id", "PVS")),
make_gdt_line("8316", config.get("gdt_sender_id", "COMHUB")),
make_gdt_line("3000", patient_id),
make_gdt_line("6200", datetime.now().strftime("%d%m%Y")),
make_gdt_line("6302", safe_name),
make_gdt_line("6303", "pdf"),
make_gdt_line("6304", document_title),
make_gdt_line("6305", absolute_pdf_path)
]
total_bytes = sum(len(l.encode('cp437')) for l in base_lines) + 14
lines = [
base_lines[0],
make_gdt_line("8100", f"{total_bytes:05d}")
] + base_lines[1:]
gdt_filename = os.path.join(gdt_out_dir, f"export_{timestamp}.gdt")
with open(gdt_filename, 'w', encoding='cp437') as f:
f.writelines(lines)
conn.execute("UPDATE faxes SET status = 'Freigegeben' WHERE id = ?", (f_id,))
conn.commit()
flash(f"PVS Export erfolgreich. Dateien im Pfad: {gdt_out_dir}", "success")
else:
flash("Quelldatei nicht mehr vorhanden.", "danger")
except Exception as e:
flash(f"GDT Export Fehler: {e}", "danger")
conn.close()
return redirect(request.referrer)
@app.route('/fax/save_note', methods=['POST'])
def fax_save_note():
out_id = request.form.get('out_id')
note = request.form.get('note', '').strip()
if out_id:
conn = get_db_connection()
conn.execute("UPDATE faxes_out SET note = ? WHERE id = ?", (note, out_id))
conn.commit()
conn.close()
return "", 204
@app.route('/fax/send', methods=['POST'])
def fax_send():
out_id = request.form.get('out_id')
raw_recipient = request.form.get('recipient')
note = request.form.get('note', '').strip()
recipient = normalize_number(raw_recipient)
if not recipient:
flash("Gültige Zielnummer erforderlich.", "danger")
return redirect(request.referrer)
from_number = config.get('fax_from_number', '')
email = config.get('fax_email', '')
if not email:
flash("Senden fehlgeschlagen: Absender E-Mail fehlt. Bitte in den Einstellungen eintragen.", "danger")
return redirect(request.referrer)
conn = get_db_connection()
fax = conn.execute("SELECT * FROM faxes_out WHERE id = ?", (out_id,)).fetchone()
if not fax:
conn.close()
flash("Entwurf nicht gefunden.", "danger")
return redirect(request.referrer)
file_path = os.path.join(DOWNLOAD_DIR, fax['file_path'])
if not os.path.exists(file_path):
conn.close()
flash("PDF-Datei nicht gefunden.", "danger")
return redirect(request.referrer)
if os.path.getsize(file_path) == 0:
conn.close()
flash("Senden fehlgeschlagen: Die PDF-Datei hat 0 Bytes (möglicherweise fehlerhafter Export).", "danger")
return redirect(request.referrer)
try:
with open(file_path, 'rb') as check_f:
if not check_f.read(5).startswith(b'%PDF-'):
conn.close()
flash("Senden abgebrochen: Dieser Entwurf ist defekt/unvollständig. Bitte löschen und Dokument neu drucken.", "danger")
return redirect(request.referrer)
except Exception:
pass
try:
url = "https://api.placetel.de/v2/faxes"
headers = {
"Authorization": f"Bearer {config.get('placetel_api_token')}",
"Content-Type": "application/json",
"Accept": "application/json"
}
with open(file_path, 'rb') as f:
pdf_b64 = base64.b64encode(f.read()).decode('utf-8')
payload = {
'to_number': recipient,
'email': email,
'file': f"data:application/pdf;base64,{pdf_b64}"
}
if from_number:
payload['from_number'] = from_number
resp = requests.post(url, headers=headers, json=payload)
if resp.status_code in [200, 201]:
data = resp.json()
placetel_id = str(data.get('id', ''))
conn.execute("UPDATE faxes_out SET status = 'In Arbeit', recipient = ?, note = ?, placetel_id = ? WHERE id = ?", (raw_recipient, note, placetel_id, out_id))
conn.commit()
flash("Fax erfolgreich an den Anbieter übergeben (Status: In Arbeit).", "success")
else:
flash(f"Fehler beim Senden: {resp.text}", "danger")
except Exception as e:
flash(f"Systemfehler: {e}", "danger")
conn.close()
return redirect(request.referrer)
@app.route('/fax/delete', methods=['POST'])
def fax_delete():
f_id = request.form.get('fax_id')
out_id = request.form.get('out_id')
pin = request.form.get('pin')
if pin != config.get("admin_pin", "1234"):
flash("Falscher PIN!", "danger")
return redirect(request.referrer)
conn = get_db_connection()
if f_id:
fax = conn.execute("SELECT * FROM faxes WHERE id = ?", (f_id,)).fetchone()
if fax:
p = os.path.join(DOWNLOAD_DIR, fax['file_path'])
if os.path.exists(p):
try: os.remove(p)
except: pass
conn.execute("DELETE FROM faxes WHERE id = ?", (f_id,))
conn.commit()
flash("Eingehendes Dokument gelöscht.", "success")
elif out_id:
fax_out = conn.execute("SELECT * FROM faxes_out WHERE id = ?", (out_id,)).fetchone()
if fax_out:
p = os.path.join(DOWNLOAD_DIR, fax_out['file_path'])
if os.path.exists(p):
try: os.remove(p)
except: pass
conn.execute("DELETE FROM faxes_out WHERE id = ?", (out_id,))
conn.commit()
flash("Ausgehendes Dokument gelöscht.", "success")
conn.close()
return redirect(request.referrer)
@app.route('/sync')
def trigger_sync():
worker.sync_now()
flash("Abruf gestartet.", "info")
return redirect(request.referrer)
@app.route('/system/update', methods=['POST'])
def system_update():
if session.get('user') != 'admin':
flash("Keine Berechtigung.", "danger")
return redirect(request.referrer)
app_file = request.files.get('app_file')
index_file = request.files.get('index_file')
needs_restart = False
if index_file and index_file.filename:
idx_path = os.path.join(TEMPLATES_DIR, 'index.html')
if os.path.exists(idx_path):
shutil.copy2(idx_path, idx_path + '.bak')
index_file.save(idx_path)
flash("Weboberfläche (index.html) aktualisiert.", "success")
if app_file and app_file.filename:
temp_path = os.path.join(BASE_DIR, 'temp_app.py')
app_file.save(temp_path)
try:
py_compile.compile(temp_path, doraise=True)
app_path = os.path.join(BASE_DIR, 'app.py')
if os.path.exists(app_path):
shutil.copy2(app_path, app_path + '.bak')
shutil.move(temp_path, app_path)
needs_restart = True
flash("Backend (app.py) aktualisiert. System führt Neustart durch...", "success")
except Exception as e:
os.remove(temp_path)
flash(f"Update abgebrochen! Syntax-Fehler in der Datei: {e}", "danger")
return redirect(request.referrer)
session.pop('authenticated', None)
session.pop('user', None)
if needs_restart:
def restart_service():
time.sleep(2)
subprocess.Popen(["systemctl", "restart", "comhub"])
threading.Thread(target=restart_service, daemon=True).start()
return redirect(request.referrer)
@app.route('/settings', methods=['POST'])
def settings():
if not session.get('authenticated'):
flash("Nicht eingeloggt!", "danger")
return redirect(request.referrer)
try:
config['placetel_api_token'] = request.form.get('api_token', '').strip()
config['check_interval_minutes'] = int(request.form.get('interval', 10))
days = int(request.form.get('days', 7))
config['delete_recordings_after_days'] = max(1, min(7, days))
config['standard_export_path'] = request.form.get('standard_export_path', '/opt/comhub/share/Export').strip()
config['fax_outbox_path'] = request.form.get('fax_outbox_path', '/opt/comhub/share/FaxOut').strip()
config['fax_from_number'] = request.form.get('fax_from_number', '').strip()
config['fax_email'] = request.form.get('fax_email', '').strip()
config['admin_pin'] = request.form.get('admin_pin', '1234').strip()
config['module_voicemail'] = 'true' if request.form.get('module_voicemail') else 'false'
config['module_transcription'] = 'true' if request.form.get('module_transcription') else 'false'
config['module_fax'] = 'true' if request.form.get('module_fax') else 'false'
config['module_gdt'] = 'true' if request.form.get('module_gdt') else 'false'
config['module_docs'] = 'true' if request.form.get('module_docs') else 'false'
config['review_pin_enabled'] = 'true' if request.form.get('review_pin_enabled') else 'false'
config['review_pin'] = request.form.get('review_pin', '0000').strip()
config['gdt_import_path'] = request.form.get('gdt_import_path', '/opt/comhub/share/GDT/Import').strip()
config['gdt_export_path'] = request.form.get('gdt_export_path', '/opt/comhub/share/GDT/Export').strip()
config['gdt_sender_id'] = request.form.get('gdt_sender_id', 'COMHUB').strip()
config['gdt_receiver_id'] = request.form.get('gdt_receiver_id', 'PVS').strip()
config['monitored_folder_path'] = request.form.get('monitored_folder_path', '/opt/comhub/share/Dokumente/Import').strip()
config['allow_retranscription'] = 'true' if request.form.get('allow_retranscription') else 'false'
new_admin_pw = request.form.get('admin_password', '').strip()
if new_admin_pw: config['admin_password'] = new_admin_pw
save_config(config)
worker.update_config()
session.pop('authenticated', None)
session.pop('user', None)
flash("Einstellungen gespeichert. Sie wurden sicherheitshalber abgemeldet.", "success")
except Exception as e:
flash(f"Fehler: {e}", "danger")
return redirect(request.referrer)
@app.route('/status/<v_id>/<new_status>')
def set_status(v_id, new_status):
conn = get_db_connection()
if new_status == 'Erledigt':
conn.execute("UPDATE voicemails SET status = 'Erledigt', is_archived = 1 WHERE id = ?", (v_id,))
elif new_status == 'Neu':
conn.execute("UPDATE voicemails SET status = 'Neu', is_archived = 0 WHERE id = ?", (v_id,))
conn.commit()
conn.close()
return redirect(request.referrer)
@app.route('/archive_toggle/<v_id>')
def archive_toggle(v_id):
conn = get_db_connection()
conn.execute("UPDATE voicemails SET is_archived = NOT is_archived WHERE id = ?", (v_id,))
conn.commit()
conn.close()
return redirect(request.referrer)
@app.route('/files/<filename>')
def serve_file(filename):
return send_from_directory(DOWNLOAD_DIR, filename)
if __name__ == '__main__':
worker.start_scheduler()
print("Server läuft auf http://0.0.0.0:5000")
app.run(debug=True, use_reloader=False, host='0.0.0.0', port=5000)