Initial commit
This commit is contained in:
14
.dockerignore
Normal file
14
.dockerignore
Normal file
@@ -0,0 +1,14 @@
|
||||
__pycache__
|
||||
*.pyc
|
||||
*.pyo
|
||||
*.pyd
|
||||
.Python
|
||||
env/
|
||||
venv/
|
||||
.env
|
||||
*.log
|
||||
.git
|
||||
.gitignore
|
||||
.pytest_cache/
|
||||
.coverage
|
||||
htmlcov/
|
48
.gitignore
vendored
Normal file
48
.gitignore
vendored
Normal file
@@ -0,0 +1,48 @@
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
*.so
|
||||
.Python
|
||||
venv/
|
||||
env/
|
||||
ENV/
|
||||
.env
|
||||
.venv
|
||||
pip-log.txt
|
||||
pip-delete-this-directory.txt
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
|
||||
# Distribution / packaging
|
||||
dist/
|
||||
build/
|
||||
*.egg-info/
|
||||
|
||||
# Unit test / coverage reports
|
||||
htmlcov/
|
||||
.tox/
|
||||
.coverage
|
||||
.coverage.*
|
||||
.cache
|
||||
nosetests.xml
|
||||
coverage.xml
|
||||
*.cover
|
||||
.hypothesis/
|
||||
.pytest_cache/
|
||||
|
||||
# Jupyter Notebook
|
||||
.ipynb_checkpoints
|
||||
|
||||
# Docker
|
||||
.docker/
|
||||
|
||||
# Daten
|
||||
spezexpo.csv
|
24
Dockerfile
Normal file
24
Dockerfile
Normal file
@@ -0,0 +1,24 @@
|
||||
# Python 3.11 als Basis-Image
|
||||
FROM python:3.11-slim
|
||||
|
||||
# Arbeitsverzeichnis setzen
|
||||
WORKDIR /app
|
||||
|
||||
# Kopiere requirements.txt
|
||||
COPY requirements.txt .
|
||||
|
||||
# Installiere Abhängigkeiten
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# Kopiere den Anwendungscode
|
||||
COPY . .
|
||||
|
||||
# Exponiere Port 5000
|
||||
EXPOSE 5000
|
||||
|
||||
# Setze Umgebungsvariablen
|
||||
ENV FLASK_APP=app.py
|
||||
ENV FLASK_ENV=production
|
||||
|
||||
# Starte die Anwendung
|
||||
CMD ["flask", "run", "--host=0.0.0.0"]
|
169
README.md
Normal file
169
README.md
Normal file
@@ -0,0 +1,169 @@
|
||||
# medisoftware Kundensuche
|
||||
|
||||
Eine Flask-basierte Webanwendung zur Suche in Kundendaten aus einer CSV-Datei.
|
||||
|
||||
## Features
|
||||
|
||||
- Live-Suche während der Eingabe
|
||||
- Suche nach:
|
||||
- Kundennummer
|
||||
- Name (Vor- und Nachname)
|
||||
- Fachrichtung
|
||||
- Ort
|
||||
- Klickbare Links für:
|
||||
- Telefonnummern (tel:)
|
||||
- E-Mail-Adressen (mailto:)
|
||||
- Adressen (Google Maps)
|
||||
- Kundennummern (KKBefe-System)
|
||||
- Responsive Design mit Bootstrap
|
||||
- Docker-Container-Unterstützung
|
||||
|
||||
## Technische Details
|
||||
|
||||
### Technologie-Stack
|
||||
|
||||
- **Backend**: Python 3.11 mit Flask
|
||||
- **Frontend**: HTML, CSS, JavaScript, Bootstrap 5
|
||||
- **Datenverarbeitung**: pandas, numpy
|
||||
- **Container**: Docker
|
||||
|
||||
### Projektstruktur
|
||||
|
||||
```
|
||||
medi-customers/
|
||||
├── app.py # Flask-Anwendung
|
||||
├── templates/ # HTML-Templates
|
||||
│ └── index.html # Hauptseite
|
||||
├── spezexpo.csv # Kundendaten
|
||||
├── requirements.txt # Python-Abhängigkeiten
|
||||
├── Dockerfile # Docker-Konfiguration
|
||||
└── .dockerignore # Docker-Ignore-Datei
|
||||
```
|
||||
|
||||
### Datenformat
|
||||
|
||||
Die Anwendung erwartet eine CSV-Datei (`spezexpo.csv`) mit folgenden Spalten:
|
||||
- Nummer (Kundennummer)
|
||||
- Vorname
|
||||
- Nachname
|
||||
- Fachrichtung
|
||||
- Strasse
|
||||
- PLZ
|
||||
- Ort
|
||||
- Tel
|
||||
- mail
|
||||
|
||||
## Installation
|
||||
|
||||
### Lokale Entwicklung
|
||||
|
||||
1. Python 3.11 installieren
|
||||
2. Virtuelle Umgebung erstellen und aktivieren:
|
||||
```bash
|
||||
python -m venv venv
|
||||
source venv/bin/activate # Linux/Mac
|
||||
venv\Scripts\activate # Windows
|
||||
```
|
||||
3. Abhängigkeiten installieren:
|
||||
```bash
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
4. Anwendung starten:
|
||||
```bash
|
||||
python app.py
|
||||
```
|
||||
|
||||
### Docker-Container
|
||||
|
||||
1. Docker installieren
|
||||
2. Container bauen:
|
||||
```bash
|
||||
docker build -t medi-customers .
|
||||
```
|
||||
3. Container starten:
|
||||
```bash
|
||||
docker run -d -p 5000:5000 --name medi-customers medi-customers
|
||||
```
|
||||
|
||||
## API-Endpunkte
|
||||
|
||||
### GET /
|
||||
- Rendert die Hauptseite
|
||||
|
||||
### GET /search
|
||||
- Sucht nach Kunden basierend auf dem Query-Parameter
|
||||
- Parameter: `q` (Suchbegriff)
|
||||
- Returns: JSON-Array mit gefundenen Kunden
|
||||
|
||||
## Frontend-Funktionen
|
||||
|
||||
### Suchfunktion
|
||||
- Live-Suche mit 300ms Debounce
|
||||
- Minimale Suchlänge: 2 Zeichen
|
||||
- Suche wird bei Enter-Taste sofort ausgeführt
|
||||
|
||||
### Link-Generierung
|
||||
- `createPhoneLink()`: Erstellt tel:-Links mit führender 0
|
||||
- `createEmailLink()`: Erstellt mailto:-Links
|
||||
- `createAddressLink()`: Erstellt Google Maps-Links
|
||||
- `createCustomerLink()`: Erstellt KKBefe-System-Links
|
||||
|
||||
## Fehlerbehandlung
|
||||
|
||||
- Logging für Backend-Fehler
|
||||
- Benutzerfreundliche Fehlermeldungen im Frontend
|
||||
- Graceful Degradation bei fehlenden Daten
|
||||
|
||||
## Entwicklung
|
||||
|
||||
### Debug-Modus
|
||||
Die Anwendung läuft standardmäßig im Debug-Modus:
|
||||
```bash
|
||||
python app.py
|
||||
```
|
||||
|
||||
### Logging
|
||||
- Backend-Logs werden mit Python's logging-Modul erstellt
|
||||
- Log-Level: DEBUG
|
||||
- Logs werden in der Konsole ausgegeben
|
||||
|
||||
## Wartung
|
||||
|
||||
### Container-Verwaltung
|
||||
```bash
|
||||
# Container stoppen
|
||||
docker stop medi-customers
|
||||
|
||||
# Container starten
|
||||
docker start medi-customers
|
||||
|
||||
# Container-Logs anzeigen
|
||||
docker logs medi-customers
|
||||
|
||||
# Container entfernen
|
||||
docker rm medi-customers
|
||||
```
|
||||
|
||||
### Datenaktualisierung
|
||||
1. CSV-Datei aktualisieren
|
||||
2. Container neu bauen und starten:
|
||||
```bash
|
||||
docker stop medi-customers
|
||||
docker build -t medi-customers .
|
||||
docker run -d -p 5000:5000 --name medi-customers medi-customers
|
||||
```
|
||||
|
||||
## Sicherheit
|
||||
|
||||
- Alle externen Links öffnen sich in neuen Tabs
|
||||
- Sicherheitsattribute für externe Links (noopener, noreferrer)
|
||||
- Input-Validierung im Backend
|
||||
- Fehlerbehandlung für ungültige Daten
|
||||
|
||||
## Browser-Kompatibilität
|
||||
|
||||
Die Anwendung wurde getestet mit:
|
||||
- Chrome (neueste Version)
|
||||
- Firefox (neueste Version)
|
||||
- Edge (neueste Version)
|
||||
- Safari (neueste Version)
|
62
app.py
Normal file
62
app.py
Normal file
@@ -0,0 +1,62 @@
|
||||
from flask import Flask, render_template, request, jsonify
|
||||
import pandas as pd
|
||||
import os
|
||||
import logging
|
||||
import numpy as np
|
||||
|
||||
app = Flask(__name__)
|
||||
logging.basicConfig(level=logging.DEBUG)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
def clean_dataframe(df):
|
||||
"""Konvertiert NaN-Werte in None für JSON-Kompatibilität"""
|
||||
return df.replace({np.nan: None})
|
||||
|
||||
# CSV-Datei laden
|
||||
def load_data():
|
||||
try:
|
||||
logger.info("Versuche CSV-Datei zu laden...")
|
||||
if not os.path.exists('spezexpo.csv'):
|
||||
logger.error("CSV-Datei 'spezexpo.csv' nicht gefunden!")
|
||||
return None
|
||||
|
||||
df = pd.read_csv('spezexpo.csv', encoding='utf-8')
|
||||
df = clean_dataframe(df)
|
||||
logger.info(f"CSV-Datei erfolgreich geladen. {len(df)} Einträge gefunden.")
|
||||
return df
|
||||
except Exception as e:
|
||||
logger.error(f"Fehler beim Laden der CSV-Datei: {str(e)}")
|
||||
return None
|
||||
|
||||
@app.route('/')
|
||||
def index():
|
||||
return render_template('index.html')
|
||||
|
||||
@app.route('/search')
|
||||
def search():
|
||||
try:
|
||||
query = request.args.get('q', '').lower()
|
||||
logger.info(f"Suche nach: {query}")
|
||||
|
||||
df = load_data()
|
||||
if df is None:
|
||||
return jsonify({"error": "Datenbank konnte nicht geladen werden"}), 500
|
||||
|
||||
# Suche über verschiedene Felder
|
||||
mask = (
|
||||
df['Vorname'].str.lower().str.contains(query, na=False) |
|
||||
df['Nachname'].str.lower().str.contains(query, na=False) |
|
||||
df['Fachrichtung'].str.lower().str.contains(query, na=False) |
|
||||
df['Ort'].str.lower().str.contains(query, na=False) |
|
||||
df['Nummer'].astype(str).str.contains(query, na=False) # Suche nach Kundennummer
|
||||
)
|
||||
|
||||
results = df[mask].to_dict('records')
|
||||
logger.info(f"{len(results)} Ergebnisse gefunden")
|
||||
return jsonify(results)
|
||||
except Exception as e:
|
||||
logger.error(f"Fehler bei der Suche: {str(e)}")
|
||||
return jsonify({"error": str(e)}), 500
|
||||
|
||||
if __name__ == '__main__':
|
||||
app.run(debug=True)
|
4
requirements.txt
Normal file
4
requirements.txt
Normal file
@@ -0,0 +1,4 @@
|
||||
flask==3.0.2
|
||||
pandas==2.2.1
|
||||
numpy==1.26.4
|
||||
python-dotenv==1.0.1
|
209
templates/index.html
Normal file
209
templates/index.html
Normal file
@@ -0,0 +1,209 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>medisoftware Kundensuche</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;
|
||||
}
|
||||
.search-container {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.result-card {
|
||||
margin-bottom: 1rem;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
.result-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 8px rgba(0,0,0,0.1);
|
||||
}
|
||||
.loading {
|
||||
display: none;
|
||||
text-align: center;
|
||||
margin: 2rem 0;
|
||||
}
|
||||
.phone-link, .email-link, .address-link, .customer-link {
|
||||
text-decoration: none;
|
||||
color: #0d6efd;
|
||||
}
|
||||
.phone-link:hover, .email-link:hover, .address-link:hover, .customer-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.search-icon {
|
||||
position: absolute;
|
||||
right: 10px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
color: #6c757d;
|
||||
}
|
||||
.customer-number {
|
||||
color: #6c757d;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
.footer {
|
||||
flex-shrink: 0;
|
||||
text-align: center;
|
||||
padding: 1rem;
|
||||
background-color: #f8f9fa;
|
||||
border-top: 1px solid #dee2e6;
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="main-content">
|
||||
<div class="container search-container">
|
||||
<h1 class="text-center mb-4">medisoftware Kundensuche</h1>
|
||||
|
||||
<div class="input-group mb-4 position-relative">
|
||||
<input type="text" id="searchInput" class="form-control form-control-lg"
|
||||
placeholder="Suchen Sie nach Name, Fachrichtung, Ort oder Kundennummer...">
|
||||
<span class="search-icon">🔍</span>
|
||||
</div>
|
||||
|
||||
<div id="loading" class="loading">
|
||||
<div class="spinner-border text-primary" role="status">
|
||||
<span class="visually-hidden">Laden...</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="results" class="mt-4">
|
||||
<!-- Hier werden die Suchergebnisse angezeigt -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer class="footer">
|
||||
<p class="mb-0">(c) 2025 <a href="https://medisoftware.de" target="_blank" rel="noopener noreferrer" class="text-decoration-none">medisoftware</a></p>
|
||||
</footer>
|
||||
|
||||
<script>
|
||||
let searchTimeout;
|
||||
|
||||
function createPhoneLink(phone) {
|
||||
if (!phone) return 'N/A';
|
||||
// Entferne alle nicht-numerischen Zeichen für den tel: Link
|
||||
const cleaned = phone.replace(/\D/g, '');
|
||||
// Füge immer eine führende 0 hinzu
|
||||
const telLink = '0' + cleaned;
|
||||
// Zeige die ursprüngliche Nummer an
|
||||
return `<a href="tel:${telLink}" class="phone-link">${phone}</a>`;
|
||||
}
|
||||
|
||||
function createEmailLink(email) {
|
||||
if (!email) return 'N/A';
|
||||
return `<a href="mailto:${email}" class="email-link">${email}</a>`;
|
||||
}
|
||||
|
||||
function createAddressLink(street, plz, city) {
|
||||
if (!street || !plz || !city) return 'N/A';
|
||||
const address = `${street}, ${plz} ${city}`;
|
||||
const searchQuery = encodeURIComponent(address);
|
||||
return `<a href="https://www.google.com/maps/search/?api=1&query=${searchQuery}"
|
||||
class="address-link" target="_blank" rel="noopener noreferrer">
|
||||
${address}
|
||||
<span class="ms-1">🗺️</span>
|
||||
</a>`;
|
||||
}
|
||||
|
||||
function createCustomerLink(customerNumber) {
|
||||
if (!customerNumber) return 'N/A';
|
||||
return `<a href="medisw:openkkbefe/P${customerNumber}?NetGrp=4"
|
||||
class="customer-link" target="_blank" rel="noopener noreferrer">
|
||||
${customerNumber}
|
||||
</a>`;
|
||||
}
|
||||
|
||||
function searchCustomers() {
|
||||
const query = document.getElementById('searchInput').value;
|
||||
if (query.length < 2) {
|
||||
document.getElementById('results').innerHTML = '';
|
||||
return;
|
||||
}
|
||||
|
||||
// Lade-Animation anzeigen
|
||||
document.getElementById('loading').style.display = 'block';
|
||||
document.getElementById('results').innerHTML = '';
|
||||
|
||||
fetch(`/search?q=${encodeURIComponent(query)}`)
|
||||
.then(response => {
|
||||
if (!response.ok) {
|
||||
return response.json().then(data => {
|
||||
throw new Error(data.error || 'Ein Fehler ist aufgetreten');
|
||||
});
|
||||
}
|
||||
return response.json();
|
||||
})
|
||||
.then(data => {
|
||||
const resultsDiv = document.getElementById('results');
|
||||
resultsDiv.innerHTML = '';
|
||||
|
||||
if (data.length === 0) {
|
||||
resultsDiv.innerHTML = '<div class="alert alert-info">Keine Ergebnisse gefunden.</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
data.forEach(customer => {
|
||||
const card = document.createElement('div');
|
||||
card.className = 'card result-card';
|
||||
card.innerHTML = `
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">
|
||||
${customer.Vorname} ${customer.Nachname}
|
||||
<span class="customer-number ms-2">(Kunde: ${createCustomerLink(customer.Nummer)})</span>
|
||||
</h5>
|
||||
<p class="card-text">
|
||||
<strong>Fachrichtung:</strong> ${customer.Fachrichtung || 'N/A'}<br>
|
||||
<strong>Adresse:</strong> ${createAddressLink(customer.Strasse, customer.PLZ, customer.Ort)}<br>
|
||||
<strong>Telefon:</strong> ${createPhoneLink(customer.Tel)}<br>
|
||||
<strong>E-Mail:</strong> ${createEmailLink(customer.mail)}
|
||||
</p>
|
||||
</div>
|
||||
`;
|
||||
resultsDiv.appendChild(card);
|
||||
});
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Fehler:', error);
|
||||
document.getElementById('results').innerHTML =
|
||||
`<div class="alert alert-danger">${error.message}</div>`;
|
||||
})
|
||||
.finally(() => {
|
||||
// Lade-Animation ausblenden
|
||||
document.getElementById('loading').style.display = 'none';
|
||||
});
|
||||
}
|
||||
|
||||
// Event-Listener für die Live-Suche
|
||||
document.getElementById('searchInput').addEventListener('input', function(e) {
|
||||
// Clear previous timeout
|
||||
clearTimeout(searchTimeout);
|
||||
|
||||
// Set new timeout
|
||||
searchTimeout = setTimeout(() => {
|
||||
searchCustomers();
|
||||
}, 300); // 300ms Verzögerung
|
||||
});
|
||||
|
||||
// Suche bei Enter-Taste
|
||||
document.getElementById('searchInput').addEventListener('keypress', function(e) {
|
||||
if (e.key === 'Enter') {
|
||||
clearTimeout(searchTimeout);
|
||||
searchCustomers();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
Reference in New Issue
Block a user