Compare commits
23 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b16ad56b98 | |||
| d2c21bc519 | |||
| 4461644cb6 | |||
| 73db5c5fe0 | |||
| d609775eb6 | |||
| c1a6a145dc | |||
| 1091854797 | |||
| a5954ce8cb | |||
| 06318d0d2d | |||
| 5ed3c906dd | |||
| 8ce74d3e62 | |||
| ee25c46ff3 | |||
| f7fe2fcc11 | |||
| 4449007a15 | |||
| 6770d42f99 | |||
| 333f9fc89a | |||
| a286236834 | |||
| 84cc8de833 | |||
| 4b3c7e646f | |||
| f9b013508c | |||
| 2a6c40eb5a | |||
| 432d6da798 | |||
| 2cdf0d2169 |
+82
-67
@@ -228,7 +228,6 @@ def rollover_student_card_classes(dry_run=False, *, max_class=None, graduate_lab
|
|||||||
client.close()
|
client.close()
|
||||||
|
|
||||||
|
|
||||||
# Admin route to trigger rollover manually
|
|
||||||
@app.route('/admin/trigger_school_year_rollover', methods=['POST'])
|
@app.route('/admin/trigger_school_year_rollover', methods=['POST'])
|
||||||
def admin_trigger_school_year_rollover():
|
def admin_trigger_school_year_rollover():
|
||||||
if 'username' not in session:
|
if 'username' not in session:
|
||||||
@@ -543,7 +542,8 @@ def _enforce_module_access():
|
|||||||
msg = {
|
msg = {
|
||||||
'library': "Bibliotheks-Modul ist deaktiviert.",
|
'library': "Bibliotheks-Modul ist deaktiviert.",
|
||||||
'inventory': "Inventar-Modul ist deaktiviert.",
|
'inventory': "Inventar-Modul ist deaktiviert.",
|
||||||
'student_cards': "Schülerausweis-Modul ist deaktiviert."
|
'student_cards': "Schülerausweis-Modul ist deaktiviert.",
|
||||||
|
'terminplaner': "Termin-Modul ist deaktiviert."
|
||||||
}.get(name, f"{name.capitalize()}-Modul ist deaktiviert.")
|
}.get(name, f"{name.capitalize()}-Modul ist deaktiviert.")
|
||||||
|
|
||||||
if request.path.startswith('/api/') or request.is_json:
|
if request.path.startswith('/api/') or request.is_json:
|
||||||
@@ -555,6 +555,8 @@ def _enforce_module_access():
|
|||||||
return redirect(url_for('home_admin'))
|
return redirect(url_for('home_admin'))
|
||||||
elif name != 'library' and cfg.MODULES.is_enabled('library'):
|
elif name != 'library' and cfg.MODULES.is_enabled('library'):
|
||||||
return redirect('/library')
|
return redirect('/library')
|
||||||
|
elif name != 'terminplaner' and cfg.MODULES.is_enabled('terminplaner'):
|
||||||
|
return redirect('/terminplaner')
|
||||||
|
|
||||||
return redirect(url_for('my_borrowed_items'))
|
return redirect(url_for('my_borrowed_items'))
|
||||||
|
|
||||||
@@ -707,7 +709,7 @@ def _action_access_allowed(permissions, action_key):
|
|||||||
def _permission_denied_fallback_endpoint(permissions, current_endpoint=None):
|
def _permission_denied_fallback_endpoint(permissions, current_endpoint=None):
|
||||||
username = session.get('username')
|
username = session.get('username')
|
||||||
|
|
||||||
for candidate in ('home_admin', 'my_borrowed_items', 'tutorial_page', 'notifications_view', 'impressum'):
|
for candidate in ('home_admin', 'library_view', 'terminplaner', 'my_borrowed_items', 'tutorial_page', 'notifications_view', 'impressum'):
|
||||||
if current_endpoint and candidate == current_endpoint:
|
if current_endpoint and candidate == current_endpoint:
|
||||||
continue
|
continue
|
||||||
if _page_access_allowed(permissions, candidate):
|
if _page_access_allowed(permissions, candidate):
|
||||||
@@ -3038,42 +3040,6 @@ def optimized_image(filename):
|
|||||||
app.logger.error(f"Error serving optimized image {filename}: {str(e)}")
|
app.logger.error(f"Error serving optimized image {filename}: {str(e)}")
|
||||||
return Response("Optimized image not found", status=404)
|
return Response("Optimized image not found", status=404)
|
||||||
|
|
||||||
|
|
||||||
# @app.route('/QRCodes/<filename>')
|
|
||||||
# def qrcode_file(filename):
|
|
||||||
# """
|
|
||||||
# Serve QR code files from the QRCodes directory.
|
|
||||||
#
|
|
||||||
# Args:
|
|
||||||
# filename (str): Name of the QR code file to serve
|
|
||||||
#
|
|
||||||
# Returns:
|
|
||||||
# flask.Response: The requested QR code file or placeholder image if not found
|
|
||||||
# """
|
|
||||||
# try:
|
|
||||||
# # Check production path first
|
|
||||||
# prod_path = "/var/Inventarsystem/Web/QRCodes"
|
|
||||||
# dev_path = app.config['QR_CODE_FOLDER']
|
|
||||||
# if os.path.exists(os.path.join(prod_path, filename)):
|
|
||||||
# return send_from_directory(prod_path, filename)
|
|
||||||
# if os.path.exists(os.path.join(dev_path, filename)):
|
|
||||||
# return send_from_directory(dev_path, filename)
|
|
||||||
#
|
|
||||||
# # Use a placeholder image if file not found - first try SVG, then PNG
|
|
||||||
# svg_placeholder_path = os.path.join(app.static_folder, 'img', 'no-image.svg')
|
|
||||||
# png_placeholder_path = os.path.join(app.static_folder, 'img', 'no-image.png')
|
|
||||||
#
|
|
||||||
# if os.path.exists(svg_placeholder_path):
|
|
||||||
# return send_from_directory(app.static_folder, 'img/no-image.svg')
|
|
||||||
# elif os.path.exists(png_placeholder_path):
|
|
||||||
# return send_from_directory(app.static_folder, 'img/no-image.png')
|
|
||||||
# else:
|
|
||||||
# return send_from_directory(app.static_folder, 'favicon.ico')
|
|
||||||
# except Exception as e:
|
|
||||||
# print(f"Error serving QR code {filename}: {str(e)}")
|
|
||||||
# return Response("QR code not found", status=404)
|
|
||||||
|
|
||||||
|
|
||||||
@app.route('/<path:filename>')
|
@app.route('/<path:filename>')
|
||||||
def catch_all_files(filename):
|
def catch_all_files(filename):
|
||||||
"""
|
"""
|
||||||
@@ -3136,32 +3102,6 @@ def test_connection():
|
|||||||
"""
|
"""
|
||||||
return {'status': 'success', 'message': 'Connection successful', 'status_code': 200}
|
return {'status': 'success', 'message': 'Connection successful', 'status_code': 200}
|
||||||
|
|
||||||
""" if sucess in deployment the funktion can be removed
|
|
||||||
@app.route('/user_status')
|
|
||||||
def user_status():
|
|
||||||
|
|
||||||
API endpoint to get the current user's status (username, admin status).
|
|
||||||
Used by JavaScript in templates to personalize the UI.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
JSON: User status information or error if not authenticated
|
|
||||||
|
|
||||||
if 'username' in session:
|
|
||||||
is_admin = us.check_admin(session['username'])
|
|
||||||
return jsonify({
|
|
||||||
'authenticated': True,
|
|
||||||
'username': session['username'],
|
|
||||||
'is_admin': is_admin
|
|
||||||
})
|
|
||||||
else:
|
|
||||||
return jsonify({
|
|
||||||
'authenticated': False,
|
|
||||||
'error': 'Not logged in'
|
|
||||||
}), 401
|
|
||||||
"""
|
|
||||||
|
|
||||||
|
|
||||||
##################################################### changes to be made to account for the new account permison managment system ##############################
|
|
||||||
|
|
||||||
@app.route('/')
|
@app.route('/')
|
||||||
def home():
|
def home():
|
||||||
@@ -3203,6 +3143,8 @@ def home_admin():
|
|||||||
flash('Ihnen ist es nicht gestattet auf dieser Internetanwendung, die eben besuchte Adrrese zu nutzen, versuchen sie es erneut nach dem sie sich mit einem berechtigten Nutzer angemeldet haben!', 'error')
|
flash('Ihnen ist es nicht gestattet auf dieser Internetanwendung, die eben besuchte Adrrese zu nutzen, versuchen sie es erneut nach dem sie sich mit einem berechtigten Nutzer angemeldet haben!', 'error')
|
||||||
return redirect(url_for('library_view'))
|
return redirect(url_for('library_view'))
|
||||||
|
|
||||||
|
filter_names = it.get_filter_names()
|
||||||
|
|
||||||
return render_template(
|
return render_template(
|
||||||
'main_admin.html',
|
'main_admin.html',
|
||||||
username=session['username'],
|
username=session['username'],
|
||||||
@@ -3212,6 +3154,7 @@ def home_admin():
|
|||||||
student_default_borrow_days=cfg.STUDENT_DEFAULT_BORROW_DAYS,
|
student_default_borrow_days=cfg.STUDENT_DEFAULT_BORROW_DAYS,
|
||||||
student_max_borrow_days=cfg.STUDENT_MAX_BORROW_DAYS,
|
student_max_borrow_days=cfg.STUDENT_MAX_BORROW_DAYS,
|
||||||
school_info=_get_school_info_for_export(),
|
school_info=_get_school_info_for_export(),
|
||||||
|
filter_names=filter_names,
|
||||||
open_item=request.args.get('open_item')
|
open_item=request.args.get('open_item')
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -4707,12 +4650,15 @@ def upload_admin():
|
|||||||
if not _action_access_allowed(permissions, 'can_insert'):
|
if not _action_access_allowed(permissions, 'can_insert'):
|
||||||
flash('Ihnen ist es nicht gestattet auf dieser Internetanwendung, die eben besuchte Adrrese zu nutzen, versuchen sie es erneut nach dem sie sich mit einem berechtigten Nutzer angemeldet haben!', 'error')
|
flash('Ihnen ist es nicht gestattet auf dieser Internetanwendung, die eben besuchte Adrrese zu nutzen, versuchen sie es erneut nach dem sie sich mit einem berechtigten Nutzer angemeldet haben!', 'error')
|
||||||
return redirect(url_for('login'))
|
return redirect(url_for('login'))
|
||||||
|
|
||||||
|
filter_names = it.get_filter_names()
|
||||||
|
|
||||||
return render_template(
|
return render_template(
|
||||||
'upload_admin.html',
|
'upload_admin.html',
|
||||||
username=session['username'],
|
username=session['username'],
|
||||||
library_module_enabled=cfg.MODULES.is_enabled('library'),
|
library_module_enabled=cfg.MODULES.is_enabled('library'),
|
||||||
student_cards_module_enabled=cfg.MODULES.is_enabled('student_cards'),
|
student_cards_module_enabled=cfg.MODULES.is_enabled('student_cards'),
|
||||||
|
filter_names=filter_names,
|
||||||
show_library_features=False,
|
show_library_features=False,
|
||||||
upload_mode='item',
|
upload_mode='item',
|
||||||
page_title='Artikel hochladen',
|
page_title='Artikel hochladen',
|
||||||
@@ -7005,12 +6951,15 @@ def item_edit(id):
|
|||||||
|
|
||||||
current_item['IndividualCodes'] = '\n'.join(individual_codes)
|
current_item['IndividualCodes'] = '\n'.join(individual_codes)
|
||||||
|
|
||||||
|
filter_names = it.get_filter_names()
|
||||||
|
|
||||||
return render_template(
|
return render_template(
|
||||||
'edit_library.html',
|
'edit_library.html',
|
||||||
username=session['username'],
|
username=session['username'],
|
||||||
item=current_item,
|
item=current_item,
|
||||||
show_library_features=show_library_features,
|
show_library_features=show_library_features,
|
||||||
library_module_enabled=library_module_active,
|
library_module_enabled=library_module_active,
|
||||||
|
filter_names=filter_names,
|
||||||
page_title=f"Bearbeiten: {current_item.get('Name', '')}"
|
page_title=f"Bearbeiten: {current_item.get('Name', '')}"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -8628,6 +8577,72 @@ def register_csv():
|
|||||||
mimetype='application/pdf'
|
mimetype='application/pdf'
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@app.route('/export_users_csv', methods=['POST'])
|
||||||
|
def export_users_csv():
|
||||||
|
"""
|
||||||
|
Exports a filtered list of users to a CSV file.
|
||||||
|
Receives a JSON array of usernames from the frontend.
|
||||||
|
"""
|
||||||
|
if 'username' not in session:
|
||||||
|
flash('Ihnen ist es nicht gestattet, diese Aktion auszuführen.', 'error')
|
||||||
|
return redirect(url_for('login'))
|
||||||
|
|
||||||
|
usernames_json = request.form.get('usernames')
|
||||||
|
if not usernames_json:
|
||||||
|
flash('Keine Benutzer zum Exportieren ausgewählt.', 'error')
|
||||||
|
return redirect(url_for('user_del'))
|
||||||
|
|
||||||
|
try:
|
||||||
|
target_usernames = json.loads(usernames_json)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
flash('Ungültige Daten beim Export übermittelt.', 'error')
|
||||||
|
return redirect(url_for('user_del'))
|
||||||
|
|
||||||
|
# Fetch all users
|
||||||
|
all_users = us.get_all_users()
|
||||||
|
|
||||||
|
# Create CSV in memory
|
||||||
|
si = io.StringIO()
|
||||||
|
# Delimiter set to semicolon (standard for Excel in German locales)
|
||||||
|
cw = csv.writer(si, delimiter=';')
|
||||||
|
|
||||||
|
# Write headers
|
||||||
|
cw.writerow(['Benutzername', 'Vorname', 'Nachname', 'Administrator', 'Rechte-Preset'])
|
||||||
|
|
||||||
|
for user in all_users:
|
||||||
|
encrypted_username = user.get('Username')
|
||||||
|
if not encrypted_username:
|
||||||
|
continue
|
||||||
|
|
||||||
|
uname = decrypt_text(encrypted_username)
|
||||||
|
|
||||||
|
# Only process users that were present in the frontend's filtered view
|
||||||
|
if uname in target_usernames:
|
||||||
|
try:
|
||||||
|
permissions_payload = us.get_effective_permissions(uname)
|
||||||
|
preset = permissions_payload.get('preset', 'standard_user')
|
||||||
|
except Exception:
|
||||||
|
preset = 'standard_user'
|
||||||
|
|
||||||
|
try:
|
||||||
|
name = us.get_name(uname) or ""
|
||||||
|
last_name = us.get_last_name(uname) or ""
|
||||||
|
except Exception:
|
||||||
|
name = ""
|
||||||
|
last_name = ""
|
||||||
|
|
||||||
|
admin_status = "Ja" if user.get('Admin', False) else "Nein"
|
||||||
|
|
||||||
|
cw.writerow([uname, name, last_name, admin_status, preset])
|
||||||
|
|
||||||
|
# Prefix with BOM (Byte Order Mark) to ensure Excel reads UTF-8 (Umlauts) correctly
|
||||||
|
output = '\ufeff' + si.getvalue()
|
||||||
|
|
||||||
|
return Response(
|
||||||
|
output,
|
||||||
|
mimetype="text/csv",
|
||||||
|
headers={"Content-disposition": "attachment; filename=benutzer_export.csv"}
|
||||||
|
)
|
||||||
|
|
||||||
@app.route('/user_del', methods=['GET'])
|
@app.route('/user_del', methods=['GET'])
|
||||||
def user_del():
|
def user_del():
|
||||||
@@ -13151,7 +13166,7 @@ def get_optimal_image_quality(img, target_size_kb=80):
|
|||||||
|
|
||||||
@app.route('/health')
|
@app.route('/health')
|
||||||
def health_check():
|
def health_check():
|
||||||
return 'OK', 200
|
return jsonify({"status": "healthy"}), 200
|
||||||
|
|
||||||
|
|
||||||
@app.route('/api/push/subscribe', methods=['POST'])
|
@app.route('/api/push/subscribe', methods=['POST'])
|
||||||
|
|||||||
@@ -911,9 +911,9 @@ def get_filter_names():
|
|||||||
if names_doc and 'names' in names_doc:
|
if names_doc and 'names' in names_doc:
|
||||||
return names_doc['names']
|
return names_doc['names']
|
||||||
return {
|
return {
|
||||||
'1': 'Fach/Kategorie',
|
'1': 'Jahrgangsstufe',
|
||||||
'2': 'System/Bereich',
|
'2': 'Fachgebiet',
|
||||||
'3': 'Typ/Art'
|
'3': 'Schlagwort'
|
||||||
}
|
}
|
||||||
|
|
||||||
def set_filter_name(filter_num, name):
|
def set_filter_name(filter_num, name):
|
||||||
|
|||||||
@@ -78,15 +78,15 @@
|
|||||||
<div class="form-row">
|
<div class="form-row">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="filter_name_1">Name Filter 1</label>
|
<label for="filter_name_1">Name Filter 1</label>
|
||||||
<input type="text" id="filter_name_1" name="filter_name_1" value="{{ filter_names.get('1', 'Fach/Kategorie') }}" placeholder="z. B. Fach/Kategorie">
|
<input type="text" id="filter_name_1" name="filter_name_1" value="{{ filter_names.get('1', 'Klassenstufe') }}" placeholder="z. B. Klassenstufe">
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="filter_name_2">Name Filter 2</label>
|
<label for="filter_name_2">Name Filter 2</label>
|
||||||
<input type="text" id="filter_name_2" name="filter_name_2" value="{{ filter_names.get('2', 'System/Bereich') }}" placeholder="z. B. System/Bereich">
|
<input type="text" id="filter_name_2" name="filter_name_2" value="{{ filter_names.get('2', 'Fach') }}" placeholder="z. B. Fach">
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="filter_name_3">Name Filter 3</label>
|
<label for="filter_name_3">Name Filter 3</label>
|
||||||
<input type="text" id="filter_name_3" name="filter_name_3" value="{{ filter_names.get('3', 'Typ/Art') }}" placeholder="z. B. Typ/Art">
|
<input type="text" id="filter_name_3" name="filter_name_3" value="{{ filter_names.get('3', 'Schlagwort') }}" placeholder="z. B. Schlagwort">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -120,9 +120,64 @@
|
|||||||
<p>Diese Angaben erscheinen künftig im amtlichen Audit-PDF und in anderen Behördenberichten.</p>
|
<p>Diese Angaben erscheinen künftig im amtlichen Audit-PDF und in anderen Behördenberichten.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Danger Zone: Schuljahreswechsel -->
|
||||||
|
<div class="card danger-zone" style="grid-column: 1 / -1; border-color: #fca5a5;">
|
||||||
|
<div class="card-header" style="background: #fef2f2; border-bottom: 1px solid #fca5a5;">
|
||||||
|
<h2 style="color: #991b1b;">Erweiterte Aktionen</h2>
|
||||||
|
</div>
|
||||||
|
<div class="card-body" style="display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 15px;">
|
||||||
|
<div>
|
||||||
|
<strong style="color: #7f1d1d; display: block; font-size: 1.05rem; margin-bottom: 4px;">Schuljahreswechsel durchführen</strong>
|
||||||
|
<span style="color: #991b1b; font-size: 0.9rem;">Diese Aktion bereitet das System auf das neue Schuljahr vor (z. B. Hochstufen von Klassen).</span>
|
||||||
|
</div>
|
||||||
|
<button type="button"
|
||||||
|
id="btn-rollover"
|
||||||
|
class="btn btn-danger"
|
||||||
|
onclick="triggerSchoolYearRollover()">
|
||||||
|
Schuljahreswechsel auslösen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
async function triggerSchoolYearRollover() {
|
||||||
|
if (!confirm('Möchtest du den Schuljahreswechsel wirklich durchführen? Dies kann nicht rückgängig gemacht werden!')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const btn = document.getElementById('btn-rollover');
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.textContent = 'Wird ausgeführt...';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/admin/trigger_school_year_rollover', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (response.ok && data.ok) {
|
||||||
|
alert('Schuljahreswechsel erfolgreich durchgeführt!\n\nZusammenfassung:\n' + JSON.stringify(data.summary, null, 2));
|
||||||
|
location.reload();
|
||||||
|
} else {
|
||||||
|
alert('Fehler: ' + (data.message || 'Schuljahreswechsel konnte nicht durchgeführt werden.'));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Rollover error:', error);
|
||||||
|
alert('Netzwerk- oder Serverfehler beim Ausführen des Schuljahreswechsels.');
|
||||||
|
} finally {
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.textContent = 'Schuljahreswechsel auslösen';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.container {
|
.container {
|
||||||
max-width: 1100px;
|
max-width: 1100px;
|
||||||
@@ -245,12 +300,17 @@
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.btn:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
.btn-primary {
|
.btn-primary {
|
||||||
background: #2563eb;
|
background: #2563eb;
|
||||||
color: #fff;
|
color: #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-primary:hover {
|
.btn-primary:hover:not(:disabled) {
|
||||||
background: #1d4ed8;
|
background: #1d4ed8;
|
||||||
color: #fff;
|
color: #fff;
|
||||||
}
|
}
|
||||||
@@ -261,11 +321,21 @@
|
|||||||
border-color: #d1d5db;
|
border-color: #d1d5db;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-secondary:hover {
|
.btn-secondary:hover:not(:disabled) {
|
||||||
background: #e5e7eb;
|
background: #e5e7eb;
|
||||||
color: #111827;
|
color: #111827;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.btn-danger {
|
||||||
|
background: #ef4444;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger:hover:not(:disabled) {
|
||||||
|
background: #dc2626;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
.preview-card p {
|
.preview-card p {
|
||||||
margin: 0 0 10px;
|
margin: 0 0 10px;
|
||||||
line-height: 1.45;
|
line-height: 1.45;
|
||||||
@@ -283,5 +353,15 @@
|
|||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.danger-zone .card-body {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
.danger-zone .btn {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
@@ -274,7 +274,7 @@
|
|||||||
{% if not show_library_features %}
|
{% if not show_library_features %}
|
||||||
<!-- ================= SYSTEM FILTERS 1-3 (INVENTORY / OTHER ITEMS ONLY) ================= -->
|
<!-- ================= SYSTEM FILTERS 1-3 (INVENTORY / OTHER ITEMS ONLY) ================= -->
|
||||||
<div class="filter-inputs">
|
<div class="filter-inputs">
|
||||||
<h3>Unterrichtsfach (Filter 1):</h3>
|
<h3>{{ filter_names.get('1', 'Jahrgangsstufe') }}</h3>
|
||||||
<div class="multi-filter">
|
<div class="multi-filter">
|
||||||
{% for idx in range(4) %}
|
{% for idx in range(4) %}
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
@@ -286,7 +286,7 @@
|
|||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h3>Jahrgangsstufe (Filter 2):</h3>
|
<h3>{{ filter_names.get('2', 'Fachgebiet') }}</h3>
|
||||||
<div class="multi-filter">
|
<div class="multi-filter">
|
||||||
{% for idx in range(4) %}
|
{% for idx in range(4) %}
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
@@ -298,7 +298,7 @@
|
|||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h3>Schlagwort (Filter 3):</h3>
|
<h3>{{ filter_names.get('3', 'Schlagwort') }}</h3>
|
||||||
<div class="multi-filter">
|
<div class="multi-filter">
|
||||||
{% for idx in range(4) %}
|
{% for idx in range(4) %}
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
|
|||||||
@@ -122,7 +122,7 @@
|
|||||||
letter-spacing: 0.04em;
|
letter-spacing: 0.04em;
|
||||||
color: #64748b;
|
color: #64748b;
|
||||||
background: var(--ui-surface-soft);
|
background: var(--ui-surface-soft);
|
||||||
user-select: none; /* Verhindert Textmarkierung beim Klicken */
|
user-select: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.library-table tr:hover td {
|
.library-table tr:hover td {
|
||||||
@@ -151,7 +151,7 @@
|
|||||||
.badge-open { background: #fee2e2; color: #991b1b; }
|
.badge-open { background: #fee2e2; color: #991b1b; }
|
||||||
.badge-paid { background: #dcfce7; color: #166534; }
|
.badge-paid { background: #dcfce7; color: #166534; }
|
||||||
.badge-damaged { background: #fee2e2; color: #991b1b; }
|
.badge-damaged { background: #fee2e2; color: #991b1b; }
|
||||||
.badge-class { background: #f1f5f9; color: #475569; border: 1px solid #cbd5e1; } /* Neues Badge für Klasse */
|
.badge-class { background: #f1f5f9; color: #475569; border: 1px solid #cbd5e1; }
|
||||||
|
|
||||||
.row-actions {
|
.row-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -168,6 +168,32 @@
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.row-actions .btn-outline-danger {
|
||||||
|
color: #dc2626;
|
||||||
|
background-color: #fef2f2; /* Light red tint so it doesn't blend into the white table */
|
||||||
|
border: 2px solid #dc2626; /* Thicker, clear border */
|
||||||
|
padding: 6px 14px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-weight: bold;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
cursor: pointer;
|
||||||
|
box-shadow: 0 2px 4px rgba(220, 38, 38, 0.1); /* Subtle shadow for depth */
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row-actions .btn-outline-danger:hover {
|
||||||
|
background-color: #dc2626;
|
||||||
|
color: #ffffff;
|
||||||
|
box-shadow: 0 4px 8px rgba(220, 38, 38, 0.25);
|
||||||
|
transform: translateY(-1px); /* Slight lift effect on hover */
|
||||||
|
}
|
||||||
|
|
||||||
|
.row-actions .btn-outline-danger:active {
|
||||||
|
transform: translateY(0);
|
||||||
|
box-shadow: 0 1px 2px rgba(220, 38, 38, 0.15); /* Pressed state */
|
||||||
|
}
|
||||||
|
/* ------------------------------------------------------ */
|
||||||
|
|
||||||
.muted {
|
.muted {
|
||||||
color: #6b7280;
|
color: #6b7280;
|
||||||
font-size: 0.92rem;
|
font-size: 0.92rem;
|
||||||
|
|||||||
@@ -614,6 +614,199 @@
|
|||||||
</div>
|
</div>
|
||||||
<script src="https://cdn.jsdelivr.net/npm/@ericblade/quagga2/dist/quagga.js"></script>
|
<script src="https://cdn.jsdelivr.net/npm/@ericblade/quagga2/dist/quagga.js"></script>
|
||||||
<script>
|
<script>
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// CUSTOM MODAL HELPERS (Replaces native browser alert, confirm & prompt)
|
||||||
|
// =========================================================================
|
||||||
|
function createModalOverlay() {
|
||||||
|
const overlay = document.createElement('div');
|
||||||
|
overlay.style.position = 'fixed';
|
||||||
|
overlay.style.top = '0';
|
||||||
|
overlay.style.left = '0';
|
||||||
|
overlay.style.width = '100vw';
|
||||||
|
overlay.style.height = '100vh';
|
||||||
|
overlay.style.backgroundColor = 'rgba(0, 0, 0, 0.6)';
|
||||||
|
overlay.style.display = 'flex';
|
||||||
|
overlay.style.alignItems = 'center';
|
||||||
|
overlay.style.justifyContent = 'center';
|
||||||
|
overlay.style.zIndex = '999999';
|
||||||
|
return overlay;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createModalBox() {
|
||||||
|
const box = document.createElement('div');
|
||||||
|
box.style.backgroundColor = '#fff';
|
||||||
|
box.style.padding = '24px';
|
||||||
|
box.style.borderRadius = '8px';
|
||||||
|
box.style.boxShadow = '0 10px 25px rgba(0,0,0,0.2)';
|
||||||
|
box.style.fontFamily = 'sans-serif';
|
||||||
|
box.style.minWidth = '320px';
|
||||||
|
box.style.maxWidth = '90%';
|
||||||
|
box.style.textAlign = 'left';
|
||||||
|
box.style.color = '#333';
|
||||||
|
return box;
|
||||||
|
}
|
||||||
|
|
||||||
|
function customAlert(message) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const overlay = createModalOverlay();
|
||||||
|
const box = createModalBox();
|
||||||
|
|
||||||
|
const text = document.createElement('p');
|
||||||
|
text.textContent = message;
|
||||||
|
text.style.marginBottom = '20px';
|
||||||
|
text.style.lineHeight = '1.5';
|
||||||
|
text.style.whiteSpace = 'pre-wrap';
|
||||||
|
|
||||||
|
const btnContainer = document.createElement('div');
|
||||||
|
btnContainer.style.display = 'flex';
|
||||||
|
btnContainer.style.justifyContent = 'flex-end';
|
||||||
|
|
||||||
|
const btn = document.createElement('button');
|
||||||
|
btn.textContent = 'OK';
|
||||||
|
btn.style.padding = '8px 16px';
|
||||||
|
btn.style.cursor = 'pointer';
|
||||||
|
btn.style.border = 'none';
|
||||||
|
btn.style.backgroundColor = '#007bff';
|
||||||
|
btn.style.color = '#fff';
|
||||||
|
btn.style.borderRadius = '4px';
|
||||||
|
|
||||||
|
btn.onclick = () => {
|
||||||
|
document.body.removeChild(overlay);
|
||||||
|
resolve();
|
||||||
|
};
|
||||||
|
|
||||||
|
btnContainer.appendChild(btn);
|
||||||
|
box.appendChild(text);
|
||||||
|
box.appendChild(btnContainer);
|
||||||
|
overlay.appendChild(box);
|
||||||
|
document.body.appendChild(overlay);
|
||||||
|
btn.focus();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function customConfirm(message) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const overlay = createModalOverlay();
|
||||||
|
const box = createModalBox();
|
||||||
|
|
||||||
|
const text = document.createElement('p');
|
||||||
|
text.textContent = message;
|
||||||
|
text.style.marginBottom = '20px';
|
||||||
|
text.style.lineHeight = '1.5';
|
||||||
|
text.style.whiteSpace = 'pre-wrap';
|
||||||
|
|
||||||
|
const btnContainer = document.createElement('div');
|
||||||
|
btnContainer.style.display = 'flex';
|
||||||
|
btnContainer.style.justifyContent = 'flex-end';
|
||||||
|
btnContainer.style.gap = '10px';
|
||||||
|
|
||||||
|
const cancelBtn = document.createElement('button');
|
||||||
|
cancelBtn.textContent = 'Abbrechen';
|
||||||
|
cancelBtn.style.padding = '8px 16px';
|
||||||
|
cancelBtn.style.cursor = 'pointer';
|
||||||
|
cancelBtn.style.border = '1px solid #ccc';
|
||||||
|
cancelBtn.style.backgroundColor = '#f8f9fa';
|
||||||
|
cancelBtn.style.color = '#333';
|
||||||
|
cancelBtn.style.borderRadius = '4px';
|
||||||
|
|
||||||
|
const okBtn = document.createElement('button');
|
||||||
|
okBtn.textContent = 'OK';
|
||||||
|
okBtn.style.padding = '8px 16px';
|
||||||
|
okBtn.style.cursor = 'pointer';
|
||||||
|
okBtn.style.border = 'none';
|
||||||
|
okBtn.style.backgroundColor = '#007bff';
|
||||||
|
okBtn.style.color = '#fff';
|
||||||
|
okBtn.style.borderRadius = '4px';
|
||||||
|
|
||||||
|
const closeAndResolve = (val) => {
|
||||||
|
document.body.removeChild(overlay);
|
||||||
|
resolve(val);
|
||||||
|
};
|
||||||
|
|
||||||
|
cancelBtn.onclick = () => closeAndResolve(false);
|
||||||
|
okBtn.onclick = () => closeAndResolve(true);
|
||||||
|
|
||||||
|
btnContainer.appendChild(cancelBtn);
|
||||||
|
btnContainer.appendChild(okBtn);
|
||||||
|
box.appendChild(text);
|
||||||
|
box.appendChild(btnContainer);
|
||||||
|
overlay.appendChild(box);
|
||||||
|
document.body.appendChild(overlay);
|
||||||
|
okBtn.focus();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function customPrompt(message, defaultValue = '') {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const overlay = createModalOverlay();
|
||||||
|
const box = createModalBox();
|
||||||
|
|
||||||
|
const text = document.createElement('p');
|
||||||
|
text.textContent = message;
|
||||||
|
text.style.marginBottom = '15px';
|
||||||
|
text.style.lineHeight = '1.5';
|
||||||
|
text.style.whiteSpace = 'pre-wrap';
|
||||||
|
|
||||||
|
const input = document.createElement('input');
|
||||||
|
input.type = 'text';
|
||||||
|
input.value = defaultValue;
|
||||||
|
input.style.width = '100%';
|
||||||
|
input.style.marginBottom = '20px';
|
||||||
|
input.style.padding = '10px';
|
||||||
|
input.style.boxSizing = 'border-box';
|
||||||
|
input.style.border = '1px solid #ccc';
|
||||||
|
input.style.borderRadius = '4px';
|
||||||
|
|
||||||
|
const btnContainer = document.createElement('div');
|
||||||
|
btnContainer.style.display = 'flex';
|
||||||
|
btnContainer.style.justifyContent = 'flex-end';
|
||||||
|
btnContainer.style.gap = '10px';
|
||||||
|
|
||||||
|
const cancelBtn = document.createElement('button');
|
||||||
|
cancelBtn.textContent = 'Abbrechen';
|
||||||
|
cancelBtn.style.padding = '8px 16px';
|
||||||
|
cancelBtn.style.cursor = 'pointer';
|
||||||
|
cancelBtn.style.border = '1px solid #ccc';
|
||||||
|
cancelBtn.style.backgroundColor = '#f8f9fa';
|
||||||
|
cancelBtn.style.color = '#333';
|
||||||
|
cancelBtn.style.borderRadius = '4px';
|
||||||
|
|
||||||
|
const okBtn = document.createElement('button');
|
||||||
|
okBtn.textContent = 'OK';
|
||||||
|
okBtn.style.padding = '8px 16px';
|
||||||
|
okBtn.style.cursor = 'pointer';
|
||||||
|
okBtn.style.border = 'none';
|
||||||
|
okBtn.style.backgroundColor = '#007bff';
|
||||||
|
okBtn.style.color = '#fff';
|
||||||
|
okBtn.style.borderRadius = '4px';
|
||||||
|
|
||||||
|
const closeAndResolve = (val) => {
|
||||||
|
document.body.removeChild(overlay);
|
||||||
|
resolve(val);
|
||||||
|
};
|
||||||
|
|
||||||
|
cancelBtn.onclick = () => closeAndResolve(null);
|
||||||
|
okBtn.onclick = () => closeAndResolve(input.value);
|
||||||
|
|
||||||
|
input.onkeydown = (e) => {
|
||||||
|
if (e.key === 'Enter') closeAndResolve(input.value);
|
||||||
|
if (e.key === 'Escape') closeAndResolve(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
btnContainer.appendChild(cancelBtn);
|
||||||
|
btnContainer.appendChild(okBtn);
|
||||||
|
|
||||||
|
box.appendChild(text);
|
||||||
|
box.appendChild(input);
|
||||||
|
box.appendChild(btnContainer);
|
||||||
|
overlay.appendChild(box);
|
||||||
|
document.body.appendChild(overlay);
|
||||||
|
|
||||||
|
input.focus();
|
||||||
|
input.select();
|
||||||
|
});
|
||||||
|
}
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
// 1. GLOBAL STATE DEFINITIONS
|
// 1. GLOBAL STATE DEFINITIONS
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
@@ -884,7 +1077,7 @@
|
|||||||
name: "Live",
|
name: "Live",
|
||||||
type: "LiveStream",
|
type: "LiveStream",
|
||||||
// This MUST match the inner div where the video should appear
|
// This MUST match the inner div where the video should appear
|
||||||
target: document.querySelector('.library-scan-reader'),
|
target: document.querySelector('.library-scan-reader'),
|
||||||
constraints: {
|
constraints: {
|
||||||
width: 640,
|
width: 640,
|
||||||
height: 480,
|
height: 480,
|
||||||
@@ -897,7 +1090,7 @@
|
|||||||
},
|
},
|
||||||
decoder: {
|
decoder: {
|
||||||
// Keep only the barcode types you actually use to improve performance
|
// Keep only the barcode types you actually use to improve performance
|
||||||
readers: ["code_128_reader", "ean_reader", "code_39_reader"]
|
readers: ["code_128_reader", "ean_reader", "code_39_reader"]
|
||||||
},
|
},
|
||||||
locate: true
|
locate: true
|
||||||
}, function(err) {
|
}, function(err) {
|
||||||
@@ -1243,31 +1436,39 @@
|
|||||||
// =========================================================================
|
// =========================================================================
|
||||||
// 4. UI INTERACTIONS & UTILITIES
|
// 4. UI INTERACTIONS & UTILITIES
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
function borrowItem(itemId) {
|
async function borrowItem(itemId) {
|
||||||
const selectedItem = (libraryItems || []).find(item => item._id === itemId);
|
const selectedItem = (libraryItems || []).find(item => item._id === itemId);
|
||||||
if (selectedItem && selectedItem.LibraryDisplayStatus === 'damaged') {
|
if (selectedItem && selectedItem.LibraryDisplayStatus === 'damaged') {
|
||||||
alert('Dieses Medium ist als defekt/zerstört markiert und kann nicht ausgeliehen werden.');
|
await customAlert('Dieses Medium ist als defekt/zerstört markiert und kann nicht ausgeliehen werden.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const defaultCardId = activeStudentCardId || '';
|
const defaultCardId = activeStudentCardId || '';
|
||||||
const cardId = (window.prompt('Bitte Bibliotheksausweis-ID eingeben:', defaultCardId) || '').trim().toUpperCase();
|
const promptCardResult = await customPrompt('Bitte Bibliotheksausweis-ID eingeben:', defaultCardId);
|
||||||
|
if (promptCardResult === null) return;
|
||||||
|
|
||||||
|
const cardId = promptCardResult.trim().toUpperCase();
|
||||||
if (!cardId) {
|
if (!cardId) {
|
||||||
alert('Ausleihe abgebrochen: Für Bibliotheksmedien ist eine gültige Bibliotheksausweis-ID erforderlich.');
|
await customAlert('Ausleihe abgebrochen: Für Bibliotheksmedien ist eine gültige Bibliotheksausweis-ID erforderlich.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setActiveStudentCard(cardId);
|
setActiveStudentCard(cardId);
|
||||||
|
|
||||||
const durationInput = (window.prompt('Ausleihdauer in Tagen (optional):') || '').trim();
|
const promptDurationResult = await customPrompt('Ausleihdauer in Tagen (optional):');
|
||||||
|
if (promptDurationResult === null) return;
|
||||||
|
const durationInput = promptDurationResult.trim();
|
||||||
|
|
||||||
const maxAvailable = Math.max(1, parseInt(selectedItem?.AvailableGroupedCount || selectedItem?.Quantity || 1, 10) || 1);
|
const maxAvailable = Math.max(1, parseInt(selectedItem?.AvailableGroupedCount || selectedItem?.Quantity || 1, 10) || 1);
|
||||||
const countPrompt = (window.prompt(`Anzahl ausleihen? (Standard: 1, verfügbar: ${maxAvailable})`, '1') || '').trim();
|
const countPromptResult = await customPrompt(`Anzahl ausleihen? (Standard: 1, verfügbar: ${maxAvailable})`, '1');
|
||||||
let borrowCount = parseInt(countPrompt || '1', 10);
|
if (countPromptResult === null) return;
|
||||||
|
|
||||||
|
let borrowCount = parseInt(countPromptResult.trim() || '1', 10);
|
||||||
if (!Number.isFinite(borrowCount) || borrowCount < 1) {
|
if (!Number.isFinite(borrowCount) || borrowCount < 1) {
|
||||||
borrowCount = 1;
|
borrowCount = 1;
|
||||||
}
|
}
|
||||||
if (borrowCount > maxAvailable) {
|
if (borrowCount > maxAvailable) {
|
||||||
alert(`Es sind nur ${maxAvailable} Exemplar(e) verfügbar.`);
|
await customAlert(`Es sind nur ${maxAvailable} Exemplar(e) verfügbar.`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2343,7 +2343,7 @@ document.addEventListener('DOMContentLoaded', ()=>{
|
|||||||
<div class="filter-container">
|
<div class="filter-container">
|
||||||
<div class="filter-group">
|
<div class="filter-group">
|
||||||
<div class="filter-header">
|
<div class="filter-header">
|
||||||
<label>Unterrichtsfach:</label>
|
<label>{{ filter_names.get('2', 'Fach') }}</label>
|
||||||
<button type="button" class="filter-toggle" onclick="toggleFilterDropdown('filter1-dropdown')">▼</button>
|
<button type="button" class="filter-toggle" onclick="toggleFilterDropdown('filter1-dropdown')">▼</button>
|
||||||
<button type="button" class="clear-filter" onclick="clearFilter(1)">Clear</button>
|
<button type="button" class="clear-filter" onclick="clearFilter(1)">Clear</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -2357,7 +2357,7 @@ document.addEventListener('DOMContentLoaded', ()=>{
|
|||||||
|
|
||||||
<div class="filter-group">
|
<div class="filter-group">
|
||||||
<div class="filter-header">
|
<div class="filter-header">
|
||||||
<label>Jahrgangsstufe:</label>
|
<label>{{ filter_names.get('1', 'Klassenstufe') }}</label>
|
||||||
<button type="button" class="filter-toggle" onclick="toggleFilterDropdown('filter2-dropdown')">▼</button>
|
<button type="button" class="filter-toggle" onclick="toggleFilterDropdown('filter2-dropdown')">▼</button>
|
||||||
<button type="button" class="clear-filter" onclick="clearFilter(2)">Clear</button>
|
<button type="button" class="clear-filter" onclick="clearFilter(2)">Clear</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -2371,7 +2371,7 @@ document.addEventListener('DOMContentLoaded', ()=>{
|
|||||||
|
|
||||||
<div class="filter-group">
|
<div class="filter-group">
|
||||||
<div class="filter-header">
|
<div class="filter-header">
|
||||||
<label>Schlagwort:</label>
|
<label>{{ filter_names.get('3', 'Schlagwort') }}</label>
|
||||||
<button type="button" class="filter-toggle" onclick="toggleFilterDropdown('filter3-dropdown')">▼</button>
|
<button type="button" class="filter-toggle" onclick="toggleFilterDropdown('filter3-dropdown')">▼</button>
|
||||||
<button type="button" class="clear-filter" onclick="clearFilter(3)">Clear</button>
|
<button type="button" class="clear-filter" onclick="clearFilter(3)">Clear</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -2420,9 +2420,9 @@ document.addEventListener('DOMContentLoaded', ()=>{
|
|||||||
<div id="table-view-header" class="table-view-header" aria-hidden="true">
|
<div id="table-view-header" class="table-view-header" aria-hidden="true">
|
||||||
<span>Name</span>
|
<span>Name</span>
|
||||||
<span>Ort</span>
|
<span>Ort</span>
|
||||||
<span>Unterrichtsfach</span>
|
<span>{{ filter_names.get('2', 'Fach') }}</span>
|
||||||
<span>Jahrgangsstufe</span>
|
<span>{{ filter_names.get('1', 'Jahrgangsstufe') }}</span>
|
||||||
<span>Schlagwort</span>
|
<span>{{ filter_names.get('3', 'Schlagwort') }}</span>
|
||||||
<span>Barcode</span>
|
<span>Barcode</span>
|
||||||
<span>Anzahl</span>
|
<span>Anzahl</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -3418,14 +3418,24 @@ document.addEventListener('DOMContentLoaded', ()=>{
|
|||||||
`<form method="POST" action="{{ url_for('ausleihen', id='') }}${item._id}">
|
`<form method="POST" action="{{ url_for('ausleihen', id='') }}${item._id}">
|
||||||
${isGroupedItem ? `
|
${isGroupedItem ? `
|
||||||
<div class="grouped-borrow-controls" style="display:flex; gap:8px; flex-wrap:wrap; margin-bottom:8px; align-items:center;">
|
<div class="grouped-borrow-controls" style="display:flex; gap:8px; flex-wrap:wrap; margin-bottom:8px; align-items:center;">
|
||||||
<label style="font-size:0.85rem; margin:0;">Anzahl:</label>
|
<label style="font-size:0.85rem; margin:0;">Anzahl:</label>
|
||||||
<input type="number" name="exemplare_count" min="1" max="${availableGroupedCount}" value="1" style="width:74px; padding:4px;">
|
<input type="number"
|
||||||
<label style="font-size:0.85rem; margin:0;">oder Code:</label>
|
name="exemplare_count"
|
||||||
<select id="specific-item-card-${item._id}" name="specific_item_id" style="max-width:190px; padding:4px;">
|
min="1"
|
||||||
<option value="">Automatisch wählen</option>
|
max="${availableGroupedCount}"
|
||||||
${groupedAvailableUnits.map(unit => `<option value="${unit.id}">${unit.code}</option>`).join('')}
|
value="1"
|
||||||
</select>
|
style="width:74px; padding:4px;"
|
||||||
</div>` : ''}
|
oninput="if(this.value){ this.form.querySelector('[name=specific_item_id]').value = ''; }">
|
||||||
|
|
||||||
|
<label style="font-size:0.85rem; margin:0;">oder Code:</label>
|
||||||
|
<select id="specific-item-card-${item._id}"
|
||||||
|
name="specific_item_id"
|
||||||
|
style="max-width:190px; padding:4px;"
|
||||||
|
onchange="if(this.value !== ''){ this.form.querySelector('[name=exemplare_count]').value = '1'; }">
|
||||||
|
<option value="">Automatisch wählen</option>
|
||||||
|
${groupedAvailableUnits.map(unit => `<option value="${unit.id}">${unit.code}</option>`).join('')}
|
||||||
|
</select>
|
||||||
|
</div>` : ''}
|
||||||
<button class="ausleihen" type="submit">Ausleihen</button>
|
<button class="ausleihen" type="submit">Ausleihen</button>
|
||||||
</form>`
|
</form>`
|
||||||
: isBorrowedByMe ?
|
: isBorrowedByMe ?
|
||||||
|
|||||||
@@ -1,135 +1,173 @@
|
|||||||
<!--
|
|
||||||
Copyright 2025-2026 AIIrondev
|
|
||||||
|
|
||||||
Licensed under the Inventarsystem EULA (Endbenutzer-Lizenzvertrag).
|
|
||||||
See Legal/LICENSE for the full license text.
|
|
||||||
Unauthorized commercial use, SaaS hosting, or removal of branding is prohibited.
|
|
||||||
For commercial licensing inquiries: https://github.com/AIIrondev
|
|
||||||
-->
|
|
||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
|
|
||||||
{% block title %}Filter verwalten{% endblock %}
|
{% block title %}Filter verwalten{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="container">
|
<div class="container py-4">
|
||||||
<h1 class="mb-4">Filterwerte verwalten</h1>
|
<div class="d-flex flex-column flex-md-row justify-content-between align-items-md-center pb-3 mb-4 border-bottom gap-3">
|
||||||
|
<div>
|
||||||
<div class="row">
|
<h1 class="h2 fw-bold mb-1">Filterwerte verwalten</h1>
|
||||||
<!-- Filter 1 -->
|
<p class="text-muted mb-0">Verwalten und Bearbeiten Sie die Zuordnungswerte für das Inventar.</p>
|
||||||
<div class="col-md-4">
|
</div>
|
||||||
<div class="card mb-4">
|
<div>
|
||||||
<div class="card-header">
|
<a href="{{ url_for('home_admin') }}" class="btn btn-outline-secondary d-inline-flex align-items-center">
|
||||||
<h2 class="card-title h5 mb-0">{{ filter_names.get('1', 'Jahrgang') }} (Filter 1)</h2>
|
<i class="bi bi-arrow-left me-2"></i> Zurück zur Admin-Übersicht
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row g-4">
|
||||||
|
<div class="col-lg-6">
|
||||||
|
<div class="card shadow-sm border-0 h-100 rounded-3">
|
||||||
|
<div class="card-header bg-white border-bottom py-3 d-flex justify-content-between align-items-center">
|
||||||
|
<h2 class="card-title h5 mb-0 fw-semibold text-primary">
|
||||||
|
<i class="bi bi-funnel me-2"></i>{{ filter_names.get('1', 'Jahrgangsstufe') }}
|
||||||
|
</h2>
|
||||||
|
<span class="badge bg-primary-subtle text-primary rounded-pill">Filter 1</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
<div class="card-body p-4">
|
||||||
<form method="POST" action="{{ url_for('add_filter_value', filter_num=1) }}" class="mb-4">
|
<form method="POST" action="{{ url_for('add_filter_value', filter_num=1) }}" class="mb-4">
|
||||||
|
<label class="form-label fw-medium text-secondary">Neuen Wert hinzufügen</label>
|
||||||
<div class="input-group">
|
<div class="input-group">
|
||||||
<input type="text" name="value" class="form-control" placeholder="Neuer Wert..." required>
|
<input type="text" name="value" class="form-control" placeholder="Wert eingeben..." required>
|
||||||
<button type="submit" class="btn btn-primary">Hinzufügen</button>
|
<button type="submit" class="btn btn-primary d-inline-flex align-items-center">
|
||||||
|
<i class="bi bi-plus-lg me-1"></i> Hinzufügen
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<h5 class="mb-3">Vorhandene Werte</h5>
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||||
|
<h3 class="h6 text-uppercase text-muted fw-bold mb-0">Vorhandene Werte</h3>
|
||||||
|
<span class="badge bg-secondary rounded-pill">{{ filter1_values|length if filter1_values else 0 }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
{% if filter1_values %}
|
{% if filter1_values %}
|
||||||
<div class="list-group">
|
<div class="list-group list-group-flush border rounded-3 overflow-hidden">
|
||||||
{% for value in filter1_values %}
|
{% for value in filter1_values %}
|
||||||
<div class="list-group-item">
|
<div class="list-group-item p-3">
|
||||||
<div class="d-flex justify-content-between align-items-center mb-2">
|
<div class="d-flex justify-content-between align-items-center">
|
||||||
<span>{{ value }}</span>
|
<span class="fw-medium">{{ value }}</span>
|
||||||
<div>
|
<div class="btn-group btn-group-sm">
|
||||||
<button type="button" class="btn btn-sm btn-secondary me-1" onclick="toggleEdit('edit-1-{{ loop.index }}')">Bearbeiten</button>
|
<button type="button" class="btn btn-outline-secondary" onclick="toggleEdit('edit-1-{{ loop.index }}')">
|
||||||
|
<i class="bi bi-pencil me-1"></i> Bearbeiten
|
||||||
|
</button>
|
||||||
<form method="POST" action="{{ url_for('remove_filter_value', filter_num=1, value=value) }}" class="d-inline">
|
<form method="POST" action="{{ url_for('remove_filter_value', filter_num=1, value=value) }}" class="d-inline">
|
||||||
<button type="submit" class="btn btn-sm btn-danger"
|
<button type="submit" class="btn btn-outline-danger"
|
||||||
onclick="return confirm('Sind Sie sicher, dass Sie den Wert \"' + '{{ value }}'.replace(/'/g, '\\\'') + '\" löschen möchten?');">
|
onclick="return confirm('Sind Sie sicher, dass Sie den Wert \"' + '{{ value }}'.replace(/'/g, '\\\'') + '\" löschen möchten?');">
|
||||||
Entfernen
|
<i class="bi bi-trash me-1"></i> Entfernen
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<form id="edit-1-{{ loop.index }}" method="POST" action="{{ url_for('edit_filter_value', filter_num=1, old_value=value) }}" style="display: none;" class="mt-2">
|
|
||||||
|
<form id="edit-1-{{ loop.index }}" method="POST" action="{{ url_for('edit_filter_value', filter_num=1, old_value=value) }}" style="display: none;" class="mt-3 pt-3 border-top">
|
||||||
<div class="input-group input-group-sm">
|
<div class="input-group input-group-sm">
|
||||||
<input type="text" name="new_value" class="form-control" value="{{ value }}" required>
|
<input type="text" name="new_value" class="form-control" value="{{ value }}" required>
|
||||||
<button type="submit" class="btn btn-primary" onclick="return confirm('Tipp: Das Ändern des Namens aktualisiert auch alle Einträge in der Datenbank, die diesen Filter verwenden. Fortfahren?');">Speichern</button>
|
<button type="submit" class="btn btn-success" onclick="return confirm('Tipp: Das Ändern des Namens aktualisiert auch alle Einträge in der Datenbank, die diesen Filter verwenden. Fortfahren?');">
|
||||||
<button type="button" class="btn btn-secondary" onclick="toggleEdit('edit-1-{{ loop.index }}')">Abbrechen</button>
|
<i class="bi bi-check-lg me-1"></i> Speichern
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn btn-outline-secondary" onclick="toggleEdit('edit-1-{{ loop.index }}')">
|
||||||
|
Abbrechen
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
<div class="alert alert-info">Keine Werte definiert.</div>
|
<div class="alert alert-light border text-center text-muted mb-0">
|
||||||
|
<i class="bi bi-info-circle me-1"></i> Keine Werte definiert.
|
||||||
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Filter 2 -->
|
<div class="col-lg-6">
|
||||||
<div class="col-md-4">
|
<div class="card shadow-sm border-0 h-100 rounded-3">
|
||||||
<div class="card mb-4">
|
<div class="card-header bg-white border-bottom py-3 d-flex justify-content-between align-items-center">
|
||||||
<div class="card-header">
|
<h2 class="card-title h5 mb-0 fw-semibold text-primary">
|
||||||
<h2 class="card-title h5 mb-0">{{ filter_names.get('2', 'Fach') }} (Filter 2)</h2>
|
<i class="bi bi-funnel me-2"></i>{{ filter_names.get('2', 'Fachgebiet') }}
|
||||||
|
</h2>
|
||||||
|
<span class="badge bg-primary-subtle text-primary rounded-pill">Filter 2</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
<div class="card-body p-4">
|
||||||
<form method="POST" action="{{ url_for('add_filter_value', filter_num=2) }}" class="mb-4">
|
<form method="POST" action="{{ url_for('add_filter_value', filter_num=2) }}" class="mb-4">
|
||||||
|
<label class="form-label fw-medium text-secondary">Neuen Wert hinzufügen</label>
|
||||||
<div class="input-group">
|
<div class="input-group">
|
||||||
<input type="text" name="value" class="form-control" placeholder="Neuer Wert..." required>
|
<input type="text" name="value" class="form-control" placeholder="Wert eingeben..." required>
|
||||||
<button type="submit" class="btn btn-primary">Hinzufügen</button>
|
<button type="submit" class="btn btn-primary d-inline-flex align-items-center">
|
||||||
|
<i class="bi bi-plus-lg me-1"></i> Hinzufügen
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<h5 class="mb-3">Vorhandene Werte</h5>
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||||
|
<h3 class="h6 text-uppercase text-muted fw-bold mb-0">Vorhandene Werte</h3>
|
||||||
|
<span class="badge bg-secondary rounded-pill">{{ filter2_values|length if filter2_values else 0 }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
{% if filter2_values %}
|
{% if filter2_values %}
|
||||||
<div class="list-group">
|
<div class="list-group list-group-flush border rounded-3 overflow-hidden">
|
||||||
{% for value in filter2_values %}
|
{% for value in filter2_values %}
|
||||||
<div class="list-group-item">
|
<div class="list-group-item p-3">
|
||||||
<div class="d-flex justify-content-between align-items-center mb-2">
|
<div class="d-flex justify-content-between align-items-center">
|
||||||
<span>{{ value }}</span>
|
<span class="fw-medium">{{ value }}</span>
|
||||||
<div>
|
<div class="btn-group btn-group-sm">
|
||||||
<button type="button" class="btn btn-sm btn-secondary me-1" onclick="toggleEdit('edit-2-{{ loop.index }}')">Bearbeiten</button>
|
<button type="button" class="btn btn-outline-secondary" onclick="toggleEdit('edit-2-{{ loop.index }}')">
|
||||||
|
<i class="bi bi-pencil me-1"></i> Bearbeiten
|
||||||
|
</button>
|
||||||
<form method="POST" action="{{ url_for('remove_filter_value', filter_num=2, value=value) }}" class="d-inline">
|
<form method="POST" action="{{ url_for('remove_filter_value', filter_num=2, value=value) }}" class="d-inline">
|
||||||
<button type="submit" class="btn btn-sm btn-danger"
|
<button type="submit" class="btn btn-outline-danger"
|
||||||
onclick="return confirm('Sind Sie sicher, dass Sie den Wert \"' + '{{ value }}'.replace(/'/g, '\\\'') + '\" löschen möchten?');">
|
onclick="return confirm('Sind Sie sicher, dass Sie den Wert \"' + '{{ value }}'.replace(/'/g, '\\\'') + '\" löschen möchten?');">
|
||||||
Entfernen
|
<i class="bi bi-trash me-1"></i> Entfernen
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<form id="edit-2-{{ loop.index }}" method="POST" action="{{ url_for('edit_filter_value', filter_num=2, old_value=value) }}" style="display: none;" class="mt-2">
|
|
||||||
|
<form id="edit-2-{{ loop.index }}" method="POST" action="{{ url_for('edit_filter_value', filter_num=2, old_value=value) }}" style="display: none;" class="mt-3 pt-3 border-top">
|
||||||
<div class="input-group input-group-sm">
|
<div class="input-group input-group-sm">
|
||||||
<input type="text" name="new_value" class="form-control" value="{{ value }}" required>
|
<input type="text" name="new_value" class="form-control" value="{{ value }}" required>
|
||||||
<button type="submit" class="btn btn-primary" onclick="return confirm('Tipp: Das Ändern des Namens aktualisiert auch alle Einträge in der Datenbank, die diesen Filter verwenden. Fortfahren?');">Speichern</button>
|
<button type="submit" class="btn btn-success" onclick="return confirm('Tipp: Das Ändern des Namens aktualisiert auch alle Einträge in der Datenbank, die diesen Filter verwenden. Fortfahren?');">
|
||||||
<button type="button" class="btn btn-secondary" onclick="toggleEdit('edit-2-{{ loop.index }}')">Abbrechen</button>
|
<i class="bi bi-check-lg me-1"></i> Speichern
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn btn-outline-secondary" onclick="toggleEdit('edit-2-{{ loop.index }}')">
|
||||||
|
Abbrechen
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
<div class="alert alert-info">Keine Werte definiert.</div>
|
<div class="alert alert-light border text-center text-muted mb-0">
|
||||||
|
<i class="bi bi-info-circle me-1"></i> Keine Werte definiert.
|
||||||
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="alert alert-warning">
|
<div class="alert alert-warning border-0 shadow-sm d-flex align-items-center mt-4 rounded-3" role="alert">
|
||||||
<strong>Hinweis:</strong> Beim Entfernen eines Filterwertes wird dieser nicht aus bestehenden Objekten entfernt.
|
<i class="bi bi-exclamation-triangle-fill fs-4 me-3 text-warning"></i>
|
||||||
Bereits verwendete Werte bleiben in den Objekten erhalten.
|
<div>
|
||||||
</div>
|
<strong>Hinweis:</strong> Beim Entfernen eines Filterwertes wird dieser nicht automatisch aus bestehenden Objekten gelöscht. Bereits zugewiesene Werte bleiben in bestehenden Datensätzen erhalten.
|
||||||
|
</div>
|
||||||
<div class="mt-4">
|
|
||||||
<a href="{{ url_for('home_admin') }}" class="btn btn-secondary">Zurück zur Admin-Übersicht</a>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
function toggleEdit(id) {
|
function toggleEdit(id) {
|
||||||
const el = document.getElementById(id);
|
const el = document.getElementById(id);
|
||||||
if (el.style.display === 'none') {
|
if (el.style.display === 'none' || el.style.display === '') {
|
||||||
el.style.display = 'block';
|
el.style.display = 'block';
|
||||||
|
const input = el.querySelector('input[type="text"]');
|
||||||
|
if (input) input.focus();
|
||||||
} else {
|
} else {
|
||||||
el.style.display = 'none';
|
el.style.display = 'none';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
@@ -670,7 +670,6 @@
|
|||||||
<form method="POST" action="{{ url_for('upload_inventory_excel') }}" enctype="multipart/form-data" style="display:flex; gap:10px; flex-wrap:wrap; align-items:center;">
|
<form method="POST" action="{{ url_for('upload_inventory_excel') }}" enctype="multipart/form-data" style="display:flex; gap:10px; flex-wrap:wrap; align-items:center;">
|
||||||
<input type="file" name="inventory_excel" accept=".xlsx,.csv" required>
|
<input type="file" name="inventory_excel" accept=".xlsx,.csv" required>
|
||||||
<button type="button" class="btn btn-link" onclick="downloadSampleCsv('inventory')">Beispiel-CSV herunterladen</button>
|
<button type="button" class="btn btn-link" onclick="downloadSampleCsv('inventory')">Beispiel-CSV herunterladen</button>
|
||||||
<button type="button" class="btn btn-outline-secondary" title="Format-Hilfe" onclick="showCsvHelp('inventory')">?</button>
|
|
||||||
<button type="submit" class="btn btn-secondary" name="excel_action" value="validate">Nur validieren</button>
|
<button type="submit" class="btn btn-secondary" name="excel_action" value="validate">Nur validieren</button>
|
||||||
<button type="submit" class="btn btn-primary" name="excel_action" value="import">Inventar importieren</button>
|
<button type="submit" class="btn btn-primary" name="excel_action" value="import">Inventar importieren</button>
|
||||||
</form>
|
</form>
|
||||||
@@ -781,7 +780,7 @@
|
|||||||
{% else %}
|
{% else %}
|
||||||
<!-- Normal Item Mode: Standard Filters -->
|
<!-- Normal Item Mode: Standard Filters -->
|
||||||
<div class="filter-inputs">
|
<div class="filter-inputs">
|
||||||
<h3>Unterrichtsfach:</h3>
|
<h3>{{ filter_names.get('1', 'Jahrgangsstufe') }}</h3>
|
||||||
<div class="multi-filter">
|
<div class="multi-filter">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="filter1-1">Wert 1:</label>
|
<label for="filter1-1">Wert 1:</label>
|
||||||
@@ -813,7 +812,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h3>Jahrgangsstufe:</h3>
|
<h3>{{ filter_names.get('2', 'Fachgebiet') }}</h3>
|
||||||
<div class="multi-filter">
|
<div class="multi-filter">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="filter2-1">Wert 1:</label>
|
<label for="filter2-1">Wert 1:</label>
|
||||||
@@ -845,7 +844,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h3>Schlagwort:</h3>
|
<h3>{{ filter_names.get('3', 'Schlagwort') }}</h3>
|
||||||
<div class="multi-filter">
|
<div class="multi-filter">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="filter3-1">Wert 1:</label>
|
<label for="filter3-1">Wert 1:</label>
|
||||||
|
|||||||
@@ -53,8 +53,11 @@
|
|||||||
<option value="desc">Absteigend</option>
|
<option value="desc">Absteigend</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-2">
|
<div class="col-md-3">
|
||||||
<button class="btn btn-secondary w-100" onclick="resetFilters()">Filter zurücksetzen</button>
|
<div class="d-flex gap-2">
|
||||||
|
<button class="btn btn-secondary flex-grow-1" onclick="resetFilters()">Zurücksetzen</button>
|
||||||
|
<button class="btn btn-success flex-grow-1" onclick="exportFilteredUsers()">Exportieren</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-2 text-muted small" id="filter-count"></div>
|
<div class="mt-2 text-muted small" id="filter-count"></div>
|
||||||
@@ -82,19 +85,20 @@
|
|||||||
<td>{{ permission_presets.get(user.permission_preset, {}).get('label', user.permission_preset) }}</td>
|
<td>{{ permission_presets.get(user.permission_preset, {}).get('label', user.permission_preset) }}</td>
|
||||||
<td class="actions">
|
<td class="actions">
|
||||||
<form method="POST" action="{{ url_for('delete_user') }}" class="d-inline">
|
<form method="POST" action="{{ url_for('delete_user') }}" class="d-inline">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||||
<input type="hidden" name="username" value="{{ user.username }}">
|
<input type="hidden" name="username" value="{{ user.username }}">
|
||||||
<button type="submit" class="btn btn-danger btn-sm" onclick="return confirm('Sind Sie sicher, dass Sie den Benutzer {{ user.username }} löschen möchten?')">
|
<button type="submit" class="btn btn-danger btn-sm" onclick="return confirm('Sind Sie sicher, dass Sie den Benutzer {{ user.username }} löschen möchten?')">
|
||||||
Löschen
|
Löschen
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
<button type="button" class="btn btn-primary btn-sm"
|
<button type="button" class="btn btn-primary btn-sm"
|
||||||
data-username="{{ user.username }}"
|
data-username="{{ user.username }}"
|
||||||
data-firstname="{{ user.name if user.name else '' }}"
|
data-firstname="{{ user.name if user.name else '' }}"
|
||||||
data-lastname="{{ user.last_name if user.last_name else '' }}"
|
data-lastname="{{ user.last_name if user.last_name else '' }}"
|
||||||
onclick="openEditUserModal(this)">
|
onclick="openEditUserModal(this)">
|
||||||
Bearbeiten
|
Bearbeiten
|
||||||
</button>
|
</button>
|
||||||
<button type="button" class="btn btn-warning btn-sm"
|
<button type="button" class="btn btn-warning btn-sm"
|
||||||
onclick="openResetPasswordModal('{{ user.username }}')">
|
onclick="openResetPasswordModal('{{ user.username }}')">
|
||||||
Passwort zurücksetzen
|
Passwort zurücksetzen
|
||||||
</button>
|
</button>
|
||||||
@@ -124,6 +128,7 @@
|
|||||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||||
</div>
|
</div>
|
||||||
<form method="POST" action="{{ url_for('admin_update_user_permissions') }}">
|
<form method="POST" action="{{ url_for('admin_update_user_permissions') }}">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||||
<div class="modal-body permissions-modal-body">
|
<div class="modal-body permissions-modal-body">
|
||||||
<input type="hidden" id="perm-username" name="username">
|
<input type="hidden" id="perm-username" name="username">
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
@@ -180,6 +185,7 @@
|
|||||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||||
</div>
|
</div>
|
||||||
<form method="POST" action="{{ url_for('admin_update_user_name') }}">
|
<form method="POST" action="{{ url_for('admin_update_user_name') }}">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
<input type="hidden" id="edit-username" name="username">
|
<input type="hidden" id="edit-username" name="username">
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
@@ -213,6 +219,7 @@
|
|||||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||||
</div>
|
</div>
|
||||||
<form method="POST" action="{{ url_for('admin_reset_user_password') }}">
|
<form method="POST" action="{{ url_for('admin_reset_user_password') }}">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
<input type="hidden" id="reset-username" name="username">
|
<input type="hidden" id="reset-username" name="username">
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
@@ -313,6 +320,54 @@
|
|||||||
applyFilters();
|
applyFilters();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function exportFilteredUsers() {
|
||||||
|
var table = document.getElementById('user-table');
|
||||||
|
var tbody = table.querySelector('tbody');
|
||||||
|
var rows = Array.from(tbody.querySelectorAll('tr'));
|
||||||
|
|
||||||
|
var visibleUsernames = [];
|
||||||
|
|
||||||
|
// Sammle alle Benutzernamen, die durch den Filter aktuell NICHT versteckt sind
|
||||||
|
rows.forEach(function(row) {
|
||||||
|
if (row.style.display !== 'none') {
|
||||||
|
var cells = row.querySelectorAll('td');
|
||||||
|
if (cells.length > 0) {
|
||||||
|
visibleUsernames.push(cells[0].textContent.trim());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (visibleUsernames.length === 0) {
|
||||||
|
alert('Keine Benutzer zum Exportieren vorhanden.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dynamisches Formular erstellen
|
||||||
|
var form = document.createElement('form');
|
||||||
|
form.method = 'POST';
|
||||||
|
form.action = "{{ url_for('export_users_csv') }}";
|
||||||
|
|
||||||
|
// 1. CSRF-Token als Wert (ohne Klammern) übergeben
|
||||||
|
var csrfInput = document.createElement('input');
|
||||||
|
csrfInput.type = 'hidden';
|
||||||
|
csrfInput.name = 'csrf_token';
|
||||||
|
csrfInput.value = "{{ csrf_token }}";
|
||||||
|
form.appendChild(csrfInput);
|
||||||
|
|
||||||
|
// 2. Gefilterte Benutzernamen hinzufügen
|
||||||
|
var input = document.createElement('input');
|
||||||
|
input.type = 'hidden';
|
||||||
|
input.name = 'usernames';
|
||||||
|
input.value = JSON.stringify(visibleUsernames);
|
||||||
|
form.appendChild(input);
|
||||||
|
|
||||||
|
document.body.appendChild(form);
|
||||||
|
form.submit();
|
||||||
|
|
||||||
|
// Aufräumen
|
||||||
|
document.body.removeChild(form);
|
||||||
|
}
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
applyFilters();
|
applyFilters();
|
||||||
document.getElementById('filter-search').addEventListener('input', applyFilters);
|
document.getElementById('filter-search').addEventListener('input', applyFilters);
|
||||||
@@ -332,12 +387,12 @@
|
|||||||
var username = button.getAttribute('data-username');
|
var username = button.getAttribute('data-username');
|
||||||
var name = button.getAttribute('data-firstname');
|
var name = button.getAttribute('data-firstname');
|
||||||
var lastName = button.getAttribute('data-lastname');
|
var lastName = button.getAttribute('data-lastname');
|
||||||
|
|
||||||
document.getElementById('edit-username').value = username;
|
document.getElementById('edit-username').value = username;
|
||||||
document.getElementById('edit-username-display').value = username;
|
document.getElementById('edit-username-display').value = username;
|
||||||
document.getElementById('edit-name').value = name;
|
document.getElementById('edit-name').value = name;
|
||||||
document.getElementById('edit-last-name').value = lastName;
|
document.getElementById('edit-last-name').value = lastName;
|
||||||
|
|
||||||
var modal = new bootstrap.Modal(document.getElementById('editUserModal'));
|
var modal = new bootstrap.Modal(document.getElementById('editUserModal'));
|
||||||
modal.show();
|
modal.show();
|
||||||
}
|
}
|
||||||
@@ -345,8 +400,7 @@
|
|||||||
function openResetPasswordModal(username) {
|
function openResetPasswordModal(username) {
|
||||||
document.getElementById('reset-username').value = username;
|
document.getElementById('reset-username').value = username;
|
||||||
document.getElementById('username-display').value = username;
|
document.getElementById('username-display').value = username;
|
||||||
|
|
||||||
// Open modal using Bootstrap
|
|
||||||
var modal = new bootstrap.Modal(document.getElementById('resetPasswordModal'));
|
var modal = new bootstrap.Modal(document.getElementById('resetPasswordModal'));
|
||||||
modal.show();
|
modal.show();
|
||||||
}
|
}
|
||||||
|
|||||||
+106
-72
@@ -430,6 +430,7 @@ PY
|
|||||||
initialize_tenant_database() {
|
initialize_tenant_database() {
|
||||||
local tenant_id="$1"
|
local tenant_id="$1"
|
||||||
local mode="$2"
|
local mode="$2"
|
||||||
|
local admin_password="${3:-admin123}"
|
||||||
|
|
||||||
APP_CONTAINER=$(docker ps -qf "name=app" | head -n 1)
|
APP_CONTAINER=$(docker ps -qf "name=app" | head -n 1)
|
||||||
if [ -z "$APP_CONTAINER" ]; then
|
if [ -z "$APP_CONTAINER" ]; then
|
||||||
@@ -438,7 +439,8 @@ initialize_tenant_database() {
|
|||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
docker exec -i "$APP_CONTAINER" python3 - "$tenant_id" "$mode" <<'PY'
|
# Fix: We inject the password securely via a temporary environment variable (-e ADMIN_PASS)
|
||||||
|
docker exec -i -e ADMIN_PASS="$admin_password" "$APP_CONTAINER" python3 - "$tenant_id" "$mode" <<'PY'
|
||||||
import sys, os, re, datetime, hashlib
|
import sys, os, re, datetime, hashlib
|
||||||
sys.path.insert(0, "/app")
|
sys.path.insert(0, "/app")
|
||||||
sys.path.insert(0, "/app/Web")
|
sys.path.insert(0, "/app/Web")
|
||||||
@@ -449,6 +451,13 @@ import Web.modules.database.user as us
|
|||||||
|
|
||||||
tenant_id = sys.argv[1].lower()
|
tenant_id = sys.argv[1].lower()
|
||||||
mode = sys.argv[2]
|
mode = sys.argv[2]
|
||||||
|
|
||||||
|
# Fix: Read the password directly from the environment variable instead of sys.argv
|
||||||
|
admin_password = os.environ.get("ADMIN_PASS", "admin123")
|
||||||
|
# Failsafe in case of empty string
|
||||||
|
if not admin_password.strip():
|
||||||
|
admin_password = "admin123"
|
||||||
|
|
||||||
sanitized = "".join(c for c in tenant_id if c.isalnum() or c == "_")
|
sanitized = "".join(c for c in tenant_id if c.isalnum() or c == "_")
|
||||||
db_name = f"inventar_{sanitized}" if sanitized else settings.MONGODB_DB
|
db_name = f"inventar_{sanitized}" if sanitized else settings.MONGODB_DB
|
||||||
|
|
||||||
@@ -458,7 +467,7 @@ users = db["users"]
|
|||||||
permission_defaults = us.build_default_permission_payload("full_access")
|
permission_defaults = us.build_default_permission_payload("full_access")
|
||||||
admin_doc = {
|
admin_doc = {
|
||||||
"Username": "admin",
|
"Username": "admin",
|
||||||
"Password": us.hashing("admin123"),
|
"Password": us.hashing(admin_password),
|
||||||
"Admin": True,
|
"Admin": True,
|
||||||
"active_ausleihung": None,
|
"active_ausleihung": None,
|
||||||
"name": dp.encrypt_text("admin"),
|
"name": dp.encrypt_text("admin"),
|
||||||
@@ -488,7 +497,7 @@ if mode == "trial":
|
|||||||
|
|
||||||
client.close()
|
client.close()
|
||||||
|
|
||||||
print(f"Tenant {sys.argv[1]} database initialized. Default admin: admin / admin123")
|
print(f"Tenant {sys.argv[1]} database initialized. Default admin: admin / {admin_password}")
|
||||||
PY
|
PY
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -812,12 +821,26 @@ case "$COMMAND" in
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
PORT_ARG="${3:-}"
|
ARG3="${3:-}"
|
||||||
if [ -n "$PORT_ARG" ]; then
|
ARG4="${4:-}"
|
||||||
if ! printf '%s\n' "$PORT_ARG" | grep -qE '^[0-9]+$'; then
|
|
||||||
echo "Error: Port must be a numeric value."
|
PORT_ARG=""
|
||||||
exit 1
|
PASSWORD_ARG="admin123"
|
||||||
|
|
||||||
|
# Check if the 3rd argument is numeric (Port) or text (Password)
|
||||||
|
if [ -n "$ARG3" ]; then
|
||||||
|
if printf '%s\n' "$ARG3" | grep -qE '^[0-9]+$'; then
|
||||||
|
PORT_ARG="$ARG3"
|
||||||
|
if [ -n "$ARG4" ]; then
|
||||||
|
PASSWORD_ARG="$ARG4"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
# If ARG3 is not numeric, treat it as the password without setting a port
|
||||||
|
PASSWORD_ARG="$ARG3"
|
||||||
fi
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -n "$PORT_ARG" ]; then
|
||||||
register_tenant_port "$TENANT_ID" "$PORT_ARG"
|
register_tenant_port "$TENANT_ID" "$PORT_ARG"
|
||||||
update_runtime_ports "$PORT_ARG"
|
update_runtime_ports "$PORT_ARG"
|
||||||
sync_tenant_port_map
|
sync_tenant_port_map
|
||||||
@@ -829,11 +852,9 @@ case "$COMMAND" in
|
|||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
echo "Adding new tenant '$TENANT_ID'..."
|
echo "Adding new tenant '$TENANT_ID'..."
|
||||||
echo "Initializing database for $TENANT_ID..."
|
echo "Initializing database for $TENANT_ID..."
|
||||||
initialize_tenant_database "$TENANT_ID" "standard"
|
initialize_tenant_database "$TENANT_ID" "standard" "$PASSWORD_ARG"
|
||||||
echo "Tenant '$TENANT_ID' successfully added. Ready to use."
|
echo "Tenant '$TENANT_ID' successfully added. Ready to use."
|
||||||
;;
|
;;
|
||||||
|
|
||||||
@@ -872,81 +893,94 @@ case "$COMMAND" in
|
|||||||
;;
|
;;
|
||||||
|
|
||||||
remove)
|
remove)
|
||||||
FORCE_REMOVE=false
|
FORCE_REMOVE=false
|
||||||
TENANT_ARG="${2:-}"
|
|
||||||
|
|
||||||
if [ "$TENANT_ARG" = "--yes" ] || [ "$TENANT_ARG" = "-y" ]; then
|
# Fix: Prüfe sowohl Position 2 als auch 3 auf das -y Flag
|
||||||
FORCE_REMOVE=true
|
if [ "${2:-}" = "--yes" ] || [ "${2:-}" = "-y" ]; then
|
||||||
TENANT_ID="${3:-}"
|
FORCE_REMOVE=true
|
||||||
else
|
TENANT_ID="${3:-}"
|
||||||
TENANT_ID="$TENANT_ARG"
|
elif [ "${3:-}" = "--yes" ] || [ "${3:-}" = "-y" ]; then
|
||||||
|
FORCE_REMOVE=true
|
||||||
|
TENANT_ID="${2:-}"
|
||||||
|
else
|
||||||
|
TENANT_ID="${2:-}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -z "$TENANT_ID" ]; then
|
||||||
|
echo "Error: Please provide a tenant_id to remove."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$FORCE_REMOVE" != true ]; then
|
||||||
|
echo -n "WARNING: Are you sure you want to permanently delete all data for tenant '$TENANT_ID'? (y/N) "
|
||||||
|
read confirm
|
||||||
|
if [ "$confirm" != "y" ] && [ "$confirm" != "Y" ]; then
|
||||||
|
echo "Removal canceled."
|
||||||
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
if [ -z "$TENANT_ID" ]; then
|
echo "Removing tenant '$TENANT_ID'..."
|
||||||
echo "Error: Please provide a tenant_id to remove."
|
APP_CONTAINER=$(docker ps -qf "name=app" | head -n 1)
|
||||||
exit 1
|
port_to_remove=""
|
||||||
fi
|
|
||||||
|
|
||||||
if [ "$FORCE_REMOVE" != true ]; then
|
if [ -n "$APP_CONTAINER" ]; then
|
||||||
echo -n "WARNING: Are you sure you want to permanently delete all data for tenant '$TENANT_ID'? (y/N) "
|
# MongoDB-Datenbank via PyMongo direkt im Container droppen
|
||||||
read confirm
|
# Fix 1: Wir hängen '|| true' an, damit das Skript nicht abstürzt, falls der Befehl fehlschlägt.
|
||||||
if [ "$confirm" != "y" ] && [ "$confirm" != "Y" ]; then
|
docker exec -i "$APP_CONTAINER" python3 -c '
|
||||||
echo "Removal canceled."
|
import sys, os
|
||||||
exit 0
|
try:
|
||||||
fi
|
import pymongo
|
||||||
fi
|
tenant_id = sys.argv[1]
|
||||||
|
# In Docker Compose heißt der Host oft "mongodb" statt "localhost"
|
||||||
|
mongo_uri = os.environ.get("MONGO_URI", "mongodb://mongodb:27017/")
|
||||||
|
client = pymongo.MongoClient(mongo_uri, serverSelectionTimeoutMS=2000)
|
||||||
|
|
||||||
echo "Removing tenant '$TENANT_ID'..."
|
# Fix 2: Wir versuchen beide Formate zu löschen, da "list" zeigt, dass sie "test" und nicht "inventar_test" heißen.
|
||||||
APP_CONTAINER=$(docker ps -qf "name=app" | head -n 1)
|
dbs_to_drop = [f"inventar_{tenant_id}", tenant_id]
|
||||||
port_to_remove=""
|
existing_dbs = client.list_database_names()
|
||||||
|
|
||||||
if [ -n "$APP_CONTAINER" ]; then
|
for db_name in dbs_to_drop:
|
||||||
# MongoDB-Datenbank via PyMongo direkt im Container droppen
|
if db_name in existing_dbs:
|
||||||
docker exec -i "$APP_CONTAINER" python3 -c '
|
|
||||||
import sys, os
|
|
||||||
try:
|
|
||||||
import pymongo
|
|
||||||
tenant_id = sys.argv[1]
|
|
||||||
mongo_uri = os.environ.get("MONGO_URI", "mongodb://localhost:27017/")
|
|
||||||
client = pymongo.MongoClient(mongo_uri, serverSelectionTimeoutMS=2000)
|
|
||||||
db_name = f"inventar_{tenant_id}"
|
|
||||||
if db_name in client.list_database_names():
|
|
||||||
client.drop_database(db_name)
|
client.drop_database(db_name)
|
||||||
print(f"Dropped database: {db_name}")
|
print(f"Dropped database: {db_name}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Error dropping database: {e}", file=sys.stderr)
|
print(f"Error dropping database: {e}", file=sys.stderr)
|
||||||
' "$TENANT_ID" > /dev/null 2>&1
|
' "$TENANT_ID" > /dev/null 2>&1 || true
|
||||||
|
|
||||||
# Konfiguration und Port via Host-Funktion bereinigen und Port ermitteln
|
# Konfiguration und Port via Host-Funktion bereinigen und Port ermitteln
|
||||||
if port_to_remove="$(remove_tenant_port "$TENANT_ID" 2>/dev/null)"; then
|
if port_to_remove="$(remove_tenant_port "$TENANT_ID" 2>/dev/null)"; then
|
||||||
:
|
:
|
||||||
else
|
|
||||||
port_to_remove=""
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "Tenant '$TENANT_ID' database and config removed."
|
|
||||||
else
|
else
|
||||||
echo "Warning: Application container not running. Tenant database may still exist in MongoDB."
|
port_to_remove=""
|
||||||
if port_to_remove="$(remove_tenant_port "$TENANT_ID" 2>/dev/null)"; then
|
|
||||||
:
|
|
||||||
else
|
|
||||||
echo "Warning: tenant '$TENANT_ID' was not configured in config.json or could not be removed."
|
|
||||||
fi
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [ -n "$port_to_remove" ]; then
|
echo "Tenant '$TENANT_ID' database and config removed."
|
||||||
remove_runtime_port "$port_to_remove"
|
else
|
||||||
fi
|
echo "Warning: Application container not running. Tenant database may still exist in MongoDB."
|
||||||
remove_tenant_nginx_config "$TENANT_ID"
|
if port_to_remove="$(remove_tenant_port "$TENANT_ID" 2>/dev/null)"; then
|
||||||
sync_tenant_port_map
|
:
|
||||||
if [ -n "$(docker ps -qf 'name=app' | head -n 1)" ]; then
|
|
||||||
restart_app_container
|
|
||||||
fi
|
|
||||||
if [ -n "$port_to_remove" ]; then
|
|
||||||
echo "Removed tenant '$TENANT_ID' and cleaned runtime port $port_to_remove."
|
|
||||||
else
|
else
|
||||||
echo "Removed tenant '$TENANT_ID'. No port mapping was present."
|
echo "Warning: tenant '$TENANT_ID' was not configured in config.json or could not be removed."
|
||||||
fi
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -n "$port_to_remove" ]; then
|
||||||
|
remove_runtime_port "$port_to_remove" 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
remove_tenant_nginx_config "$TENANT_ID"
|
||||||
|
sync_tenant_port_map
|
||||||
|
|
||||||
|
if [ -n "$(docker ps -qf 'name=app' | head -n 1)" ]; then
|
||||||
|
restart_app_container || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -n "$port_to_remove" ]; then
|
||||||
|
echo "Removed tenant '$TENANT_ID' and cleaned runtime port $port_to_remove."
|
||||||
|
else
|
||||||
|
echo "Removed tenant '$TENANT_ID'. No port mapping was present."
|
||||||
|
fi
|
||||||
;;
|
;;
|
||||||
|
|
||||||
restart-tenant)
|
restart-tenant)
|
||||||
|
|||||||
Reference in New Issue
Block a user