1559 lines
67 KiB
Python
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) |