Compare commits

...

9 Commits

12 changed files with 382 additions and 86 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
NUITKA_BUILD=0 NUITKA_BUILD=0
INVENTAR_HTTP_PORT=10000 INVENTAR_HTTP_PORT=10000
INVENTAR_HTTP_PORTS=10000 INVENTAR_HTTP_PORTS=10000
INVENTAR_APP_IMAGE=ghcr.io/aiirondev/legendary-octo-garbanzo:latest INVENTAR_APP_IMAGE=ghcr.io/aiirondev/legendary-octo-garbanzo:v0.7.42
INVENTAR_APP_CPUS=1.0 INVENTAR_APP_CPUS=1.0
INVENTAR_APP_MEM_LIMIT=384m INVENTAR_APP_MEM_LIMIT=384m
INVENTAR_APP_MEM_SWAP_LIMIT=768m INVENTAR_APP_MEM_SWAP_LIMIT=768m
+1
View File
@@ -0,0 +1 @@
v0.7.42
+54 -16
View File
@@ -25,9 +25,10 @@ Features:
- Booking and reservation of items - Booking and reservation of items
""" """
from flask import Flask, render_template, request, redirect, url_for, session, flash, send_from_directory, get_flashed_messages, jsonify, Response, make_response, send_file from flask import Flask, render_template, request, redirect, url_for, session, flash, send_from_directory, get_flashed_messages, jsonify, Response, make_response, send_file, abort
from werkzeug.utils import secure_filename from werkzeug.utils import secure_filename
from werkzeug.middleware.proxy_fix import ProxyFix from werkzeug.middleware.proxy_fix import ProxyFix
from jinja2 import TemplateNotFound
import user as us import user as us
import items as it import items as it
import ausleihung as au import ausleihung as au
@@ -353,7 +354,7 @@ def _enforce_active_session_user():
@app.before_request @app.before_request
def _enforce_module_access(): def _enforce_module_access():
endpoint = request.endpoint or '' endpoint = request.endpoint or ''
if endpoint == 'static' or endpoint.startswith('static') or request.path.startswith('/api/'): if endpoint == 'static' or endpoint.startswith('static'):
return None return None
for name, matcher in cfg.MODULES._path_matchers.items(): for name, matcher in cfg.MODULES._path_matchers.items():
@@ -363,7 +364,18 @@ def _enforce_module_access():
'inventory': "Inventar-Modul ist deaktiviert.", 'inventory': "Inventar-Modul ist deaktiviert.",
'student_cards': "Schülerausweis-Modul ist deaktiviert." 'student_cards': "Schülerausweis-Modul ist deaktiviert."
}.get(name, f"{name.capitalize()}-Modul ist deaktiviert.") }.get(name, f"{name.capitalize()}-Modul ist deaktiviert.")
return msg, 403
if request.path.startswith('/api/') or request.is_json:
return jsonify({'ok': False, 'message': msg}), 403
flash(msg, 'info')
if name != 'inventory' and cfg.MODULES.is_enabled('inventory'):
return redirect(url_for('home'))
elif name != 'library' and cfg.MODULES.is_enabled('library'):
return redirect(url_for('library_view'))
return redirect(url_for('my_borrowed_items'))
""" -----------------------------------------------------------After Request Handlers----------------------------------------------------------------------------- """ """ -----------------------------------------------------------After Request Handlers----------------------------------------------------------------------------- """
@@ -401,6 +413,26 @@ def _set_security_headers(response):
return response return response
@app.errorhandler(TemplateNotFound)
def handle_template_not_found(e):
"""Handle missing template files with a 404 response."""
app.logger.error(f"Template not found: {e.name}")
if request.is_json or request.path.startswith('/api/'):
return jsonify({'error': 'Template not found', 'status': 404}), 404
return abort(404)
@app.errorhandler(404)
def handle_not_found(e):
"""Handle 404 errors with a user-friendly response."""
app.logger.warning(f"404 Not Found: {request.path}")
if request.is_json or request.path.startswith('/api/'):
return jsonify({'error': 'Not found', 'status': 404}), 404
if 'username' in session:
return render_template('error.html', error_code=404, error_message='Seite nicht gefunden.'), 404
return redirect(url_for('login'))
def _csrf_error_response(message='CSRF token fehlt oder ist ungültig.'): def _csrf_error_response(message='CSRF token fehlt oder ist ungültig.'):
if request.is_json or request.path.startswith('/api/') or request.path in {'/download_book_cover', '/proxy_image', '/log_mobile_issue'}: if request.is_json or request.path.startswith('/api/') or request.path in {'/download_book_cover', '/proxy_image', '/log_mobile_issue'}:
return jsonify({'error': message}), 400 return jsonify({'error': message}), 400
@@ -2619,7 +2651,8 @@ def home():
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'),
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,
open_item=request.args.get('open_item')
) )
else: else:
permissions = _get_current_user_permissions() or us.build_default_permission_payload('standard_user') permissions = _get_current_user_permissions() or us.build_default_permission_payload('standard_user')
@@ -2662,6 +2695,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(),
open_item=request.args.get('open_item')
) )
@@ -4667,14 +4701,24 @@ def upload_item():
# Check if base code is unique for single-item uploads # Check if base code is unique for single-item uploads
if code_4 and item_count == 1 and not it.is_code_unique(code_4[0]): if code_4 and item_count == 1 and not it.is_code_unique(code_4[0]):
existing_item = it._get_items_collection().find_one({"code_4": code_4[0]})
if existing_item:
error_msg = 'Der Code wird bereits verwendet. Umleitung zum Eintrag.'
if is_mobile:
return jsonify({
'success': False,
'message': error_msg,
'existing_item_id': str(existing_item['_id']),
'redirect_to_item': True
}), 400
flash(error_msg, 'info')
return redirect(url_for(success_redirect_endpoint, open_item=str(existing_item['_id'])))
else:
error_msg = 'Der Code wird bereits verwendet. Bitte wählen Sie einen anderen Code.' error_msg = 'Der Code wird bereits verwendet. Bitte wählen Sie einen anderen Code.'
if is_mobile: if is_mobile:
return jsonify({'success': False, 'message': error_msg}), 400 return jsonify({'success': False, 'message': error_msg}), 400
else: else:
conflicting_items = it.get_item_by_code_4(code_4[0])
if conflicting_items:
flash('Der Code wird bereits verwendet. Das existierende Element wurde geöffnet.', 'info')
return redirect(url_for(success_redirect_endpoint, open_item_modal=str(conflicting_items[0]['_id'])))
flash(error_msg, 'error') flash(error_msg, 'error')
return redirect(url_for(success_redirect_endpoint)) return redirect(url_for(success_redirect_endpoint))
@@ -4699,10 +4743,6 @@ def upload_item():
error_msg = f'Der Einzelcode "{specific_code}" wird bereits verwendet.' error_msg = f'Der Einzelcode "{specific_code}" wird bereits verwendet.'
if is_mobile: if is_mobile:
return jsonify({'success': False, 'message': error_msg}), 400 return jsonify({'success': False, 'message': error_msg}), 400
conflicting_items = it.get_item_by_code_4(specific_code)
if conflicting_items:
flash(f'Der Einzelcode "{specific_code}" wird bereits verwendet. Das existierende Element wurde geöffnet.', 'info')
return redirect(url_for(success_redirect_endpoint, open_item_modal=str(conflicting_items[0]['_id'])))
flash(error_msg, 'error') flash(error_msg, 'error')
return redirect(url_for(success_redirect_endpoint)) return redirect(url_for(success_redirect_endpoint))
@@ -5834,10 +5874,6 @@ def edit_item(id):
# Check if code is unique (excluding the current item) # Check if code is unique (excluding the current item)
if code_4 and not it.is_code_unique(code_4, exclude_id=id): if code_4 and not it.is_code_unique(code_4, exclude_id=id):
conflicting_items = it.get_item_by_code_4(code_4)
if conflicting_items:
flash('Der Code wird bereits verwendet. Das existierende Element wurde stattdessen geöffnet.', 'info')
return redirect(url_for('home_admin', open_item_modal=str(conflicting_items[0]['_id'])))
flash('Der Code wird bereits verwendet. Bitte wählen Sie einen anderen Code.', 'error') flash('Der Code wird bereits verwendet. Bitte wählen Sie einen anderen Code.', 'error')
return redirect(url_for('home_admin')) return redirect(url_for('home_admin'))
@@ -8551,11 +8587,13 @@ def manage_filters():
# Get predefined filter values # Get predefined filter values
filter1_values = it.get_predefined_filter_values(1) filter1_values = it.get_predefined_filter_values(1)
filter2_values = it.get_predefined_filter_values(2) filter2_values = it.get_predefined_filter_values(2)
filter3_values = it.get_predefined_filter_values(3)
return render_template( return render_template(
'manage_filters.html', 'manage_filters.html',
filter1_values=filter1_values, filter1_values=filter1_values,
filter2_values=filter2_values, filter2_values=filter2_values,
filter3_values=filter3_values,
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')
) )
+12 -3
View File
@@ -570,7 +570,10 @@ def get_primary_filters():
items = db['items'] items = db['items']
filters = [f for f in items.distinct('Filter', _active_record_query(_non_library_query())) if f] filters = [f for f in items.distinct('Filter', _active_record_query(_non_library_query())) if f]
client.close() client.close()
return filters
# Add predefined values
predefined = get_predefined_filter_values(1)
return sorted(list(set(filters + predefined)))
except Exception as e: except Exception as e:
print(f"Error retrieving primary filters: {e}") print(f"Error retrieving primary filters: {e}")
return [] return []
@@ -589,7 +592,10 @@ def get_secondary_filters():
items = db['items'] items = db['items']
filters = [f for f in items.distinct('Filter2', _active_record_query(_non_library_query())) if f] filters = [f for f in items.distinct('Filter2', _active_record_query(_non_library_query())) if f]
client.close() client.close()
return filters
# Add predefined values
predefined = get_predefined_filter_values(2)
return sorted(list(set(filters + predefined)))
except Exception as e: except Exception as e:
print(f"Error retrieving secondary filters: {e}") print(f"Error retrieving secondary filters: {e}")
return [] return []
@@ -608,7 +614,10 @@ def get_tertiary_filters():
items = db['items'] items = db['items']
filters = [f for f in items.distinct('Filter3', _active_record_query(_non_library_query())) if f] filters = [f for f in items.distinct('Filter3', _active_record_query(_non_library_query())) if f]
client.close() client.close()
return filters
# Add predefined values
predefined = get_predefined_filter_values(3)
return sorted(list(set(filters + predefined)))
except Exception as e: except Exception as e:
print(f"Error retrieving tertiary filters: {e}") print(f"Error retrieving tertiary filters: {e}")
return [] return []
+75
View File
@@ -0,0 +1,75 @@
{% extends "base.html" %}
{% block title %}
{% if error_code == 404 %}
Seite nicht gefunden - Inventarsystem
{% else %}
Fehler - Inventarsystem
{% endif %}
{% endblock %}
{% block content %}
<div class="error-container" style="text-align: center; padding: 60px 20px; min-height: 70vh; display: flex; flex-direction: column; justify-content: center; align-items: center;">
<div style="max-width: 600px;">
<!-- Error Code Display -->
<div style="font-size: 120px; font-weight: bold; color: #ef4444; margin-bottom: 20px;">
{{ error_code }}
</div>
<!-- Error Title -->
<h1 style="font-size: 32px; margin: 20px 0; color: #1f2937;">
{% if error_code == 404 %}
Seite nicht gefunden
{% else %}
Ein Fehler ist aufgetreten
{% endif %}
</h1>
<!-- Error Message -->
<p style="font-size: 18px; color: #6b7280; margin: 20px 0 40px;">
{{ error_message or 'Es tut uns leid, aber die angeforderte Seite konnte nicht gefunden werden.' }}
</p>
<!-- Action Buttons -->
<div style="display: flex; gap: 15px; justify-content: center; flex-wrap: wrap;">
<a href="{{ url_for('home') }}" class="button" style="background-color: #3b82f6; color: white; padding: 12px 30px; text-decoration: none; border-radius: 5px; display: inline-block; font-weight: 500;">
Zur Startseite
</a>
<a href="javascript:history.back()" class="button" style="background-color: #6b7280; color: white; padding: 12px 30px; text-decoration: none; border-radius: 5px; display: inline-block; font-weight: 500;">
Zurück
</a>
</div>
<!-- Additional Help -->
<div style="margin-top: 60px; padding: 20px; background-color: #f3f4f6; border-radius: 8px;">
<p style="color: #6b7280; font-size: 14px;">
Wenn Sie denken, dass dies ein Fehler ist, oder Sie Hilfe benötigen,
<a href="{{ url_for('impressum') }}" style="color: #3b82f6; text-decoration: none;">kontaktieren Sie den Administrator</a>.
</p>
</div>
</div>
</div>
<style>
.error-container {
animation: fadeIn 0.3s ease-in;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.button:hover {
opacity: 0.9;
transform: translateY(-2px);
transition: all 0.2s ease;
}
</style>
{% endblock %}
+70 -6
View File
@@ -751,6 +751,7 @@
let mainItemsSentinel = null; let mainItemsSentinel = null;
let mainItemsLoadingIndicator = null; let mainItemsLoadingIndicator = null;
let mainItemsScrollPrefetchBound = false; let mainItemsScrollPrefetchBound = false;
let cardVirtualizationObserver = null;
function ensureMainItemsLoadingIndicator() { function ensureMainItemsLoadingIndicator() {
const itemsContainer = document.querySelector('#items-container'); const itemsContainer = document.querySelector('#items-container');
@@ -1189,6 +1190,55 @@
mainItemsScrollPrefetchBound = true; mainItemsScrollPrefetchBound = true;
} }
// Setup DOM virtualization for items
if (!cardVirtualizationObserver) {
cardVirtualizationObserver = new IntersectionObserver((entries) => {
entries.forEach(entry => {
const card = entry.target;
if (entry.isIntersecting) {
if (card.dataset.deloaded === 'true') {
card.innerHTML = card.dataset.savedHtml;
card.dataset.deloaded = 'false';
card.classList.remove('is-deloaded');
const favBtn = card.querySelector('.bookmark-btn');
if (favBtn) {
favBtn.addEventListener('click', (ev) => {
ev.stopPropagation();
toggleFavorite(card.dataset.itemId, favBtn, card);
});
}
const cardContent = card.querySelector('.card-content');
if (cardContent) {
cardContent.addEventListener('click', function(e) {
if (e.target.tagName === 'BUTTON' || e.target.tagName === 'A' || e.target.tagName === 'INPUT' ||
e.target.closest('button') || e.target.closest('a') || e.target.closest('.image-container')) return;
e.stopPropagation();
openItemQuick(card.dataset.itemId);
});
}
}
} else {
if (card.dataset.deloaded !== 'true') {
const rect = card.getBoundingClientRect();
if (rect.height > 50) {
card.style.minHeight = rect.height + 'px';
card.dataset.itemId = card.querySelector('.card-content')?.dataset.itemId || '';
card.dataset.savedHtml = card.innerHTML;
card.innerHTML = '';
card.dataset.deloaded = 'true';
card.classList.add('is-deloaded');
}
}
}
});
}, {
rootMargin: isMobileLayout ? '1500px 0px 1500px 0px' : '0px 2500px 0px 2500px'
});
}
// Observe all valid cards
document.querySelectorAll('.item-card').forEach(card => cardVirtualizationObserver.observe(card));
if (!mainItemsHasMore) return; if (!mainItemsHasMore) return;
mainItemsObserver = new IntersectionObserver(entries => { mainItemsObserver = new IntersectionObserver(entries => {
@@ -2419,29 +2469,29 @@
// Add these functions right after the loadFilterState() function // Add these functions right after the loadFilterState() function
function rebuildFilter3Options() { function rebuildFilter3Options() {
if (!allItems || allItems.length === 0) return; if (!allItems) return;
// Get current filter 1 and filter 2 selections // Get current filter 1 and filter 2 selections
const filter1Selections = activeFilters.filter1; const filter1Selections = activeFilters.filter1;
const filter2Selections = activeFilters.filter2; const filter2Selections = activeFilters.filter2;
// If both filters are empty, show all filter3 values // If both filters are empty, show all filter3 values from server
if (filter1Selections.length === 0 && filter2Selections.length === 0) { if (filter1Selections.length === 0 && filter2Selections.length === 0) {
// Get all unique filter3 values // Include dynamically added ones from loaded items too, just in case
const allFilter3Values = new Set(); const combinedFilter3Values = new Set(typeof allFilter3Values !== 'undefined' ? [...allFilter3Values] : []);
allItems.forEach(item => { allItems.forEach(item => {
const filter3Array = Array.isArray(item.Filter3) ? item.Filter3 : const filter3Array = Array.isArray(item.Filter3) ? item.Filter3 :
(item.Filter3 ? [item.Filter3] : []); (item.Filter3 ? [item.Filter3] : []);
filter3Array.forEach(value => { filter3Array.forEach(value => {
if (value && typeof value === 'string' && value.trim() !== '') { if (value && typeof value === 'string' && value.trim() !== '') {
allFilter3Values.add(value); combinedFilter3Values.add(value);
} }
}); });
}); });
// Populate filter 3 dropdown with all values // Populate filter 3 dropdown with all values
populateFilterOptions('filter3-options', [...allFilter3Values].sort(), 3); populateFilterOptions('filter3-options', [...combinedFilter3Values].sort(), 3);
return; return;
} }
@@ -3114,6 +3164,8 @@
cursor: pointer; cursor: pointer;
transition: transform 0.2s, box-shadow 0.2s; transition: transform 0.2s, box-shadow 0.2s;
position: relative; position: relative;
content-visibility: auto;
contain-intrinsic-size: 300px 450px;
} }
.item-card:hover { .item-card:hover {
transform: translateY(-5px); transform: translateY(-5px);
@@ -4614,6 +4666,18 @@ function openItemQuick(id){
} }
} }
</style> </style>
{% if open_item %}
<script>
document.addEventListener("DOMContentLoaded", function() {
if (typeof openEditModalFromServer === 'function') {
setTimeout(function() {
openEditModalFromServer('{{ open_item }}');
}, 500);
}
});
</script>
{% endif %}
{% endblock %} {% endblock %}
<style> <style>
/* Admin/Main Content Container align */ /* Admin/Main Content Container align */
+71 -13
View File
@@ -403,6 +403,8 @@
cursor: pointer; cursor: pointer;
transition: transform 0.2s, box-shadow 0.2s; transition: transform 0.2s, box-shadow 0.2s;
position: relative; position: relative;
content-visibility: auto;
contain-intrinsic-size: 300px 450px;
} }
.item-card.bulk-selected { .item-card.bulk-selected {
@@ -2876,30 +2878,29 @@ document.addEventListener('DOMContentLoaded', ()=>{
} }
function rebuildFilter3Options() { function rebuildFilter3Options() {
if (!allItems || allItems.length === 0) return; if (!allItems) return;
// Get current filter 1 and filter 2 selections // Get current filter 1 and filter 2 selections
const filter1Selections = activeFilters.filter1; const filter1Selections = activeFilters.filter1;
const filter2Selections = activeFilters.filter2; const filter2Selections = activeFilters.filter2;
// If both filters are empty, show all filter3 values // If both filters are empty, show all filter3 values from server
if (filter1Selections.length === 0 && filter2Selections.length === 0) { if (filter1Selections.length === 0 && filter2Selections.length === 0) {
// Get all unique filter3 values // Include dynamically added ones from loaded items too, just in case
const allFilter3Values = new Set(); const combinedFilter3Values = new Set(typeof allFilter3Values !== 'undefined' ? [...allFilter3Values] : []);
allItems.forEach(item => { allItems.forEach(item => {
const filter3Array = Array.isArray(item.Filter3) ? item.Filter3 : const filter3Array = Array.isArray(item.Filter3) ? item.Filter3 :
(item.Filter3 ? [item.Filter3] : []); (item.Filter3 ? [item.Filter3] : []);
filter3Array.forEach(value => { filter3Array.forEach(value => {
if (value && typeof value === 'string' && value.trim() !== '') { if (value && typeof value === 'string' && value.trim() !== '') {
allFilter3Values.add(value); combinedFilter3Values.add(value);
} }
}); });
}); });
// Populate filter 3 dropdown with all values // Populate filter 3 dropdown with all values
populateFilterOptions('filter3-options', [...allFilter3Values].sort(), 3); populateFilterOptions('filter3-options', [...combinedFilter3Values].sort(), 3);
return;
} }
// Filter items based on filter1 and filter2 selections // Filter items based on filter1 and filter2 selections
@@ -3252,12 +3253,6 @@ document.addEventListener('DOMContentLoaded', ()=>{
} }
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {
const urlParams = new URLSearchParams(window.location.search);
const openItem = urlParams.get('open_item_modal');
if (openItem) {
openEditModalFromServer(openItem);
}
// Load saved filters // Load saved filters
loadFilterState(); loadFilterState();
@@ -3362,6 +3357,7 @@ document.addEventListener('DOMContentLoaded', ()=>{
let mainAdminItemsLoadingIndicator = null; let mainAdminItemsLoadingIndicator = null;
let mainAdminItemsScrollPrefetchBound = false; let mainAdminItemsScrollPrefetchBound = false;
let totalItemsInSystem = 0; let totalItemsInSystem = 0;
let cardVirtualizationObserver = null;
function ensureMainAdminItemsLoadingIndicator() { function ensureMainAdminItemsLoadingIndicator() {
const itemsContainer = document.querySelector('#items-container'); const itemsContainer = document.querySelector('#items-container');
@@ -3789,6 +3785,56 @@ document.addEventListener('DOMContentLoaded', ()=>{
mainAdminItemsScrollPrefetchBound = true; mainAdminItemsScrollPrefetchBound = true;
} }
// Setup DOM virtualization for items
if (!cardVirtualizationObserver) {
cardVirtualizationObserver = new IntersectionObserver((entries) => {
entries.forEach(entry => {
const card = entry.target;
if (entry.isIntersecting) {
if (card.dataset.deloaded === 'true') {
card.innerHTML = card.dataset.savedHtml;
card.dataset.deloaded = 'false';
card.classList.remove('is-deloaded');
const favBtn = card.querySelector('.bookmark-btn');
if (favBtn) {
favBtn.addEventListener('click', (ev) => {
ev.stopPropagation();
toggleFavorite(card.dataset.itemId, favBtn, card);
});
}
const cardContent = card.querySelector('.card-content');
if (cardContent) {
cardContent.addEventListener('click', function(e) {
if (e.target.tagName === 'BUTTON' || e.target.tagName === 'A' || e.target.tagName === 'INPUT' ||
e.target.closest('button') || e.target.closest('a') || e.target.closest('.image-container') ||
e.target.closest('.bulk-delete-toggle') || e.target.closest('label')) return;
e.stopPropagation();
openItemQuick(card.dataset.itemId);
});
}
}
} else {
if (card.dataset.deloaded !== 'true') {
const rect = card.getBoundingClientRect();
if (rect.height > 50) {
card.style.minHeight = rect.height + 'px';
card.dataset.itemId = card.querySelector('.card-content')?.dataset.itemId || '';
card.dataset.savedHtml = card.innerHTML;
card.innerHTML = '';
card.dataset.deloaded = 'true';
card.classList.add('is-deloaded');
}
}
}
});
}, {
rootMargin: isMobileLayout ? '1500px 0px 1500px 0px' : '0px 2500px 0px 2500px'
});
}
// Observe all valid cards
document.querySelectorAll('.item-card').forEach(card => cardVirtualizationObserver.observe(card));
if (!mainAdminItemsHasMore) return; if (!mainAdminItemsHasMore) return;
mainAdminItemsObserver = new IntersectionObserver(entries => { mainAdminItemsObserver = new IntersectionObserver(entries => {
@@ -5722,4 +5768,16 @@ document.addEventListener('DOMContentLoaded', ()=>{
} }
} }
</style> </style>
{% if open_item %}
<script>
document.addEventListener("DOMContentLoaded", function() {
if (typeof openEditModalFromServer === 'function') {
setTimeout(function() {
openEditModalFromServer('{{ open_item }}');
}, 500);
}
});
</script>
{% endif %}
{% endblock %} {% endblock %}
+44 -8
View File
@@ -15,16 +15,16 @@
<h1 class="mb-4">Filterwerte verwalten</h1> <h1 class="mb-4">Filterwerte verwalten</h1>
<div class="row"> <div class="row">
<!-- Filter 1: Unterrichtsfach --> <!-- Filter 1 -->
<div class="col-md-6"> <div class="col-md-4">
<div class="card mb-4"> <div class="card mb-4">
<div class="card-header"> <div class="card-header">
<h2 class="card-title h5 mb-0">Unterrichtsfach (Filter 1)</h2> <h2 class="card-title h5 mb-0">Fach/Kategorie (Filter 1)</h2>
</div> </div>
<div class="card-body"> <div class="card-body">
<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">
<div class="input-group"> <div class="input-group">
<input type="text" name="value" class="form-control" placeholder="Neues Unterrichtsfach..." required> <input type="text" name="value" class="form-control" placeholder="Neuer Wert..." required>
<button type="submit" class="btn btn-primary">Hinzufügen</button> <button type="submit" class="btn btn-primary">Hinzufügen</button>
</div> </div>
</form> </form>
@@ -51,16 +51,16 @@
</div> </div>
</div> </div>
<!-- Filter 2: Jahrgangsstufe --> <!-- Filter 2 -->
<div class="col-md-6"> <div class="col-md-4">
<div class="card mb-4"> <div class="card mb-4">
<div class="card-header"> <div class="card-header">
<h2 class="card-title h5 mb-0">Jahrgangsstufe (Filter 2)</h2> <h2 class="card-title h5 mb-0">System/Bereich (Filter 2)</h2>
</div> </div>
<div class="card-body"> <div class="card-body">
<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">
<div class="input-group"> <div class="input-group">
<input type="text" name="value" class="form-control" placeholder="Neue Jahrgangsstufe..." required> <input type="text" name="value" class="form-control" placeholder="Neuer Wert..." required>
<button type="submit" class="btn btn-primary">Hinzufügen</button> <button type="submit" class="btn btn-primary">Hinzufügen</button>
</div> </div>
</form> </form>
@@ -86,6 +86,42 @@
</div> </div>
</div> </div>
</div> </div>
<!-- Filter 3 -->
<div class="col-md-4">
<div class="card mb-4">
<div class="card-header">
<h2 class="card-title h5 mb-0">Typ/Art (Filter 3)</h2>
</div>
<div class="card-body">
<form method="POST" action="{{ url_for('add_filter_value', filter_num=3) }}" class="mb-4">
<div class="input-group">
<input type="text" name="value" class="form-control" placeholder="Neuer Wert..." required>
<button type="submit" class="btn btn-primary">Hinzufügen</button>
</div>
</form>
<h5 class="mb-3">Vorhandene Werte</h5>
{% if filter3_values %}
<div class="list-group">
{% for value in filter3_values %}
<div class="list-group-item d-flex justify-content-between align-items-center">
{{ value }}
<form method="POST" action="{{ url_for('remove_filter_value', filter_num=3, value=value) }}" class="d-inline">
<button type="submit" class="btn btn-sm btn-danger"
onclick="return confirm('Sind Sie sicher, dass Sie den Wert \"' + '{{ value }}'.replace(/'/g, '\\\'') + '\" löschen möchten?');">
Entfernen
</button>
</form>
</div>
{% endfor %}
</div>
{% else %}
<div class="alert alert-info">Keine Werte definiert.</div>
{% endif %}
</div>
</div>
</div>
</div> </div>
<div class="alert alert-warning"> <div class="alert alert-warning">
-1
View File
@@ -709,7 +709,6 @@
</style> </style>
<div class="upload-container"> <div class="upload-container">
<a href="{{ url_for(back_target|default('home_admin')) }}" class="nav-back-button">← Zurück zur Artikelübersicht</a>
{% if show_library_features %} {% if show_library_features %}
<div style="border:1px solid #dbe4ee; border-radius:8px; padding:14px; margin-bottom:16px; background:#f8fbff;"> <div style="border:1px solid #dbe4ee; border-radius:8px; padding:14px; margin-bottom:16px; background:#f8fbff;">
+1 -7
View File
@@ -498,8 +498,6 @@ def check_nm_pwd(username, password):
finally: finally:
client.close() client.close()
return None
def add_user( def add_user(
username, username,
@@ -536,16 +534,12 @@ def add_user(
for key, value in page_permissions.items(): for key, value in page_permissions.items():
permission_defaults['pages'][str(key)] = bool(value) permission_defaults['pages'][str(key)] = bool(value)
alias_first = name if str(name or '').strip() else username
alias_last = last_name if str(last_name or '').strip() else ''
name_alias = build_name_synonym(alias_first, alias_last)
user_doc = { user_doc = {
'Username': username, 'Username': username,
'Password': hashing(password), 'Password': hashing(password),
'Admin': False, 'Admin': False,
'active_ausleihung': None, 'active_ausleihung': None,
'name': name_alias, 'name': name.strip() if name else '',
'last_name': last_name.strip() if last_name else '', 'last_name': last_name.strip() if last_name else '',
'IsStudent': bool(is_student), 'IsStudent': bool(is_student),
'PermissionPreset': permission_defaults['preset'], 'PermissionPreset': permission_defaults['preset'],
Regular → Executable
View File
+47 -25
View File
@@ -1,41 +1,63 @@
version: "3.8"
services: services:
app: app:
build: . image: ghcr.io/aiirondev/legendary-octo-garbanzo:v0.7.39
container_name: inventory-app container_name: inventarsystem-app
restart: unless-stopped restart: unless-stopped
environment: ports:
- MONGO_URL=mongodb://mongodb:27017/inventar - "10000:8000"
- REDIS_URL=redis://redis:6379
- BASE_URL=https://inventar.maximiliangruendinger.de #alle öffentliche subdomains
depends_on: depends_on:
- mongodb - mongodb
- redis - redis
environment:
INVENTAR_MONGODB_HOST: mongodb
INVENTAR_MONGODB_PORT: "27017"
INVENTAR_MONGODB_DB: Inventarsystem
INVENTAR_BACKUP_FOLDER: /data/backups
INVENTAR_LOGS_FOLDER: /data/logs
expose:
- "8000"
volumes:
- ./config.json:/app/config.json:ro
- app_uploads:/app/Web/uploads
- app_thumbnails:/app/Web/thumbnails
- app_previews:/app/Web/previews
- app_qrcodes:/app/Web/QRCodes
- app_backups:/data/backups
- app_logs:/data/logs
mongodb: mongodb:
image: mongo:latest image: mongo:7.0
container_name: mongodb container_name: inventarsystem-mongodb
restart: unless-stopped restart: unless-stopped
volumes: volumes:
- mongo_data:/data/db - mongodb_data:/data/db
healthcheck:
test: ["CMD", "mongosh", "--quiet", "--eval", "db.adminCommand('ping').ok"]
interval: 10s
timeout: 5s
retries: 10
redis: redis:
image: redis:alpine image: redis:7-alpine
container_name: redis container_name: inventarsystem-redis
restart: unless-stopped restart: unless-stopped
command: redis-server --appendonly yes --maxmemory 512mb --maxmemory-policy allkeys-lru
cloudflared: ports:
image: cloudflare/cloudflared:latest - "6379:6379"
container_name: cloudflared
restart: unless-stopped
# Der Tunnel-Name 'homeserver' muss zu deiner credentials.json passen
command: tunnel run homeserver
volumes: volumes:
- ./config.yml:/etc/cloudflared/config.yml - redis_data:/data
- ./credentials.json:/etc/cloudflared/credentials.json healthcheck:
depends_on: test: ["CMD", "redis-cli", "ping"]
- app interval: 10s
timeout: 5s
retries: 5
volumes: volumes:
mongo_data: mongodb_data:
app_uploads:
app_thumbnails:
app_previews:
app_qrcodes:
app_backups:
app_logs:
redis_data: