6 Commits

9 changed files with 612 additions and 289 deletions

View File

@@ -212,3 +212,10 @@ und dieses Projekt adhäriert zu [Semantic Versioning](https://semver.org/lang/d
- Hervorhebung von Suchbegriffen in den Ergebnissen - Hervorhebung von Suchbegriffen in den Ergebnissen
- Klickbare Links für Telefonnummern, E-Mail-Adressen und Adressen - Klickbare Links für Telefonnummern, E-Mail-Adressen und Adressen
- Docker-Container für einfache Installation und Deployment - Docker-Container für einfache Installation und Deployment
## [1.2.18] - 2024-03-19
### Geändert
- Version auf 1.2.18 aktualisiert
## [1.2.17] - 2024-03-19
# ... existing code ...

View File

@@ -11,10 +11,15 @@ Eine einfache und effiziente Kundensuche für medisoftware Kunden.
- 🏥 Unterscheidung zwischen MEDISOFT und MEDICONSULT Kunden - 🏥 Unterscheidung zwischen MEDISOFT und MEDICONSULT Kunden
- 🎨 Farbliche Hervorhebung der Kundentypen (blau für MEDISOFT, orange für MEDICONSULT) - 🎨 Farbliche Hervorhebung der Kundentypen (blau für MEDISOFT, orange für MEDICONSULT)
- 📍 Verbesserte Adress-Links mit Location- und Route-Icons - 📍 Verbesserte Adress-Links mit Location- und Route-Icons
- 📤 CSV-Export der Suchergebnisse
- 📥 CSV-Import für Kundendaten
- 📖 Integrierte README-Anzeige
- 🍔 Intuitives Hamburger-Menü
- 📱 VCF-Export für Kontakte
## Version ## Version
Aktuelle Version: 1.2.16 Aktuelle Version: 1.2.18
## Installation ## Installation
@@ -52,7 +57,8 @@ Die Anwendung kann über folgende Umgebungsvariablen konfiguriert werden:
- `FLASK_ENV`: Die Flask-Umgebung (development/production) - `FLASK_ENV`: Die Flask-Umgebung (development/production)
- `SECRET_KEY`: Der geheime Schlüssel für Flask-Sessions - `SECRET_KEY`: Der geheime Schlüssel für Flask-Sessions
- `DATABASE_URL`: Die URL zur SQLite-Datenbank - `DATABASE_URL`: Die URL zur SQLite-Datenbank
- `STATIC_PASSWORD`: Das Passwort für die Login-Seite - `LOGIN_PASSWORD`: Das Passwort für die Login-Seite
- `UPLOAD_PASSWORD`: Das Passwort für den CSV-Upload
- `ALLOWED_IP_RANGES`: Komma-getrennte Liste von IP-Bereichen, die direkten Zugriff haben - `ALLOWED_IP_RANGES`: Komma-getrennte Liste von IP-Bereichen, die direkten Zugriff haben
- `LOG_LEVEL`: Das Logging-Level (INFO/DEBUG) - `LOG_LEVEL`: Das Logging-Level (INFO/DEBUG)
@@ -107,23 +113,46 @@ curl "http://localhost:5001/search?name=Mustermann&telefon=030"
curl "http://localhost:5001/search?fachrichtung=Zahnarzt&ort=Berlin&name=Schmidt" curl "http://localhost:5001/search?fachrichtung=Zahnarzt&ort=Berlin&name=Schmidt"
``` ```
## Benutzeroberfläche
### Hauptmenü
- Home: Zurück zur Hauptseite
- CSV-Dateien hochladen: Import neuer Kundendaten
- README: Anzeige der Dokumentation
### Suchfunktionen
- Allgemeine Suche über alle Felder
- Spezifische Suche nach:
- Name
- Ort
- Kundennummer
- PLZ
- Fachrichtung
- Filterung nach Kundentyp (MEDISOFT/MEDICONSULT)
### Export
- CSV-Export der Suchergebnisse
- VCF-Export für Kontakte
- Direkte Links zu Kundendetails
- Teilen von Suchergebnissen
## Version ## Version
Aktuelle Version: [v1.2.4](CHANGELOG.md#v124---2024-03-19) Aktuelle Version: [v1.2.17](CHANGELOG.md#v1217---2024-03-19)
## Code-Statistiken ## Code-Statistiken
Language|files|blank|comment|code Language|files|blank|comment|code
:-------|-------:|-------:|-------:|-------: :-------|-------:|-------:|-------:|-------:
HTML|2|56|0|416 JavaScript|1|67|28|420
CSS|2|51|1|265 HTML|4|16|0|382
Markdown|2|66|0|236 CSS|1|63|0|324
Python|1|51|103|225 Markdown|2|77|0|295
YAML|1|0|0|13 YAML|1|0|0|14
Dockerfile|1|8|9|11 Dockerfile|1|8|9|11
Text|1|0|0|5 Text|1|0|0|6
--------|--------|--------|--------|-------- --------|--------|--------|--------|--------
SUM:|10|232|113|1171 SUM:|12|302|163|1732
## Lizenz ## Lizenz
Alle Rechte vorbehalten. © 2025 medisoftware Alle Rechte vorbehalten. © 2025 medisoftware

542
app.py
View File

@@ -5,28 +5,88 @@ import logging
import numpy as np import numpy as np
from datetime import datetime, timedelta from datetime import datetime, timedelta
from dotenv import load_dotenv from dotenv import load_dotenv
import requests
from collections import defaultdict
import ipaddress
import csv
import sqlite3 import sqlite3
from functools import wraps from functools import wraps
from contextlib import contextmanager
import time
import threading
import markdown2
app = Flask(__name__, static_folder='static') app = Flask(__name__, static_folder='static')
app.config['SECRET_KEY'] = os.environ.get('SECRET_KEY', 'dev') app.config['SECRET_KEY'] = os.environ.get('SECRET_KEY', 'dev')
app.config['ALLOWED_IP_RANGES'] = os.getenv('ALLOWED_IP_RANGES', '192.168.0.0/16,10.0.0.0/8').split(',') app.config['ALLOWED_IP_RANGES'] = os.getenv('ALLOWED_IP_RANGES', '192.168.0.0/16,10.0.0.0/8').split(',')
app.config['VERSION'] = '1.2.16' app.config['VERSION'] = '1.2.18'
app.config['DATABASE'] = 'data/customers.db'
app.config['DATABASE_TIMEOUT'] = 20
app.config['DATABASE_POOL_SIZE'] = 5
logging.basicConfig(level=logging.INFO) logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# Pfad zur Datenbank # Thread-lokaler Speicher für Datenbankverbindungen
DB_FILE = 'data/customers.db' thread_local = threading.local()
# Lade Umgebungsvariablen def get_db_connection():
load_dotenv() """Erstellt eine neue Datenbankverbindung für den aktuellen Thread"""
if not hasattr(thread_local, "connection"):
thread_local.connection = sqlite3.connect(app.config['DATABASE'], timeout=app.config['DATABASE_TIMEOUT'])
thread_local.connection.row_factory = sqlite3.Row
return thread_local.connection
# Statisches Passwort aus der .env Datei @contextmanager
STATIC_PASSWORD = os.getenv('LOGIN_PASSWORD', 'default-password') def get_db():
"""Context Manager für Datenbankverbindungen"""
conn = get_db_connection()
try:
yield conn
except Exception:
conn.rollback()
raise
finally:
conn.commit()
def init_db():
"""Initialisiert die SQLite-Datenbank mit der notwendigen Tabelle."""
with get_db() as conn:
c = conn.cursor()
try:
# Erstelle die Kunden-Tabelle
c.execute('''
CREATE TABLE IF NOT EXISTS customers (
id INTEGER PRIMARY KEY AUTOINCREMENT,
nummer TEXT,
name TEXT,
strasse TEXT,
plz TEXT,
ort TEXT,
telefon TEXT,
mobil TEXT,
email TEXT,
bemerkung TEXT,
fachrichtung TEXT,
tag TEXT,
handy TEXT,
tele_firma TEXT,
kontakt1 TEXT,
kontakt2 TEXT,
kontakt3 TEXT
)
''')
# Optimierte Indizes für die häufigsten Suchanfragen
c.execute('CREATE INDEX IF NOT EXISTS idx_customers_name_ort ON customers(name, ort)')
c.execute('CREATE INDEX IF NOT EXISTS idx_customers_fachrichtung ON customers(fachrichtung)')
c.execute('CREATE INDEX IF NOT EXISTS idx_customers_tag ON customers(tag)')
c.execute('CREATE INDEX IF NOT EXISTS idx_customers_plz ON customers(plz)')
# Zusammengesetzter Index für die häufigste Suchkombination
c.execute('CREATE INDEX IF NOT EXISTS idx_customers_search ON customers(name, ort, fachrichtung, tag)')
logger.info('Datenbank initialisiert')
except Exception as e:
logger.error(f'Fehler bei der Datenbankinitialisierung: {str(e)}')
raise
def isIPInSubnet(ip, subnet): def isIPInSubnet(ip, subnet):
"""Überprüft, ob eine IP-Adresse in einem Subnetz liegt.""" """Überprüft, ob eine IP-Adresse in einem Subnetz liegt."""
@@ -49,132 +109,75 @@ def isIPInSubnet(ip, subnet):
logger.error(f"Fehler bei der IP-Überprüfung: {str(e)}") logger.error(f"Fehler bei der IP-Überprüfung: {str(e)}")
return False return False
def get_db_connection():
"""Erstellt eine neue Datenbankverbindung mit Timeout"""
conn = sqlite3.connect(DB_FILE, timeout=20)
conn.row_factory = sqlite3.Row
return conn
def init_db():
"""Initialisiert die SQLite-Datenbank mit der notwendigen Tabelle."""
conn = get_db_connection()
c = conn.cursor()
try:
# Erstelle die Kunden-Tabelle
c.execute('''
CREATE TABLE IF NOT EXISTS customers (
id INTEGER PRIMARY KEY AUTOINCREMENT,
nummer TEXT,
name TEXT,
strasse TEXT,
plz TEXT,
ort TEXT,
telefon TEXT,
mobil TEXT,
email TEXT,
bemerkung TEXT,
fachrichtung TEXT,
tag TEXT,
handy TEXT,
tele_firma TEXT,
kontakt1 TEXT,
kontakt2 TEXT,
kontakt3 TEXT
)
''')
# Erstelle Indizes für alle Suchfelder
c.execute('CREATE INDEX IF NOT EXISTS idx_customers_nummer ON customers(nummer)')
c.execute('CREATE INDEX IF NOT EXISTS idx_customers_name ON customers(name)')
c.execute('CREATE INDEX IF NOT EXISTS idx_customers_strasse ON customers(strasse)')
c.execute('CREATE INDEX IF NOT EXISTS idx_customers_plz ON customers(plz)')
c.execute('CREATE INDEX IF NOT EXISTS idx_customers_ort ON customers(ort)')
c.execute('CREATE INDEX IF NOT EXISTS idx_customers_telefon ON customers(telefon)')
c.execute('CREATE INDEX IF NOT EXISTS idx_customers_mobil ON customers(mobil)')
c.execute('CREATE INDEX IF NOT EXISTS idx_customers_email ON customers(email)')
c.execute('CREATE INDEX IF NOT EXISTS idx_customers_fachrichtung ON customers(fachrichtung)')
c.execute('CREATE INDEX IF NOT EXISTS idx_customers_tag ON customers(tag)')
c.execute('CREATE INDEX IF NOT EXISTS idx_customers_handy ON customers(handy)')
c.execute('CREATE INDEX IF NOT EXISTS idx_customers_tele_firma ON customers(tele_firma)')
# Erstelle einen zusammengesetzten Index für die häufigste Suchkombination
c.execute('CREATE INDEX IF NOT EXISTS idx_customers_name_ort ON customers(name, ort)')
conn.commit()
logger.info('Datenbank initialisiert')
except Exception as e:
logger.error(f'Fehler bei der Datenbankinitialisierung: {str(e)}')
raise
finally:
conn.close()
def import_csv(): def import_csv():
"""Importiert die CSV-Datei in die Datenbank""" """Importiert die CSV-Datei in die Datenbank"""
conn = None
try: try:
conn = get_db_connection() with get_db() as conn:
c = conn.cursor() c = conn.cursor()
# Lösche bestehende Daten # Lösche bestehende Daten
c.execute('DELETE FROM customers') c.execute('DELETE FROM customers')
# Importiere MEDISOFT-Daten # Importiere MEDISOFT-Daten
if os.path.exists('data/customers.csv'): if os.path.exists('data/customers.csv'):
logger.info("Importiere MEDISOFT-Daten...") logger.info("Importiere MEDISOFT-Daten...")
df = pd.read_csv('data/customers.csv', encoding='iso-8859-1') df = pd.read_csv('data/customers.csv', encoding='iso-8859-1')
df.columns = df.columns.str.strip().str.replace('"', '') df.columns = df.columns.str.strip().str.replace('"', '')
df = df.apply(lambda x: x.str.strip().str.replace('"', '') if x.dtype == "object" else x) df = df.apply(lambda x: x.str.strip().str.replace('"', '') if x.dtype == "object" else x)
for _, row in df.iterrows(): # Filtere Datensätze mit Fachrichtung "intern"
c.execute(''' df = df[df['Fachrichtung'].str.lower() != 'intern']
INSERT INTO customers (
name, nummer, strasse, plz, ort, telefon, mobil, email, # Bereite die Daten für den Batch-Insert vor
fachrichtung, tag, handy, tele_firma, kontakt1, kontakt2, kontakt3 data = [(
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
''', (
row['VorNachname'], row['Nummer'], row['Strasse'], row['PLZ'], row['Ort'], row['VorNachname'], row['Nummer'], row['Strasse'], row['PLZ'], row['Ort'],
row['Tel'], row['Tel'], row['mail'], row['Fachrichtung'], 'medisoft', row['Tel'], row['Tel'], row['mail'], row['Fachrichtung'], 'medisoft',
row['Handy'], row['Tele Firma'], row['Kontakt1'], row['Kontakt2'], row['Kontakt3'] row['Handy'], row['Tele Firma'], row['Kontakt1'], row['Kontakt2'], row['Kontakt3']
)) ) for _, row in df.iterrows()]
else:
logger.warning("MEDISOFT CSV-Datei nicht gefunden")
# Importiere MEDICONSULT-Daten # Führe Batch-Insert durch
if os.path.exists('data/customers_snk.csv'): c.executemany('''
logger.info("Importiere MEDICONSULT-Daten...")
df_snk = pd.read_csv('data/customers_snk.csv', encoding='iso-8859-1')
df_snk.columns = df_snk.columns.str.strip().str.replace('"', '')
df_snk = df_snk.apply(lambda x: x.str.strip().str.replace('"', '') if x.dtype == "object" else x)
for _, row in df_snk.iterrows():
c.execute('''
INSERT INTO customers ( INSERT INTO customers (
name, nummer, strasse, plz, ort, telefon, mobil, email, name, nummer, strasse, plz, ort, telefon, mobil, email,
fachrichtung, tag, handy, tele_firma, kontakt1, kontakt2, kontakt3 fachrichtung, tag, handy, tele_firma, kontakt1, kontakt2, kontakt3
) )
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
''', ( ''', data)
else:
logger.warning("MEDISOFT CSV-Datei nicht gefunden")
# Importiere MEDICONSULT-Daten
if os.path.exists('data/customers_snk.csv'):
logger.info("Importiere MEDICONSULT-Daten...")
df_snk = pd.read_csv('data/customers_snk.csv', encoding='iso-8859-1')
df_snk.columns = df_snk.columns.str.strip().str.replace('"', '')
df_snk = df_snk.apply(lambda x: x.str.strip().str.replace('"', '') if x.dtype == "object" else x)
# Filtere Datensätze mit Fachrichtung "intern"
df_snk = df_snk[df_snk['Fachrichtung'].str.lower() != 'intern']
# Bereite die Daten für den Batch-Insert vor
data = [(
row['VorNachname'], row['Nummer'], row['Strasse'], row['PLZ'], row['Ort'], row['VorNachname'], row['Nummer'], row['Strasse'], row['PLZ'], row['Ort'],
row['Tel'], row['Tel'], row['mail'], row['Fachrichtung'], 'mediconsult', row['Tel'], row['Tel'], row['mail'], row['Fachrichtung'], 'mediconsult',
row['Handy'], row['Tele Firma'], row['Kontakt1'], row['Kontakt2'], row['Kontakt3'] row['Handy'], row['Tele Firma'], row['Kontakt1'], row['Kontakt2'], row['Kontakt3']
)) ) for _, row in df_snk.iterrows()]
else:
logger.warning("MEDICONSULT CSV-Datei nicht gefunden")
conn.commit() # Führe Batch-Insert durch
logger.info("CSV-Daten erfolgreich in die Datenbank importiert") c.executemany('''
INSERT INTO customers (
name, nummer, strasse, plz, ort, telefon, mobil, email,
fachrichtung, tag, handy, tele_firma, kontakt1, kontakt2, kontakt3
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
''', data)
else:
logger.warning("MEDICONSULT CSV-Datei nicht gefunden")
logger.info("CSV-Daten erfolgreich in die Datenbank importiert")
except Exception as e: except Exception as e:
logger.error(f"Fehler beim Importieren der CSV-Datei: {str(e)}") logger.error(f"Fehler beim Importieren der CSV-Datei: {str(e)}")
raise raise
finally:
if conn:
conn.close()
def clean_dataframe(df):
"""Konvertiert NaN-Werte in None für JSON-Kompatibilität"""
return df.replace({np.nan: None})
@app.route('/login', methods=['GET', 'POST']) @app.route('/login', methods=['GET', 'POST'])
def login(): def login():
@@ -191,7 +194,7 @@ def login():
if request.method == 'POST': if request.method == 'POST':
password = request.form.get('password') password = request.form.get('password')
if password == STATIC_PASSWORD: if password == os.environ.get('LOGIN_PASSWORD'):
session['logged_in'] = True session['logged_in'] = True
logger.info("Erfolgreicher Login") logger.info("Erfolgreicher Login")
return redirect(url_for('index')) return redirect(url_for('index'))
@@ -233,114 +236,112 @@ def search():
fachrichtung = request.args.get('fachrichtung', '') fachrichtung = request.args.get('fachrichtung', '')
tag = request.args.get('tag', 'medisoft') tag = request.args.get('tag', 'medisoft')
conn = get_db_connection() with get_db() as conn:
c = conn.cursor() c = conn.cursor()
# Baue die SQL-Abfrage # Baue die SQL-Abfrage
sql_query = ''' sql_query = '''
SELECT SELECT
nummer, nummer,
name, name,
strasse, strasse,
plz, plz,
ort, ort,
telefon, telefon,
mobil, mobil,
email, email,
fachrichtung, fachrichtung,
tag, tag,
handy, handy,
tele_firma, tele_firma,
kontakt1, kontakt1,
kontakt2, kontakt2,
kontakt3 kontakt3
FROM customers FROM customers
WHERE 1=1 WHERE 1=1
''' '''
params = [] params = []
# Füge die Suchbedingungen hinzu # Füge die Suchbedingungen hinzu
if search_query: if search_query:
# Optimierte Suche mit FTS (Full Text Search) # Optimierte Suche mit FTS (Full Text Search)
sql_query += """ sql_query += """
AND ( AND (
name LIKE ? OR name LIKE ? OR
nummer LIKE ? OR nummer LIKE ? OR
fachrichtung LIKE ? OR fachrichtung LIKE ? OR
ort LIKE ? OR ort LIKE ? OR
plz LIKE ? OR plz LIKE ? OR
strasse LIKE ? OR strasse LIKE ? OR
telefon LIKE ? OR telefon LIKE ? OR
mobil LIKE ? OR mobil LIKE ? OR
email LIKE ? OR email LIKE ? OR
bemerkung LIKE ? OR bemerkung LIKE ? OR
tag LIKE ? OR tag LIKE ? OR
handy LIKE ? OR handy LIKE ? OR
tele_firma LIKE ? OR tele_firma LIKE ? OR
kontakt1 LIKE ? OR kontakt1 LIKE ? OR
kontakt2 LIKE ? OR kontakt2 LIKE ? OR
kontakt3 LIKE ? kontakt3 LIKE ?
) )
""" """
search_term = f"%{search_query}%" search_term = f"%{search_query}%"
params.extend([search_term] * 16) # 16 Felder für die allgemeine Suche params.extend([search_term] * 16) # 16 Felder für die allgemeine Suche
if name: if name:
sql_query += " AND name LIKE ?" sql_query += " AND name LIKE ?"
params.append(f"%{name}%") params.append(f"%{name}%")
if ort: if ort:
sql_query += " AND ort LIKE ?" sql_query += " AND ort LIKE ?"
params.append(f"%{ort}%") params.append(f"%{ort}%")
if nummer: if nummer:
sql_query += " AND nummer LIKE ?" sql_query += " AND nummer LIKE ?"
params.append(f"%{nummer}%") params.append(f"%{nummer}%")
if plz: if plz:
sql_query += " AND plz LIKE ?" sql_query += " AND plz LIKE ?"
params.append(f"%{plz}%") params.append(f"%{plz}%")
if fachrichtung: if fachrichtung:
sql_query += " AND fachrichtung LIKE ?" sql_query += " AND fachrichtung LIKE ?"
params.append(f"%{fachrichtung}%") params.append(f"%{fachrichtung}%")
# Filter nach Tag # Filter nach Tag
if tag != 'all': if tag != 'all':
sql_query += " AND tag = ?" sql_query += " AND tag = ?"
params.append(tag) params.append(tag)
# Füge LIMIT hinzu und optimiere die Sortierung # Füge LIMIT hinzu und optimiere die Sortierung
sql_query += " ORDER BY name LIMIT 100" sql_query += " ORDER BY name LIMIT 100"
# Führe die Abfrage aus # Führe die Abfrage aus
cursor = conn.cursor() c.execute(sql_query, params)
cursor.execute(sql_query, params) results = c.fetchall()
results = cursor.fetchall()
formatted_results = [] formatted_results = []
for row in results: for row in results:
customer = { customer = {
'nummer': row[0], 'nummer': row[0],
'name': row[1], 'name': row[1],
'strasse': row[2], 'strasse': row[2],
'plz': row[3], 'plz': row[3],
'ort': row[4], 'ort': row[4],
'telefon': row[5], 'telefon': row[5],
'mobil': row[6], 'mobil': row[6],
'email': row[7], 'email': row[7],
'fachrichtung': row[8], 'fachrichtung': row[8],
'tag': row[9], 'tag': row[9],
'handy': row[10], 'handy': row[10],
'tele_firma': row[11], 'tele_firma': row[11],
'kontakt1': row[12], 'kontakt1': row[12],
'kontakt2': row[13], 'kontakt2': row[13],
'kontakt3': row[14] 'kontakt3': row[14]
} }
formatted_results.append(customer) formatted_results.append(customer)
conn.close() return jsonify(formatted_results)
return jsonify(formatted_results)
except Exception as e: except Exception as e:
logger.error(f"Fehler bei der Suche: {str(e)}") logger.error(f"Fehler bei der Suche: {str(e)}")
@@ -350,24 +351,22 @@ def search():
def get_fachrichtungen(): def get_fachrichtungen():
try: try:
search_term = request.args.get('q', '').lower() search_term = request.args.get('q', '').lower()
conn = get_db_connection() with get_db() as conn:
c = conn.cursor() c = conn.cursor()
# Hole alle eindeutigen Fachrichtungen, die mit dem Suchbegriff übereinstimmen # Hole alle eindeutigen Fachrichtungen, die mit dem Suchbegriff übereinstimmen
c.execute(''' c.execute('''
SELECT DISTINCT fachrichtung SELECT DISTINCT fachrichtung
FROM customers FROM customers
WHERE fachrichtung IS NOT NULL WHERE fachrichtung IS NOT NULL
AND fachrichtung != '' AND fachrichtung != ''
AND LOWER(fachrichtung) LIKE ? AND LOWER(fachrichtung) LIKE ?
ORDER BY fachrichtung ORDER BY fachrichtung
LIMIT 10 LIMIT 10
''', (f'%{search_term}%',)) ''', (f'%{search_term}%',))
fachrichtungen = [row[0] for row in c.fetchall()] fachrichtungen = [row[0] for row in c.fetchall()]
conn.close() return jsonify(fachrichtungen)
return jsonify(fachrichtungen)
except Exception as e: except Exception as e:
logger.error(f"Fehler beim Abrufen der Fachrichtungen: {str(e)}") logger.error(f"Fehler beim Abrufen der Fachrichtungen: {str(e)}")
return jsonify([]) return jsonify([])
@@ -376,28 +375,77 @@ def get_fachrichtungen():
def get_orte(): def get_orte():
try: try:
search_term = request.args.get('q', '').lower() search_term = request.args.get('q', '').lower()
conn = get_db_connection() with get_db() as conn:
c = conn.cursor() c = conn.cursor()
# Hole alle eindeutigen Orte, die mit dem Suchbegriff übereinstimmen # Hole alle eindeutigen Orte, die mit dem Suchbegriff übereinstimmen
c.execute(''' c.execute('''
SELECT DISTINCT ort SELECT DISTINCT ort
FROM customers FROM customers
WHERE ort IS NOT NULL WHERE ort IS NOT NULL
AND ort != '' AND ort != ''
AND LOWER(ort) LIKE ? AND LOWER(ort) LIKE ?
ORDER BY ort ORDER BY ort
LIMIT 10 LIMIT 10
''', (f'%{search_term}%',)) ''', (f'%{search_term}%',))
orte = [row[0] for row in c.fetchall()] orte = [row[0] for row in c.fetchall()]
conn.close() return jsonify(orte)
return jsonify(orte)
except Exception as e: except Exception as e:
logger.error(f"Fehler beim Abrufen der Orte: {str(e)}") logger.error(f"Fehler beim Abrufen der Orte: {str(e)}")
return jsonify([]) return jsonify([])
@app.route('/upload', methods=['GET', 'POST'])
def upload():
if not session.get('logged_in'):
return redirect(url_for('login'))
if request.method == 'POST':
if request.form.get('password') != os.environ.get('UPLOAD_PASSWORD'):
return render_template('upload.html', error="Falsches Passwort", version=app.config['VERSION'])
if 'customers_snk' not in request.files or 'customers' not in request.files:
return render_template('upload.html', error="Bitte beide CSV-Dateien auswählen", version=app.config['VERSION'])
customers_snk = request.files['customers_snk']
customers = request.files['customers']
if customers_snk.filename == '' or customers.filename == '':
return render_template('upload.html', error="Keine Datei ausgewählt", version=app.config['VERSION'])
if not customers_snk.filename.endswith('.csv') or not customers.filename.endswith('.csv'):
return render_template('upload.html', error="Nur CSV-Dateien sind erlaubt", version=app.config['VERSION'])
try:
# Speichere die Dateien
customers_snk.save('data/customers_snk.csv')
customers.save('data/customers.csv')
# Importiere die Daten in die Datenbank
import_csv('data/customers_snk.csv', 'snk')
import_csv('data/customers.csv', 'medisoft')
return render_template('upload.html', success="Dateien erfolgreich hochgeladen und importiert", version=app.config['VERSION'])
except Exception as e:
logger.error(f"Fehler beim Upload: {str(e)}")
return render_template('upload.html', error=f"Fehler beim Upload: {str(e)}", version=app.config['VERSION'])
return render_template('upload.html', version=app.config['VERSION'])
@app.route('/readme')
def readme():
if not session.get('logged_in'):
return redirect(url_for('login'))
try:
with open('README.md', 'r', encoding='utf-8') as f:
content = f.read()
html_content = markdown2.markdown(content, extras=['fenced-code-blocks', 'tables'])
return render_template('readme.html', content=html_content, version=app.config['VERSION'])
except Exception as e:
logger.error(f"Fehler beim Lesen der README: {str(e)}")
return render_template('readme.html', error="Fehler beim Lesen der README", version=app.config['VERSION'])
def init_app(app): def init_app(app):
"""Initialisiert die Anwendung mit allen notwendigen Einstellungen.""" """Initialisiert die Anwendung mit allen notwendigen Einstellungen."""
with app.app_context(): with app.app_context():
@@ -406,10 +454,10 @@ def init_app(app):
os.makedirs('data', exist_ok=True) os.makedirs('data', exist_ok=True)
# Lösche die alte Datenbank, falls sie existiert # Lösche die alte Datenbank, falls sie existiert
if os.path.exists(DB_FILE): if os.path.exists(app.config['DATABASE']):
try: try:
os.remove(DB_FILE) os.remove(app.config['DATABASE'])
logger.info(f"Alte Datenbank {DB_FILE} wurde gelöscht") logger.info(f"Alte Datenbank {app.config['DATABASE']} wurde gelöscht")
except Exception as e: except Exception as e:
logger.error(f"Fehler beim Löschen der alten Datenbank: {str(e)}") logger.error(f"Fehler beim Löschen der alten Datenbank: {str(e)}")

View File

@@ -9,5 +9,6 @@ services:
- FLASK_APP=app.py - FLASK_APP=app.py
- FLASK_ENV=production - FLASK_ENV=production
- LOGIN_PASSWORD=changeme - LOGIN_PASSWORD=changeme
- UPLOAD_PASSWORD=upload_changeme
- ALLOWED_IP_RANGES=213.178.68.218/29,192.168.0.0/24,192.168.177.0/24 - ALLOWED_IP_RANGES=213.178.68.218/29,192.168.0.0/24,192.168.177.0/24
command: flask run --host=0.0.0.0 command: flask run --host=0.0.0.0

View File

@@ -3,3 +3,4 @@ pandas==2.2.1
numpy==1.26.4 numpy==1.26.4
python-dotenv==1.0.1 python-dotenv==1.0.1
requests==2.32.3 requests==2.32.3
markdown2==2.4.12

View File

@@ -2,6 +2,15 @@ let searchTimeout;
let lastResults = []; let lastResults = [];
let fachrichtungTimeout; let fachrichtungTimeout;
let ortTimeout; let ortTimeout;
let currentPage = 1;
let totalPages = 1;
let currentResults = [];
let currentSearchQuery = '';
let currentFilters = {
fachrichtung: '',
plz: '',
ort: ''
};
function createPhoneLink(phone) { function createPhoneLink(phone) {
if (!phone) return ''; if (!phone) return '';
@@ -220,14 +229,13 @@ function exportToVCF(customer) {
const vcfData = [ const vcfData = [
'BEGIN:VCARD', 'BEGIN:VCARD',
'VERSION:3.0', 'VERSION:3.0',
`FN:${customer.vorname || ''} ${customer.nachname || ''}`, `FN:${customer.name || ''}`,
`N:${customer.nachname || ''};${customer.vorname || ''};;`, `N:${customer.name || ''};;;`,
`TEL;TYPE=CELL:${customer.telefon || ''}`, `TEL;TYPE=CELL:${customer.telefon || ''}`,
`TEL;TYPE=HOME:${customer.telefon_2 || ''}`, `TEL;TYPE=HOME:${customer.mobil || ''}`,
`EMAIL:${customer.email || ''}`, `EMAIL:${customer.email || ''}`,
`ADR;TYPE=HOME:;;${customer.strasse || ''};${customer.plz || ''};${customer.ort || ''};${customer.land || ''}`, `ADR;TYPE=HOME:;;${customer.strasse || ''};${customer.plz || ''};${customer.ort || ''};`,
`ORG:${customer.firma || ''}`, `ORG:${customer.fachrichtung || ''}`,
`NOTE:${customer.notizen || ''}`,
'END:VCARD' 'END:VCARD'
].join('\n'); ].join('\n');
@@ -235,7 +243,7 @@ function exportToVCF(customer) {
const url = window.URL.createObjectURL(blob); const url = window.URL.createObjectURL(blob);
const a = document.createElement('a'); const a = document.createElement('a');
a.href = url; a.href = url;
a.download = `kontakt_${customer.vorname || ''}_${customer.nachname || ''}_${new Date().toISOString().split('T')[0]}.vcf`; a.download = `kontakt_${customer.name || ''}_${new Date().toISOString().split('T')[0]}.vcf`;
document.body.appendChild(a); document.body.appendChild(a);
a.click(); a.click();
window.URL.revokeObjectURL(url); window.URL.revokeObjectURL(url);
@@ -328,10 +336,14 @@ function displayResults(results) {
function clearInput(inputId) { function clearInput(inputId) {
document.getElementById(inputId).value = ''; document.getElementById(inputId).value = '';
searchCustomers(); document.getElementById('results').innerHTML = '';
document.getElementById('result-count').textContent = '';
document.getElementById('exportButton').style.display = 'none';
lastResults = [];
} }
async function searchCustomers() { async function searchCustomers() {
let searchTimeout;
const loading = document.getElementById('loading'); const loading = document.getElementById('loading');
const results = document.getElementById('results'); const results = document.getElementById('results');
const generalSearch = document.getElementById('q').value; const generalSearch = document.getElementById('q').value;
@@ -341,6 +353,8 @@ async function searchCustomers() {
const plzSearch = document.getElementById('plzInput').value; const plzSearch = document.getElementById('plzInput').value;
const fachrichtungSearch = document.getElementById('fachrichtungInput').value; const fachrichtungSearch = document.getElementById('fachrichtungInput').value;
const tagFilter = document.getElementById('tagFilter').value; const tagFilter = document.getElementById('tagFilter').value;
currentSearchQuery = generalSearch;
currentPage = 1;
// Zeige Ladeanimation // Zeige Ladeanimation
loading.style.display = 'block'; loading.style.display = 'block';

View File

@@ -15,8 +15,32 @@
<body> <body>
<div class="main-content"> <div class="main-content">
<div class="container"> <div class="container">
<div class="text-center mb-4"> <div class="d-flex justify-content-between align-items-center mb-4">
<a href="https://medisoftware.de" target="_blank" rel="noopener noreferrer"><img src="{{ url_for('static', filename='medisoftware_logo_rb_200.png') }}" alt="medisoftware Logo" class="img-fluid" style="max-width: 200px;"></a> <div class="dropdown">
<button class="btn btn-link text-dark" type="button" id="menuButton" data-bs-toggle="dropdown" aria-expanded="false">
<i class="bi bi-list fs-4"></i>
</button>
<ul class="dropdown-menu" aria-labelledby="menuButton">
<li>
<a class="dropdown-item" href="{{ url_for('index') }}">
<i class="bi bi-house"></i> Home
</a>
</li>
<li>
<a class="dropdown-item" href="{{ url_for('upload') }}">
<i class="bi bi-cloud-upload"></i> CSV-Dateien hochladen
</a>
</li>
<li>
<a class="dropdown-item" href="{{ url_for('readme') }}">
<i class="bi bi-book"></i> README
</a>
</li>
</ul>
</div>
<a href="https://medisoftware.de" target="_blank" rel="noopener noreferrer">
<img src="{{ url_for('static', filename='medisoftware_logo_rb_200.png') }}" alt="medisoftware Logo" class="img-fluid" style="max-width: 200px;">
</a>
</div> </div>
<div class="search-container"> <div class="search-container">
<h1 class="text-center mb-4">Kundensuche</h1> <h1 class="text-center mb-4">Kundensuche</h1>
@@ -111,5 +135,6 @@
</footer> </footer>
<script src="{{ url_for('static', filename='js/main.js') }}"></script> <script src="{{ url_for('static', filename='js/main.js') }}"></script>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
</body> </body>
</html> </html>

111
templates/readme.html Normal file
View File

@@ -0,0 +1,111 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>medisoftware Kundensuche - README</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css" rel="stylesheet">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css">
<link href="{{ url_for('static', filename='css/styles.css') }}" rel="stylesheet">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/github-markdown-css@5.5.0/github-markdown.min.css">
<style>
body {
min-height: 100vh;
display: flex;
flex-direction: column;
margin: 0;
padding: 0;
}
.main-content {
flex: 1 0 auto;
padding: 2rem 0;
margin-bottom: 4rem;
}
.footer {
flex-shrink: 0;
text-align: center;
padding: 1rem;
background-color: #f8f9fa;
border-top: 1px solid #dee2e6;
width: 100%;
position: fixed;
bottom: 0;
left: 0;
z-index: 100;
}
.markdown-body {
box-sizing: border-box;
min-width: 200px;
max-width: 980px;
margin: 0 auto;
padding: 45px;
}
@media (max-width: 767px) {
.markdown-body {
padding: 15px;
}
}
</style>
</head>
<body>
<div class="main-content">
<div class="container">
<div class="d-flex justify-content-between align-items-center mb-4">
<div class="dropdown">
<button class="btn btn-link text-dark" type="button" id="menuButton" data-bs-toggle="dropdown" aria-expanded="false">
<i class="bi bi-list fs-4"></i>
</button>
<ul class="dropdown-menu" aria-labelledby="menuButton">
<li>
<a class="dropdown-item" href="{{ url_for('index') }}">
<i class="bi bi-house"></i> Home
</a>
</li>
<li>
<a class="dropdown-item" href="{{ url_for('upload') }}">
<i class="bi bi-cloud-upload"></i> CSV-Dateien hochladen
</a>
</li>
<li>
<a class="dropdown-item" href="{{ url_for('readme') }}">
<i class="bi bi-book"></i> README
</a>
</li>
</ul>
</div>
<a href="https://medisoftware.de" target="_blank" rel="noopener noreferrer">
<img src="{{ url_for('static', filename='medisoftware_logo_rb_200.png') }}" alt="medisoftware Logo" class="img-fluid" style="max-width: 200px;">
</a>
</div>
<div class="card">
<div class="card-header">
<h2 class="text-center mb-0">README</h2>
</div>
<div class="card-body">
{% if error %}
<div class="alert alert-danger" role="alert">
{{ error }}
</div>
{% else %}
<div class="markdown-body">
{{ content | safe }}
</div>
{% endif %}
</div>
</div>
</div>
</div>
<footer class="footer">
<div class="footer-content">
Proudly made with ❤️ and 🍺 by <a href="https://www.medisoftware.de" target="_blank" class="footer-link">medisoftware</a>
<div style="font-size: 0.8em;">Version: {{ version }}</div>
</div>
</footer>
<script src="{{ url_for('static', filename='js/main.js') }}"></script>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
</body>
</html>

87
templates/upload.html Normal file
View File

@@ -0,0 +1,87 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>medisoftware Kundensuche - CSV Upload</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<style>
body {
min-height: 100vh;
display: flex;
flex-direction: column;
margin: 0;
padding: 0;
}
.main-content {
flex: 1 0 auto;
padding: 2rem 0;
margin-bottom: 4rem;
}
.footer {
flex-shrink: 0;
text-align: center;
padding: 1rem;
background-color: #f8f9fa;
border-top: 1px solid #dee2e6;
width: 100%;
position: fixed;
bottom: 0;
left: 0;
z-index: 100;
}
</style>
</head>
<body>
<div class="container mt-5">
<div class="text-center mb-4">
<img src="{{ url_for('static', filename='medisoftware_logo_rb_200.png') }}" alt="medisoftware Logo" class="img-fluid" style="max-width: 200px;">
</div>
<div class="row justify-content-center">
<div class="col-md-6">
<div class="card">
<div class="card-header">
<h2 class="text-center mb-0">CSV Upload</h2>
</div>
<div class="card-body">
{% if error %}
<div class="alert alert-danger" role="alert">
{{ error }}
</div>
{% endif %}
{% if success %}
<div class="alert alert-success" role="alert">
{{ success }}
</div>
{% endif %}
<form method="POST" enctype="multipart/form-data">
<div class="mb-3">
<label for="customers_snk" class="form-label">SNK Kunden (customers_snk.csv)</label>
<input type="file" class="form-control" id="customers_snk" name="customers_snk" accept=".csv" required>
</div>
<div class="mb-3">
<label for="customers" class="form-label">MEDISOFT Kunden (customers.csv)</label>
<input type="file" class="form-control" id="customers" name="customers" accept=".csv" required>
</div>
<div class="mb-3">
<label for="password" class="form-label">Passwort</label>
<input type="password" class="form-control" id="password" name="password" required>
</div>
<div class="d-grid gap-2">
<button type="submit" class="btn btn-primary">Dateien hochladen</button>
<a href="{{ url_for('index') }}" class="btn btn-secondary">Zurück zur Suche</a>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
<footer class="footer">
<div class="footer-content">
Proudly made with ❤️ and 🍺 by <a href="https://www.medisoftware.de" target="_blank" class="footer-link">medisoftware</a>
<div style="font-size: 0.8em;">Version: {{ version }}</div>
</div>
</footer>
</body>
</html>