Compare commits

...

9 Commits

12 changed files with 781 additions and 54 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
NUITKA_BUILD=0
INVENTAR_HTTP_PORT=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_MEM_LIMIT=384m
INVENTAR_APP_MEM_SWAP_LIMIT=768m
+1
View File
@@ -0,0 +1 @@
v0.7.42
+627 -19
View File
@@ -25,9 +25,10 @@ Features:
- 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.middleware.proxy_fix import ProxyFix
from jinja2 import TemplateNotFound
import user as us
import items as it
import ausleihung as au
@@ -353,7 +354,7 @@ def _enforce_active_session_user():
@app.before_request
def _enforce_module_access():
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
for name, matcher in cfg.MODULES._path_matchers.items():
@@ -363,7 +364,18 @@ def _enforce_module_access():
'inventory': "Inventar-Modul ist deaktiviert.",
'student_cards': "Schülerausweis-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----------------------------------------------------------------------------- """
@@ -401,6 +413,26 @@ def _set_security_headers(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.'):
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
@@ -2619,7 +2651,8 @@ def home():
library_module_enabled=cfg.MODULES.is_enabled('library'),
student_cards_module_enabled=cfg.MODULES.is_enabled('student_cards'),
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:
permissions = _get_current_user_permissions() or us.build_default_permission_payload('standard_user')
@@ -2637,25 +2670,23 @@ def home_admin():
"""
Admin homepage route.
Only accessible by users with admin privileges.
Returns:
flask.Response: Rendered template or redirect
"""
if 'username' not in session:
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'))
inv = cfg.MODULES.is_enabled('inventory')
lib = cfg.MODULES.is_enabled('library')
if not inv and not lib:
return "Weder Inventar- noch Bibliotheks-Modul sind aktiviert.", 403
elif not inv and lib:
return redirect(url_for('library_admin'))
elif not lib and inv:
pass # render_template main_admin.html
if not cfg.MODULES.is_enabled('inventory'):
if cfg.MODULES.is_enabled('library'):
return redirect(url_for('library_admin'))
else:
return "Weder Inventar- noch Bibliotheks-Modul sind aktiviert.", 403
if not us.check_admin(session['username']):
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 render_template(
'main_admin.html',
username=session['username'],
@@ -2664,9 +2695,572 @@ def home_admin():
student_default_borrow_days=cfg.STUDENT_DEFAULT_BORROW_DAYS,
student_max_borrow_days=cfg.STUDENT_MAX_BORROW_DAYS,
school_info=_get_school_info_for_export(),
open_item=request.args.get('open_item')
)
@app.route('/tutorial')
def tutorial_page():
"""Guided onboarding page (2-5 minutes) for first-time users."""
if 'username' not in session:
flash('Bitte mit registriertem Konto anmelden!', 'error')
return redirect(url_for('login'))
return render_template(
'tutorial.html',
username=session['username'],
is_admin=us.check_admin(session['username']),
library_module_enabled=cfg.MODULES.is_enabled('library'),
student_cards_module_enabled=cfg.MODULES.is_enabled('student_cards'),
student_default_borrow_days=cfg.STUDENT_DEFAULT_BORROW_DAYS,
student_max_borrow_days=cfg.STUDENT_MAX_BORROW_DAYS
)
@app.route('/library')
def library_view():
"""
Dedicated page for viewing library items (books, CDs, etc.).
Only available when library module is enabled.
Table-only view with customizable filter.
Returns:
flask.Response: Rendered template or redirect
"""
if 'username' not in session:
flash('Bitte mit registriertem Konto anmelden!', 'error')
return redirect(url_for('login'))
if not cfg.MODULES.is_enabled('library'):
flash('Bibliotheks-Modul ist deaktiviert.', 'error')
return redirect(url_for('home'))
return render_template(
'library_table.html',
username=session['username'],
library_module_enabled=cfg.MODULES.is_enabled('library'),
is_admin=us.check_admin(session['username']),
student_cards_module_enabled=cfg.MODULES.is_enabled('student_cards'),
student_default_borrow_days=cfg.STUDENT_DEFAULT_BORROW_DAYS,
student_max_borrow_days=cfg.STUDENT_MAX_BORROW_DAYS
)
@app.route('/library_loans_admin')
def library_loans_admin():
"""Admin overview for library borrowings and damaged library items."""
if 'username' not in session:
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'))
if not us.check_admin(session['username']):
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'))
if not cfg.MODULES.is_enabled('library'):
flash('Bibliotheks-Modul ist deaktiviert.', 'error')
return redirect(url_for('home_admin'))
_ensure_audit_indexes_once()
def fmt_dt(dt):
try:
return dt.strftime('%d.%m.%Y %H:%M') if dt else ''
except Exception:
return str(dt) if dt else ''
def fmt_money(value):
return _format_money_value(value)
client = None
try:
client = MongoClient(MONGODB_HOST, MONGODB_PORT)
db = client[MONGODB_DB]
items_col = db['items']
ausleihungen_col = db['ausleihungen']
library_items = list(items_col.find(
{'ItemType': {'$in': LIBRARY_ITEM_TYPES}, 'Deleted': {'$ne': True}},
{'Name': 1, 'Code_4': 1, 'Anschaffungskosten': 1, 'Condition': 1, 'HasDamage': 1, 'DamageReports': 1, 'Verfuegbar': 1, 'User': 1, 'ItemType': 1, 'Author': 1, 'ISBN': 1}
))
item_map = {str(item['_id']): item for item in library_items if item.get('_id')}
item_ids = list(item_map.keys())
active_records = []
if item_ids:
active_records = list(ausleihungen_col.find(
{'Item': {'$in': item_ids}, 'Status': {'$in': ['active', 'planned', 'completed']}},
{'User': 1, 'Item': 1, 'Status': 1, 'Start': 1, 'End': 1, 'Period': 1, 'Notes': 1, 'InvoiceData': 1}
).sort('Start', -1))
active_item_ids = set()
loan_entries = []
for record in active_records:
item_id = str(record.get('Item') or '')
item_doc = item_map.get(item_id)
if item_id and record.get('Status') == 'active':
active_item_ids.add(item_id)
if not item_doc:
continue
invoice_data = record.get('InvoiceData') or {}
condition_value = str(item_doc.get('Condition', '')).strip().lower()
item_has_damage = bool(item_doc.get('HasDamage')) or condition_value == 'destroyed' or bool(item_doc.get('DamageReports'))
damage_reports = item_doc.get('DamageReports', []) or []
loan_entries.append({
'id': str(record.get('_id')),
'item_id': item_id,
'item_name': item_doc.get('Name', ''),
'item_code': item_doc.get('Code_4', ''),
'item_author': item_doc.get('Author', ''),
'item_isbn': item_doc.get('ISBN', ''),
'user': record.get('User', ''),
'status': record.get('Status', ''),
'start': fmt_dt(record.get('Start')),
'end': fmt_dt(record.get('End')),
'period': record.get('Period', ''),
'notes': record.get('Notes', ''),
'invoice_number': invoice_data.get('invoice_number', ''),
'invoice_amount': fmt_money(invoice_data.get('amount')) if invoice_data.get('amount') is not None else fmt_money(item_doc.get('Anschaffungskosten')),
'invoice_paid': bool(invoice_data.get('paid', False)),
'invoice_paid_at': fmt_dt(invoice_data.get('paid_at')) if isinstance(invoice_data.get('paid_at'), datetime.datetime) else '',
'invoice_corrections_count': len(record.get('InvoiceCorrections', []) or []),
'has_damage': item_has_damage,
'damage_count': len(damage_reports),
'damage_text': (damage_reports[0].get('description', '') if damage_reports else ''),
})
damaged_items = []
for item_doc in library_items:
item_id = str(item_doc.get('_id') or '')
condition_value = str(item_doc.get('Condition', '')).strip().lower()
damage_reports = item_doc.get('DamageReports', []) or []
item_has_damage = bool(item_doc.get('HasDamage')) or condition_value == 'destroyed' or bool(damage_reports)
if not item_has_damage or item_id in active_item_ids:
continue
damaged_items.append({
'id': item_id,
'name': item_doc.get('Name', ''),
'code': item_doc.get('Code_4', ''),
'author': item_doc.get('Author', ''),
'isbn': item_doc.get('ISBN', ''),
'condition': item_doc.get('Condition', ''),
'damage_count': len(damage_reports),
'damage_text': (damage_reports[0].get('description', '') if damage_reports else ''),
'available': bool(item_doc.get('Verfuegbar', False)),
'last_updated': fmt_dt(item_doc.get('LastUpdated')),
})
return render_template(
'library_borrowings_admin.html',
loan_entries=loan_entries,
damaged_items=damaged_items,
library_module_enabled=cfg.MODULES.is_enabled('library'),
student_cards_module_enabled=cfg.MODULES.is_enabled('student_cards'),
)
except Exception as e:
app.logger.error(f"Error loading library loans admin view: {e}")
flash('Fehler beim Laden der Bibliotheksverwaltung.', 'error')
return redirect(url_for('home_admin'))
finally:
if client:
client.close()
@app.route('/api/library_items')
def api_library_items():
"""
API endpoint to fetch library items (books, CDs, DVDs, media).
Supports pagination via query params: offset, limit.
"""
if 'username' not in session:
return jsonify({'items': []}), 401
offset_raw = request.args.get('offset', '0')
limit_raw = request.args.get('limit', '120')
try:
offset = max(0, int(offset_raw))
except (TypeError, ValueError):
offset = 0
try:
limit = int(limit_raw)
except (TypeError, ValueError):
limit = 120
limit = min(max(limit, 1), 500)
try:
client = MongoClient(MONGODB_HOST, MONGODB_PORT)
db = client[MONGODB_DB]
items_db = db['items']
ausleihungen_db = db['ausleihungen']
query = {
'ItemType': {'$in': ['book', 'cd', 'dvd', 'media']},
'Deleted': {'$ne': True}
}
projection = {
'Name': 1,
'Autor': 1,
'Author': 1,
'ISBN': 1,
'Code_4': 1,
'Code4': 1,
'ItemType': 1,
'Verfuegbar': 1,
'Condition': 1,
'HasDamage': 1,
'User': 1,
'Ort': 1,
'Beschreibung': 1,
'Image': 1
}
total_count = items_db.count_documents(query)
library_items = list(
items_db.find(query, projection)
.sort([('Name', 1), ('_id', 1)])
.skip(offset)
.limit(limit)
)
item_ids = [str(item.get('_id')) for item in library_items if item.get('_id')]
active_records = []
if item_ids:
active_records = list(ausleihungen_db.find(
{'Item': {'$in': item_ids}, 'Status': 'active'},
{'Item': 1, 'User': 1}
))
active_item_ids = set()
active_user_by_item = {}
for rec in active_records:
item_id = str(rec.get('Item') or '')
if not item_id:
continue
active_item_ids.add(item_id)
if item_id not in active_user_by_item:
active_user_by_item[item_id] = rec.get('User', '')
client.close()
# Convert ObjectId to string for JSON serialization
for item in library_items:
item_id = str(item['_id'])
item['_id'] = item_id
if item.get('Code4') in (None, '') and item.get('Code_4') not in (None, ''):
item['Code4'] = item.get('Code_4')
condition_value = str(item.get('Condition', '')).strip().lower()
has_damage = bool(item.get('HasDamage')) or condition_value == 'destroyed'
has_active_borrow = item_id in active_item_ids
if has_damage and not has_active_borrow:
item['LibraryDisplayStatus'] = 'damaged'
elif has_active_borrow or item.get('Verfuegbar') is False:
item['LibraryDisplayStatus'] = 'borrowed'
else:
item['LibraryDisplayStatus'] = 'available'
item['BorrowedBy'] = active_user_by_item.get(item_id) or item.get('User', '')
count = len(library_items)
return jsonify({
'items': library_items,
'offset': offset,
'limit': limit,
'count': count,
'total': total_count,
'has_more': (offset + count) < total_count
}), 200
except Exception as e:
app.logger.error(f"Error fetching library items: {e}")
return jsonify({'error': str(e)}), 500
@app.route('/api/library_scan_action', methods=['POST'])
def api_library_scan_action():
"""
Scan-based library workflow:
- scan student card id
- scan media code (ISBN/Code_4)
- borrow if available, otherwise return (toggle behavior)
"""
if 'username' not in session:
return jsonify({'ok': False, 'message': 'Nicht angemeldet.'}), 401
if not cfg.MODULES.is_enabled('library'):
return jsonify({'ok': False, 'message': 'Bibliotheks-Modul ist deaktiviert.'}), 403
if not cfg.MODULES.is_enabled('student_cards'):
return jsonify({'ok': False, 'message': 'Schülerausweis-Modul ist deaktiviert.'}), 403
payload = request.get_json(silent=True) or {}
student_card_id = us.normalize_student_card_id(payload.get('student_card_id') or payload.get('card_id'))
item_code_raw = str(payload.get('item_code') or payload.get('code') or '').strip()
duration_raw = str(payload.get('borrow_duration_days') or '').strip()
if not student_card_id:
return jsonify({'ok': False, 'message': 'Schülerausweis-ID fehlt.'}), 400
if not item_code_raw:
return jsonify({'ok': False, 'message': 'Mediencode fehlt.'}), 400
normalized_isbn = normalize_and_validate_isbn(item_code_raw)
normalized_code = item_code_raw.upper()
client = None
try:
client = MongoClient(MONGODB_HOST, MONGODB_PORT)
db = client[MONGODB_DB]
student_cards_col = db['student_cards']
items_col = db['items']
ausleihungen_col = db['ausleihungen']
card_doc = student_cards_col.find_one({'AusweisId': student_card_id})
if not card_doc:
return jsonify({'ok': False, 'message': 'Ungültige Schülerausweis-ID.'}), 404
card_doc = _decrypt_student_card_doc(card_doc)
query_or = [
{'Code_4': item_code_raw},
{'Code_4': normalized_code},
]
if normalized_isbn:
query_or.append({'ISBN': normalized_isbn})
item_doc = items_col.find_one({
'ItemType': {'$in': LIBRARY_ITEM_TYPES},
'$or': query_or
})
if not item_doc:
return jsonify({'ok': False, 'message': 'Kein Bibliotheksmedium für diesen Code gefunden.'}), 404
item_id = str(item_doc['_id'])
borrower_name = card_doc.get('SchülerName') or f"Ausweis {student_card_id}"
now = datetime.datetime.now()
if item_doc.get('Verfuegbar', True):
borrow_duration_days = None
if duration_raw:
try:
parsed_duration = int(duration_raw)
if 1 <= parsed_duration <= cfg.STUDENT_MAX_BORROW_DAYS:
borrow_duration_days = parsed_duration
else:
return jsonify({
'ok': False,
'message': f'Ausleihdauer muss zwischen 1 und {cfg.STUDENT_MAX_BORROW_DAYS} Tagen liegen.'
}), 400
except ValueError:
return jsonify({'ok': False, 'message': 'Ungültige Ausleihdauer.'}), 400
else:
try:
card_default = int(card_doc.get('StandardAusleihdauer', cfg.STUDENT_DEFAULT_BORROW_DAYS))
except (TypeError, ValueError):
card_default = cfg.STUDENT_DEFAULT_BORROW_DAYS
borrow_duration_days = max(1, min(card_default, cfg.STUDENT_MAX_BORROW_DAYS))
end_date = now + datetime.timedelta(days=borrow_duration_days) if borrow_duration_days else None
it.update_item_status(item_id, False, borrower_name)
au.add_ausleihung(item_id, borrower_name, now, end_date=end_date)
_append_audit_event_standalone(
event_type='ausleihung_borrowed',
payload={
'channel': 'library_scan',
'item_id': item_id,
'item_name': item_doc.get('Name', ''),
'borrower': borrower_name,
'student_card_id': student_card_id,
'borrow_duration_days': borrow_duration_days,
}
)
return jsonify({
'ok': True,
'action': 'borrowed',
'item_id': item_id,
'item_name': item_doc.get('Name', ''),
'student_card_id': student_card_id,
'borrower': borrower_name,
'borrow_duration_days': borrow_duration_days,
'message': f"{item_doc.get('Name', 'Medium')} wurde ausgeliehen."
}), 200
# Toggle back: item is currently borrowed -> return
current_borrower = str(item_doc.get('User') or '').strip()
if current_borrower and current_borrower != borrower_name and not us.check_admin(session['username']):
return jsonify({
'ok': False,
'message': f"Medium ist aktuell an '{current_borrower}' ausgeliehen und kann mit diesem Ausweis nicht zurückgegeben werden."
}), 409
update_result = ausleihungen_col.update_many(
{'Item': item_id, 'Status': 'active'},
{'$set': {
'Status': 'completed',
'End': now,
'LastUpdated': now
}}
)
it.update_item_status(item_id, True, borrower_name)
_append_audit_event_standalone(
event_type='ausleihung_returned',
payload={
'channel': 'library_scan',
'item_id': item_id,
'item_name': item_doc.get('Name', ''),
'borrower': borrower_name,
'student_card_id': student_card_id,
'completed_records': update_result.modified_count,
}
)
return jsonify({
'ok': True,
'action': 'returned',
'item_id': item_id,
'item_name': item_doc.get('Name', ''),
'student_card_id': student_card_id,
'borrower': borrower_name,
'completed_records': update_result.modified_count,
'message': f"{item_doc.get('Name', 'Medium')} wurde zurückgegeben."
}), 200
except Exception as e:
app.logger.error(f"Error in library scan action: {e}")
return jsonify({'ok': False, 'message': 'Fehler beim Verarbeiten des Scan-Vorgangs.'}), 500
finally:
if client:
client.close()
@app.route('/api/item_detail/<item_id>')
def api_item_detail(item_id):
"""
API endpoint to fetch detail view HTML for a library item.
"""
if 'username' not in session:
return jsonify({'error': 'Unauthorized'}), 401
try:
item = it.get_item(item_id)
if not item:
return jsonify({'error': 'Item not found'}), 404
client = MongoClient(MONGODB_HOST, MONGODB_PORT)
db = client[MONGODB_DB]
ausleihungen_col = db['ausleihungen']
active_borrow = ausleihungen_col.find_one(
{'Item': str(item.get('_id')), 'Status': 'active'},
{'User': 1}
)
client.close()
condition_value = str(item.get('Condition', '')).strip().lower()
has_damage = bool(item.get('HasDamage')) or condition_value == 'destroyed'
if has_damage and not active_borrow:
status_label = 'Defekt/Zerstört'
elif item.get('Verfuegbar') is False or active_borrow:
status_label = 'Ausgeliehen'
else:
status_label = 'Verfügbar'
borrower_value = ''
if active_borrow:
borrower_value = active_borrow.get('User', '')
elif item.get('User'):
borrower_value = item.get('User')
# Basic detail HTML
detail_html = f"""
<h2>{html.escape(item.get('Name', 'Untitled'))}</h2>
<p><strong>Autor/Künstler:</strong> {html.escape(item.get('Autor', item.get('Author', '-')))}</p>
<p><strong>ISBN:</strong> {html.escape(item.get('ISBN', item.get('Code4', '-')))}</p>
<p><strong>Beschreibung:</strong> {html.escape(item.get('Beschreibung', '-'))}</p>
<p><strong>Status:</strong> {html.escape(status_label)}</p>
{f'<p><strong>Ausgeliehen von:</strong> {html.escape(str(borrower_value))}</p>' if borrower_value and status_label == 'Ausgeliehen' else ''}
"""
return detail_html, 200
except Exception as e:
app.logger.error(f"Error fetching item detail: {e}")
return jsonify({'error': str(e)}), 500
@app.route('/api/library_item/<item_id>/update', methods=['POST'])
def api_library_item_update(item_id):
"""Admin-only API to edit library item core fields from the library table view."""
if 'username' not in session:
return jsonify({'ok': False, 'message': 'Nicht angemeldet.'}), 401
if not us.check_admin(session['username']):
return jsonify({'ok': False, 'message': 'Administratorrechte erforderlich.'}), 403
if not cfg.MODULES.is_enabled('library'):
return jsonify({'ok': False, 'message': 'Bibliotheks-Modul ist deaktiviert.'}), 403
payload = request.get_json(silent=True) or {}
name = sanitize_form_value(payload.get('name'))
description = sanitize_form_value(payload.get('beschreibung'))
location = sanitize_form_value(payload.get('ort'))
author = sanitize_form_value(payload.get('autor'))
media_type = sanitize_form_value(payload.get('item_type')).lower() or 'book'
code_4 = sanitize_form_value(payload.get('code_4'))
isbn_raw = sanitize_form_value(payload.get('isbn'))
if not name or not description or not location:
return jsonify({'ok': False, 'message': 'Name, Ort und Beschreibung sind erforderlich.'}), 400
if media_type not in LIBRARY_ITEM_TYPES:
return jsonify({'ok': False, 'message': 'Ungültiger Medientyp.'}), 400
normalized_isbn = ''
if isbn_raw:
normalized_isbn = normalize_and_validate_isbn(isbn_raw)
if not normalized_isbn:
return jsonify({'ok': False, 'message': 'Ungültige ISBN (nur ISBN-10/13).'}), 400
if code_4 and not it.is_code_unique(code_4, exclude_id=item_id):
return jsonify({'ok': False, 'message': 'Der Code wird bereits verwendet.'}), 409
try:
client = MongoClient(MONGODB_HOST, MONGODB_PORT)
db = client[MONGODB_DB]
items_col = db['items']
current = items_col.find_one({'_id': ObjectId(item_id)})
if not current:
client.close()
return jsonify({'ok': False, 'message': 'Element nicht gefunden.'}), 404
if current.get('ItemType') not in LIBRARY_ITEM_TYPES:
client.close()
return jsonify({'ok': False, 'message': 'Element ist kein Bibliotheksmedium.'}), 400
update_doc = {
'Name': name,
'Beschreibung': description,
'Ort': location,
'Autor': author,
'Code_4': code_4,
'ItemType': media_type,
'LastUpdated': datetime.datetime.now()
}
if normalized_isbn:
update_doc['ISBN'] = normalized_isbn
elif 'ISBN' in current:
update_doc['ISBN'] = ''
items_col.update_one({'_id': ObjectId(item_id)}, {'$set': update_doc})
client.close()
return jsonify({'ok': True, 'message': 'Bibliotheksmedium aktualisiert.'}), 200
except Exception as e:
app.logger.error(f"Error updating library item {item_id}: {e}")
return jsonify({'ok': False, 'message': 'Fehler beim Aktualisieren des Bibliotheksmediums.'}), 500
@app.route('/upload_admin')
def upload_admin():
"""
@@ -4107,12 +4701,26 @@ def upload_item():
# 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]):
error_msg = 'Der Code wird bereits verwendet. Bitte wählen Sie einen anderen Code.'
if is_mobile:
return jsonify({'success': False, 'message': error_msg}), 400
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:
flash(error_msg, 'error')
return redirect(url_for(success_redirect_endpoint))
error_msg = 'Der Code wird bereits verwendet. Bitte wählen Sie einen anderen Code.'
if is_mobile:
return jsonify({'success': False, 'message': error_msg}), 400
else:
flash(error_msg, 'error')
return redirect(url_for(success_redirect_endpoint))
# Validate optional per-item codes
if individual_codes:
+1 -1
View File
@@ -230,7 +230,7 @@ MODULES.register('library', LIBRARY_MODULE_ENABLED, _match_library)
MODULES.register('student_cards', STUDENT_CARDS_MODULE_ENABLED, _match_student_cards)
STUDENT_DEFAULT_BORROW_DAYS = int(_get(_conf, ['modules', 'student_cards', 'default_borrow_days'], DEFAULTS['modules']['student_cards']['default_borrow_days']))
STUDENT_MAX_BORROW_DAYS = int(_get(_conf, ['modules', 'student_cards', 'max_borrow_days'], DEFAULTS['modules']['student_cards']['max_borrow_days']))
STUDENT_MAX_BORROW_DAYS = int(_get(_conf, ["modules", "student_cards", "max_borrow_days"], DEFAULTS["modules"]["student_cards"]["max_borrow_days"]))
# Upload/paths
ALLOWED_EXTENSIONS = set(_get(_conf, ['allowed_extensions'], DEFAULTS['upload']['allowed_extensions']))
+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 %}
+12
View File
@@ -4614,6 +4614,18 @@ function openItemQuick(id){
}
}
</style>
{% if open_item %}
<script>
document.addEventListener("DOMContentLoaded", function() {
if (typeof openEditModalFromServer === 'function') {
setTimeout(function() {
openEditModalFromServer('{{ open_item }}');
}, 500);
}
});
</script>
{% endif %}
{% endblock %}
<style>
/* Admin/Main Content Container align */
+12
View File
@@ -5716,4 +5716,16 @@ document.addEventListener('DOMContentLoaded', ()=>{
}
}
</style>
{% if open_item %}
<script>
document.addEventListener("DOMContentLoaded", function() {
if (typeof openEditModalFromServer === 'function') {
setTimeout(function() {
openEditModalFromServer('{{ open_item }}');
}, 500);
}
});
</script>
{% endif %}
{% endblock %}
-1
View File
@@ -709,7 +709,6 @@
</style>
<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 %}
<div style="border:1px solid #dbe4ee; border-radius:8px; padding:14px; margin-bottom:16px; background:#f8fbff;">
+4
View File
@@ -96,6 +96,10 @@ def get_tenant_config(tenant_id=None):
if tenant_id in TENANT_REGISTRY:
return TENANT_REGISTRY[tenant_id] or {}
for alias in _tenant_db_aliases(tenant_id):
if alias in TENANT_REGISTRY:
return TENANT_REGISTRY[alias] or {}
return TENANT_REGISTRY.get('default', {}) or {}
+1 -7
View File
@@ -498,8 +498,6 @@ def check_nm_pwd(username, password):
finally:
client.close()
return None
def add_user(
username,
@@ -536,16 +534,12 @@ def add_user(
for key, value in page_permissions.items():
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 = {
'Username': username,
'Password': hashing(password),
'Admin': False,
'active_ausleihung': None,
'name': name_alias,
'name': name.strip() if name else '',
'last_name': last_name.strip() if last_name else '',
'IsStudent': bool(is_student),
'PermissionPreset': permission_defaults['preset'],
Regular → Executable
View File
+47 -25
View File
@@ -1,41 +1,63 @@
version: "3.8"
services:
app:
build: .
container_name: inventory-app
image: ghcr.io/aiirondev/legendary-octo-garbanzo:v0.7.39
container_name: inventarsystem-app
restart: unless-stopped
environment:
- MONGO_URL=mongodb://mongodb:27017/inventar
- REDIS_URL=redis://redis:6379
- BASE_URL=https://inventar.maximiliangruendinger.de #alle öffentliche subdomains
ports:
- "10000:8000"
depends_on:
- mongodb
- 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:
image: mongo:latest
container_name: mongodb
image: mongo:7.0
container_name: inventarsystem-mongodb
restart: unless-stopped
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:
image: redis:alpine
container_name: redis
image: redis:7-alpine
container_name: inventarsystem-redis
restart: unless-stopped
cloudflared:
image: cloudflare/cloudflared:latest
container_name: cloudflared
restart: unless-stopped
# Der Tunnel-Name 'homeserver' muss zu deiner credentials.json passen
command: tunnel run homeserver
command: redis-server --appendonly yes --maxmemory 512mb --maxmemory-policy allkeys-lru
ports:
- "6379:6379"
volumes:
- ./config.yml:/etc/cloudflared/config.yml
- ./credentials.json:/etc/cloudflared/credentials.json
depends_on:
- app
- redis_data:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 5s
retries: 5
volumes:
mongo_data:
mongodb_data:
app_uploads:
app_thumbnails:
app_previews:
app_qrcodes:
app_backups:
app_logs:
redis_data: