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

236
app.py
View File

@@ -5,59 +5,49 @@ 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
load_dotenv()
# Statisches Passwort aus der .env Datei
STATIC_PASSWORD = os.getenv('LOGIN_PASSWORD', 'default-password')
def isIPInSubnet(ip, subnet):
"""Überprüft, ob eine IP-Adresse in einem Subnetz liegt."""
try:
# Teile die IP und das Subnetz in ihre Komponenten
subnet_ip, bits = subnet.split('/')
ip_parts = [int(x) for x in ip.split('.')]
subnet_parts = [int(x) for x in subnet_ip.split('.')]
# Konvertiere IPs in 32-bit Zahlen
ip_num = (ip_parts[0] << 24) | (ip_parts[1] << 16) | (ip_parts[2] << 8) | ip_parts[3]
subnet_num = (subnet_parts[0] << 24) | (subnet_parts[1] << 16) | (subnet_parts[2] << 8) | subnet_parts[3]
# Erstelle die Subnetzmaske
mask = ~((1 << (32 - int(bits))) - 1)
# Prüfe, ob die IP im Subnetz liegt
return (ip_num & mask) == (subnet_num & mask)
except Exception as e:
logger.error(f"Fehler bei der IP-Überprüfung: {str(e)}")
return False
def get_db_connection(): def get_db_connection():
"""Erstellt eine neue Datenbankverbindung mit Timeout""" """Erstellt eine neue Datenbankverbindung für den aktuellen Thread"""
conn = sqlite3.connect(DB_FILE, timeout=20) if not hasattr(thread_local, "connection"):
conn.row_factory = sqlite3.Row thread_local.connection = sqlite3.connect(app.config['DATABASE'], timeout=app.config['DATABASE_TIMEOUT'])
return conn thread_local.connection.row_factory = sqlite3.Row
return thread_local.connection
@contextmanager
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(): def init_db():
"""Initialisiert die SQLite-Datenbank mit der notwendigen Tabelle.""" """Initialisiert die SQLite-Datenbank mit der notwendigen Tabelle."""
conn = get_db_connection() with get_db() as conn:
c = conn.cursor() c = conn.cursor()
try: try:
@@ -84,36 +74,45 @@ def init_db():
) )
''') ''')
# Erstelle Indizes für alle Suchfelder # Optimierte Indizes für die häufigsten Suchanfragen
c.execute('CREATE INDEX IF NOT EXISTS idx_customers_nummer ON customers(nummer)') c.execute('CREATE INDEX IF NOT EXISTS idx_customers_name_ort ON customers(name, ort)')
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_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_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_plz ON customers(plz)')
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 # Zusammengesetzter Index für die häufigste Suchkombination
c.execute('CREATE INDEX IF NOT EXISTS idx_customers_name_ort ON customers(name, ort)') c.execute('CREATE INDEX IF NOT EXISTS idx_customers_search ON customers(name, ort, fachrichtung, tag)')
conn.commit()
logger.info('Datenbank initialisiert') logger.info('Datenbank initialisiert')
except Exception as e: except Exception as e:
logger.error(f'Fehler bei der Datenbankinitialisierung: {str(e)}') logger.error(f'Fehler bei der Datenbankinitialisierung: {str(e)}')
raise raise
finally:
conn.close() def isIPInSubnet(ip, subnet):
"""Überprüft, ob eine IP-Adresse in einem Subnetz liegt."""
try:
# Teile die IP und das Subnetz in ihre Komponenten
subnet_ip, bits = subnet.split('/')
ip_parts = [int(x) for x in ip.split('.')]
subnet_parts = [int(x) for x in subnet_ip.split('.')]
# Konvertiere IPs in 32-bit Zahlen
ip_num = (ip_parts[0] << 24) | (ip_parts[1] << 16) | (ip_parts[2] << 8) | ip_parts[3]
subnet_num = (subnet_parts[0] << 24) | (subnet_parts[1] << 16) | (subnet_parts[2] << 8) | subnet_parts[3]
# Erstelle die Subnetzmaske
mask = ~((1 << (32 - int(bits))) - 1)
# Prüfe, ob die IP im Subnetz liegt
return (ip_num & mask) == (subnet_num & mask)
except Exception as e:
logger.error(f"Fehler bei der IP-Überprüfung: {str(e)}")
return False
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
@@ -126,18 +125,24 @@ def import_csv():
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']
# Bereite die Daten für den Batch-Insert vor
data = [(
row['VorNachname'], row['Nummer'], row['Strasse'], row['PLZ'], row['Ort'],
row['Tel'], row['Tel'], row['mail'], row['Fachrichtung'], 'medisoft',
row['Handy'], row['Tele Firma'], row['Kontakt1'], row['Kontakt2'], row['Kontakt3']
) for _, row in df.iterrows()]
# Führe Batch-Insert durch
c.executemany('''
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)
row['VorNachname'], row['Nummer'], row['Strasse'], row['PLZ'], row['Ort'],
row['Tel'], row['Tel'], row['mail'], row['Fachrichtung'], 'medisoft',
row['Handy'], row['Tele Firma'], row['Kontakt1'], row['Kontakt2'], row['Kontakt3']
))
else: else:
logger.warning("MEDISOFT CSV-Datei nicht gefunden") logger.warning("MEDISOFT CSV-Datei nicht gefunden")
@@ -148,33 +153,31 @@ def import_csv():
df_snk.columns = df_snk.columns.str.strip().str.replace('"', '') 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) df_snk = df_snk.apply(lambda x: x.str.strip().str.replace('"', '') if x.dtype == "object" else x)
for _, row in df_snk.iterrows(): # Filtere Datensätze mit Fachrichtung "intern"
c.execute(''' 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['Tel'], row['Tel'], row['mail'], row['Fachrichtung'], 'mediconsult',
row['Handy'], row['Tele Firma'], row['Kontakt1'], row['Kontakt2'], row['Kontakt3']
) for _, row in df_snk.iterrows()]
# Führe Batch-Insert durch
c.executemany('''
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)
row['VorNachname'], row['Nummer'], row['Strasse'], row['PLZ'], row['Ort'],
row['Tel'], row['Tel'], row['mail'], row['Fachrichtung'], 'mediconsult',
row['Handy'], row['Tele Firma'], row['Kontakt1'], row['Kontakt2'], row['Kontakt3']
))
else: else:
logger.warning("MEDICONSULT CSV-Datei nicht gefunden") logger.warning("MEDICONSULT CSV-Datei nicht gefunden")
conn.commit()
logger.info("CSV-Daten erfolgreich in die Datenbank importiert") 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,7 +236,7 @@ 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
@@ -314,9 +317,8 @@ def search():
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:
@@ -339,7 +341,6 @@ def search():
} }
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:
@@ -350,7 +351,7 @@ 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
@@ -365,8 +366,6 @@ def get_fachrichtungen():
''', (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)}")
@@ -376,7 +375,7 @@ 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
@@ -391,13 +390,62 @@ def get_orte():
''', (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>