Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 58064ee249 | |||
| f7573b9a62 | |||
| 85d758fadb | |||
| 212dd2abcf | |||
| 689819df08 | |||
| 457d32a087 | |||
| cded7b02fe | |||
| f314b5ae70 | |||
| 77c4e1b4c5 |
+1
-1
@@ -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
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
v0.7.42
|
||||
+627
-19
@@ -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
@@ -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']))
|
||||
|
||||
@@ -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 %}
|
||||
@@ -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 */
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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;">
|
||||
|
||||
@@ -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
@@ -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'],
|
||||
|
||||
+47
-25
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user