Suchfunktion bei Key anlegen
Dieser Commit ist enthalten in:
@@ -44,7 +44,8 @@
|
||||
"Bash(docker-compose restart:*)",
|
||||
"Bash(find:*)",
|
||||
"Bash(docker network:*)",
|
||||
"Bash(curl:*)"
|
||||
"Bash(curl:*)",
|
||||
"Bash(find:*)"
|
||||
],
|
||||
"deny": []
|
||||
}
|
||||
|
||||
35
JOURNAL.md
35
JOURNAL.md
@@ -1029,3 +1029,38 @@ Die Session-Daten werden erst gefüllt, wenn der License Server API implementier
|
||||
- ✅ Copy-to-Clipboard Funktionalität
|
||||
- ✅ Audit-Log-Integration
|
||||
- ✅ Navigation aktualisiert
|
||||
|
||||
## 2025-01-06: Implementierung Searchable Dropdown für Kundenauswahl
|
||||
|
||||
**Problem:**
|
||||
- Bei der Lizenzerstellung wurde immer ein neuer Kunde angelegt
|
||||
- Keine Möglichkeit, Lizenzen für bestehende Kunden zu erstellen
|
||||
- Bei vielen Kunden wäre ein normales Dropdown unübersichtlich
|
||||
|
||||
**Lösung:**
|
||||
1. **Select2 Library** für searchable Dropdown integriert
|
||||
2. **API-Endpoint `/api/customers`** für die Kundensuche erstellt
|
||||
3. **Frontend angepasst:**
|
||||
- Searchable Dropdown mit Live-Suche
|
||||
- Option "Neuer Kunde" im Dropdown
|
||||
- Eingabefelder erscheinen nur bei "Neuer Kunde"
|
||||
4. **Backend-Logik verbessert:**
|
||||
- Prüfung ob neuer oder bestehender Kunde
|
||||
- E-Mail-Duplikatsprüfung vor Kundenerstellung
|
||||
- Separate Audit-Logs für Kunde und Lizenz
|
||||
5. **Datenbank:**
|
||||
- UNIQUE Constraint auf E-Mail-Spalte hinzugefügt
|
||||
|
||||
**Änderungen:**
|
||||
- `app.py`: Neuer API-Endpoint `/api/customers`, angepasste Routes `/create` und `/batch`
|
||||
- `base.html`: Select2 CSS und JS eingebunden
|
||||
- `index.html`: Kundenauswahl mit Select2 implementiert
|
||||
- `batch_form.html`: Kundenauswahl mit Select2 implementiert
|
||||
- `init.sql`: UNIQUE Constraint für E-Mail
|
||||
|
||||
**Status:**
|
||||
- ✅ API-Endpoint funktioniert mit Pagination
|
||||
- ✅ Select2 Dropdown mit Suchfunktion
|
||||
- ✅ Neue/bestehende Kunden können ausgewählt werden
|
||||
- ✅ E-Mail-Duplikate werden verhindert
|
||||
- ✅ Sowohl Einzellizenz als auch Batch unterstützt
|
||||
@@ -2,4 +2,3 @@
|
||||
# https://curl.se/docs/http-cookies.html
|
||||
# This file was generated by libcurl! Edit at your own risk.
|
||||
|
||||
#HttpOnly_localhost FALSE / FALSE 1749330711 admin_session kL1GAl-qrgTLBwfFpeQLngAfFne2ehrnKbWE3M3awqE
|
||||
|
||||
@@ -764,6 +764,86 @@ def api_generate_key():
|
||||
'error': 'Fehler bei der Key-Generierung'
|
||||
}), 500
|
||||
|
||||
@app.route("/api/customers", methods=['GET'])
|
||||
@login_required
|
||||
def api_customers():
|
||||
"""API Endpoint für die Kundensuche mit Select2"""
|
||||
try:
|
||||
# Suchparameter
|
||||
search = request.args.get('q', '').strip()
|
||||
page = request.args.get('page', 1, type=int)
|
||||
per_page = 20
|
||||
|
||||
conn = get_connection()
|
||||
cur = conn.cursor()
|
||||
|
||||
# SQL Query mit optionaler Suche
|
||||
if search:
|
||||
cur.execute("""
|
||||
SELECT c.id, c.name, c.email,
|
||||
COUNT(l.id) as license_count
|
||||
FROM customers c
|
||||
LEFT JOIN licenses l ON c.id = l.customer_id
|
||||
WHERE LOWER(c.name) LIKE LOWER(%s)
|
||||
OR LOWER(c.email) LIKE LOWER(%s)
|
||||
GROUP BY c.id, c.name, c.email
|
||||
ORDER BY c.name
|
||||
LIMIT %s OFFSET %s
|
||||
""", (f'%{search}%', f'%{search}%', per_page, (page - 1) * per_page))
|
||||
else:
|
||||
cur.execute("""
|
||||
SELECT c.id, c.name, c.email,
|
||||
COUNT(l.id) as license_count
|
||||
FROM customers c
|
||||
LEFT JOIN licenses l ON c.id = l.customer_id
|
||||
GROUP BY c.id, c.name, c.email
|
||||
ORDER BY c.name
|
||||
LIMIT %s OFFSET %s
|
||||
""", (per_page, (page - 1) * per_page))
|
||||
|
||||
customers = cur.fetchall()
|
||||
|
||||
# Format für Select2
|
||||
results = []
|
||||
for customer in customers:
|
||||
results.append({
|
||||
'id': customer[0],
|
||||
'text': f"{customer[1]} - {customer[2]} ({customer[3]} Lizenzen)",
|
||||
'name': customer[1],
|
||||
'email': customer[2],
|
||||
'license_count': customer[3]
|
||||
})
|
||||
|
||||
# Gesamtanzahl für Pagination
|
||||
if search:
|
||||
cur.execute("""
|
||||
SELECT COUNT(*) FROM customers
|
||||
WHERE LOWER(name) LIKE LOWER(%s)
|
||||
OR LOWER(email) LIKE LOWER(%s)
|
||||
""", (f'%{search}%', f'%{search}%'))
|
||||
else:
|
||||
cur.execute("SELECT COUNT(*) FROM customers")
|
||||
|
||||
total_count = cur.fetchone()[0]
|
||||
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
# Select2 Response Format
|
||||
return jsonify({
|
||||
'results': results,
|
||||
'pagination': {
|
||||
'more': (page * per_page) < total_count
|
||||
}
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"Fehler bei Kundensuche: {str(e)}")
|
||||
return jsonify({
|
||||
'results': [],
|
||||
'error': 'Fehler bei der Kundensuche'
|
||||
}), 500
|
||||
|
||||
@app.route("/")
|
||||
@login_required
|
||||
def dashboard():
|
||||
@@ -931,8 +1011,7 @@ def dashboard():
|
||||
@login_required
|
||||
def create_license():
|
||||
if request.method == "POST":
|
||||
name = request.form["customer_name"]
|
||||
email = request.form["email"]
|
||||
customer_id = request.form.get("customer_id")
|
||||
license_key = request.form["license_key"].upper() # Immer Großbuchstaben
|
||||
license_type = request.form["license_type"]
|
||||
valid_from = request.form["valid_from"]
|
||||
@@ -946,13 +1025,45 @@ def create_license():
|
||||
conn = get_connection()
|
||||
cur = conn.cursor()
|
||||
|
||||
# Kunde einfügen (falls nicht vorhanden)
|
||||
try:
|
||||
# Prüfe ob neuer Kunde oder bestehender
|
||||
if customer_id == "new":
|
||||
# Neuer Kunde
|
||||
name = request.form.get("customer_name")
|
||||
email = request.form.get("email")
|
||||
|
||||
if not name:
|
||||
flash('Kundenname ist erforderlich!', 'error')
|
||||
return redirect(url_for('create_license'))
|
||||
|
||||
# Prüfe ob E-Mail bereits existiert
|
||||
if email:
|
||||
cur.execute("SELECT id, name FROM customers WHERE LOWER(email) = LOWER(%s)", (email,))
|
||||
existing = cur.fetchone()
|
||||
if existing:
|
||||
flash(f'E-Mail bereits vergeben für Kunde: {existing[1]}', 'error')
|
||||
return redirect(url_for('create_license'))
|
||||
|
||||
# Kunde einfügen
|
||||
cur.execute("""
|
||||
INSERT INTO customers (name, email, created_at)
|
||||
VALUES (%s, %s, NOW())
|
||||
RETURNING id
|
||||
""", (name, email))
|
||||
customer_id = cur.fetchone()[0]
|
||||
customer_info = {'name': name, 'email': email}
|
||||
|
||||
# Audit-Log für neuen Kunden
|
||||
log_audit('CREATE', 'customer', customer_id,
|
||||
new_values={'name': name, 'email': email})
|
||||
else:
|
||||
# Bestehender Kunde - hole Infos für Audit-Log
|
||||
cur.execute("SELECT name, email FROM customers WHERE id = %s", (customer_id,))
|
||||
customer_data = cur.fetchone()
|
||||
if not customer_data:
|
||||
flash('Kunde nicht gefunden!', 'error')
|
||||
return redirect(url_for('create_license'))
|
||||
customer_info = {'name': customer_data[0], 'email': customer_data[1]}
|
||||
|
||||
# Lizenz hinzufügen
|
||||
cur.execute("""
|
||||
@@ -968,12 +1079,20 @@ def create_license():
|
||||
log_audit('CREATE', 'license', license_id,
|
||||
new_values={
|
||||
'license_key': license_key,
|
||||
'customer_name': name,
|
||||
'customer_email': email,
|
||||
'customer_name': customer_info['name'],
|
||||
'customer_email': customer_info['email'],
|
||||
'license_type': license_type,
|
||||
'valid_from': valid_from,
|
||||
'valid_until': valid_until
|
||||
})
|
||||
|
||||
flash(f'Lizenz {license_key} erfolgreich erstellt!', 'success')
|
||||
|
||||
except Exception as e:
|
||||
conn.rollback()
|
||||
logging.error(f"Fehler beim Erstellen der Lizenz: {str(e)}")
|
||||
flash('Fehler beim Erstellen der Lizenz!', 'error')
|
||||
finally:
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
@@ -987,8 +1106,7 @@ def batch_licenses():
|
||||
"""Batch-Generierung mehrerer Lizenzen für einen Kunden"""
|
||||
if request.method == "POST":
|
||||
# Formulardaten
|
||||
name = request.form["customer_name"]
|
||||
email = request.form["email"]
|
||||
customer_id = request.form.get("customer_id")
|
||||
license_type = request.form["license_type"]
|
||||
quantity = int(request.form["quantity"])
|
||||
valid_from = request.form["valid_from"]
|
||||
@@ -1003,15 +1121,45 @@ def batch_licenses():
|
||||
cur = conn.cursor()
|
||||
|
||||
try:
|
||||
# Kunde einfügen (falls nicht vorhanden)
|
||||
# Prüfe ob neuer Kunde oder bestehender
|
||||
if customer_id == "new":
|
||||
# Neuer Kunde
|
||||
name = request.form.get("customer_name")
|
||||
email = request.form.get("email")
|
||||
|
||||
if not name:
|
||||
flash('Kundenname ist erforderlich!', 'error')
|
||||
return redirect(url_for('batch_licenses'))
|
||||
|
||||
# Prüfe ob E-Mail bereits existiert
|
||||
if email:
|
||||
cur.execute("SELECT id, name FROM customers WHERE LOWER(email) = LOWER(%s)", (email,))
|
||||
existing = cur.fetchone()
|
||||
if existing:
|
||||
flash(f'E-Mail bereits vergeben für Kunde: {existing[1]}', 'error')
|
||||
return redirect(url_for('batch_licenses'))
|
||||
|
||||
# Kunde einfügen
|
||||
cur.execute("""
|
||||
INSERT INTO customers (name, email, created_at)
|
||||
VALUES (%s, %s, NOW())
|
||||
ON CONFLICT (name, email) DO UPDATE SET name=EXCLUDED.name
|
||||
RETURNING id
|
||||
""", (name, email))
|
||||
customer_id = cur.fetchone()[0]
|
||||
|
||||
# Audit-Log für neuen Kunden
|
||||
log_audit('CREATE', 'customer', customer_id,
|
||||
new_values={'name': name, 'email': email})
|
||||
else:
|
||||
# Bestehender Kunde - hole Infos
|
||||
cur.execute("SELECT name, email FROM customers WHERE id = %s", (customer_id,))
|
||||
customer_data = cur.fetchone()
|
||||
if not customer_data:
|
||||
flash('Kunde nicht gefunden!', 'error')
|
||||
return redirect(url_for('batch_licenses'))
|
||||
name = customer_data[0]
|
||||
email = customer_data[1]
|
||||
|
||||
# Lizenzen generieren und speichern
|
||||
generated_licenses = []
|
||||
for i in range(quantity):
|
||||
|
||||
@@ -5,7 +5,8 @@ CREATE TABLE IF NOT EXISTS customers (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
email TEXT,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT unique_email UNIQUE (email)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS licenses (
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{% block title %}Admin Panel{% endblock %} - Lizenzverwaltung</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link href="https://cdn.jsdelivr.net/npm/select2@4.1.0-rc.0/dist/css/select2.min.css" rel="stylesheet" />
|
||||
<link href="https://cdn.jsdelivr.net/npm/select2-bootstrap-5-theme@1.3.0/dist/select2-bootstrap-5-theme.min.css" rel="stylesheet" />
|
||||
{% block extra_css %}{% endblock %}
|
||||
<style>
|
||||
#session-timer {
|
||||
@@ -87,6 +89,8 @@
|
||||
</div>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script>
|
||||
<script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/select2@4.1.0-rc.0/dist/js/select2.min.js"></script>
|
||||
|
||||
<script>
|
||||
// Session-Timer Konfiguration
|
||||
|
||||
@@ -33,12 +33,19 @@
|
||||
|
||||
<form method="post" action="/batch" accept-charset="UTF-8">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-6">
|
||||
<div class="col-md-12">
|
||||
<label for="customerSelect" class="form-label">Kunde auswählen</label>
|
||||
<select class="form-select" id="customerSelect" name="customer_id" required>
|
||||
<option value="">🔍 Kunde suchen oder neuen Kunden anlegen...</option>
|
||||
<option value="new">➕ Neuer Kunde</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-6" id="customerNameDiv" style="display: none;">
|
||||
<label for="customerName" class="form-label">Kundenname</label>
|
||||
<input type="text" class="form-control" id="customerName" name="customer_name"
|
||||
placeholder="Firma GmbH" accept-charset="UTF-8" required>
|
||||
placeholder="Firma GmbH" accept-charset="UTF-8">
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="col-md-6" id="emailDiv" style="display: none;">
|
||||
<label for="email" class="form-label">E-Mail</label>
|
||||
<input type="email" class="form-control" id="email" name="email"
|
||||
placeholder="kontakt@firma.de" accept-charset="UTF-8">
|
||||
@@ -113,6 +120,83 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
const oneYearLater = new Date();
|
||||
oneYearLater.setFullYear(oneYearLater.getFullYear() + 1);
|
||||
document.getElementById('validUntil').value = oneYearLater.toISOString().split('T')[0];
|
||||
|
||||
// Initialisiere Select2 für Kundenauswahl
|
||||
$('#customerSelect').select2({
|
||||
theme: 'bootstrap-5',
|
||||
placeholder: '🔍 Kunde suchen oder neuen Kunden anlegen...',
|
||||
allowClear: true,
|
||||
ajax: {
|
||||
url: '/api/customers',
|
||||
dataType: 'json',
|
||||
delay: 250,
|
||||
data: function (params) {
|
||||
return {
|
||||
q: params.term,
|
||||
page: params.page || 1
|
||||
};
|
||||
},
|
||||
processResults: function (data, params) {
|
||||
params.page = params.page || 1;
|
||||
|
||||
// "Neuer Kunde" Option immer oben anzeigen
|
||||
const results = data.results || [];
|
||||
if (params.page === 1) {
|
||||
results.unshift({
|
||||
id: 'new',
|
||||
text: '➕ Neuer Kunde',
|
||||
isNew: true
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
results: results,
|
||||
pagination: data.pagination
|
||||
};
|
||||
},
|
||||
cache: true
|
||||
},
|
||||
minimumInputLength: 0,
|
||||
language: {
|
||||
inputTooShort: function() { return ''; },
|
||||
noResults: function() { return 'Keine Kunden gefunden'; },
|
||||
searching: function() { return 'Suche...'; },
|
||||
loadingMore: function() { return 'Lade weitere Ergebnisse...'; }
|
||||
}
|
||||
});
|
||||
|
||||
// Event Handler für Kundenauswahl
|
||||
$('#customerSelect').on('select2:select', function (e) {
|
||||
const selectedValue = e.params.data.id;
|
||||
const nameDiv = document.getElementById('customerNameDiv');
|
||||
const emailDiv = document.getElementById('emailDiv');
|
||||
const nameInput = document.getElementById('customerName');
|
||||
const emailInput = document.getElementById('email');
|
||||
|
||||
if (selectedValue === 'new') {
|
||||
// Zeige Eingabefelder für neuen Kunden
|
||||
nameDiv.style.display = 'block';
|
||||
emailDiv.style.display = 'block';
|
||||
nameInput.required = true;
|
||||
emailInput.required = true;
|
||||
} else {
|
||||
// Verstecke Eingabefelder bei bestehendem Kunden
|
||||
nameDiv.style.display = 'none';
|
||||
emailDiv.style.display = 'none';
|
||||
nameInput.required = false;
|
||||
emailInput.required = false;
|
||||
nameInput.value = '';
|
||||
emailInput.value = '';
|
||||
}
|
||||
});
|
||||
|
||||
// Clear handler
|
||||
$('#customerSelect').on('select2:clear', function (e) {
|
||||
document.getElementById('customerNameDiv').style.display = 'none';
|
||||
document.getElementById('emailDiv').style.display = 'none';
|
||||
document.getElementById('customerName').required = false;
|
||||
document.getElementById('email').required = false;
|
||||
});
|
||||
});
|
||||
|
||||
// Vorschau-Funktion
|
||||
|
||||
@@ -19,11 +19,18 @@
|
||||
|
||||
<form method="post" action="/create" accept-charset="UTF-8">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-6">
|
||||
<label for="customerName" class="form-label">Kundenname</label>
|
||||
<input type="text" class="form-control" id="customerName" name="customer_name" accept-charset="UTF-8" required>
|
||||
<div class="col-md-12">
|
||||
<label for="customerSelect" class="form-label">Kunde auswählen</label>
|
||||
<select class="form-select" id="customerSelect" name="customer_id" required>
|
||||
<option value="">🔍 Kunde suchen oder neuen Kunden anlegen...</option>
|
||||
<option value="new">➕ Neuer Kunde</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="col-md-6" id="customerNameDiv" style="display: none;">
|
||||
<label for="customerName" class="form-label">Kundenname</label>
|
||||
<input type="text" class="form-control" id="customerName" name="customer_name" accept-charset="UTF-8">
|
||||
</div>
|
||||
<div class="col-md-6" id="emailDiv" style="display: none;">
|
||||
<label for="email" class="form-label">E-Mail</label>
|
||||
<input type="email" class="form-control" id="email" name="email" accept-charset="UTF-8">
|
||||
</div>
|
||||
@@ -155,6 +162,83 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
const oneYearLater = new Date();
|
||||
oneYearLater.setFullYear(oneYearLater.getFullYear() + 1);
|
||||
document.getElementById('validUntil').value = oneYearLater.toISOString().split('T')[0];
|
||||
|
||||
// Initialisiere Select2 für Kundenauswahl
|
||||
$('#customerSelect').select2({
|
||||
theme: 'bootstrap-5',
|
||||
placeholder: '🔍 Kunde suchen oder neuen Kunden anlegen...',
|
||||
allowClear: true,
|
||||
ajax: {
|
||||
url: '/api/customers',
|
||||
dataType: 'json',
|
||||
delay: 250,
|
||||
data: function (params) {
|
||||
return {
|
||||
q: params.term,
|
||||
page: params.page || 1
|
||||
};
|
||||
},
|
||||
processResults: function (data, params) {
|
||||
params.page = params.page || 1;
|
||||
|
||||
// "Neuer Kunde" Option immer oben anzeigen
|
||||
const results = data.results || [];
|
||||
if (params.page === 1) {
|
||||
results.unshift({
|
||||
id: 'new',
|
||||
text: '➕ Neuer Kunde',
|
||||
isNew: true
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
results: results,
|
||||
pagination: data.pagination
|
||||
};
|
||||
},
|
||||
cache: true
|
||||
},
|
||||
minimumInputLength: 0,
|
||||
language: {
|
||||
inputTooShort: function() { return ''; },
|
||||
noResults: function() { return 'Keine Kunden gefunden'; },
|
||||
searching: function() { return 'Suche...'; },
|
||||
loadingMore: function() { return 'Lade weitere Ergebnisse...'; }
|
||||
}
|
||||
});
|
||||
|
||||
// Event Handler für Kundenauswahl
|
||||
$('#customerSelect').on('select2:select', function (e) {
|
||||
const selectedValue = e.params.data.id;
|
||||
const nameDiv = document.getElementById('customerNameDiv');
|
||||
const emailDiv = document.getElementById('emailDiv');
|
||||
const nameInput = document.getElementById('customerName');
|
||||
const emailInput = document.getElementById('email');
|
||||
|
||||
if (selectedValue === 'new') {
|
||||
// Zeige Eingabefelder für neuen Kunden
|
||||
nameDiv.style.display = 'block';
|
||||
emailDiv.style.display = 'block';
|
||||
nameInput.required = true;
|
||||
emailInput.required = true;
|
||||
} else {
|
||||
// Verstecke Eingabefelder bei bestehendem Kunden
|
||||
nameDiv.style.display = 'none';
|
||||
emailDiv.style.display = 'none';
|
||||
nameInput.required = false;
|
||||
emailInput.required = false;
|
||||
nameInput.value = '';
|
||||
emailInput.value = '';
|
||||
}
|
||||
});
|
||||
|
||||
// Clear handler
|
||||
$('#customerSelect').on('select2:clear', function (e) {
|
||||
document.getElementById('customerNameDiv').style.display = 'none';
|
||||
document.getElementById('emailDiv').style.display = 'none';
|
||||
document.getElementById('customerName').required = false;
|
||||
document.getElementById('email').required = false;
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
In neuem Issue referenzieren
Einen Benutzer sperren