Compare commits

...

14 Commits

Author SHA1 Message Date
github-actions[bot] 451b7bd3c5 chore: bump version to v0.8.0 2026-05-28 16:40:27 +00:00
Aiirondev_dev 84543c8734 feat: enhance file upload functionality and improve CSV import experience 2026-05-28 18:38:08 +02:00
github-actions[bot] 8a64a1adcd chore: bump version to v0.7.79-dev 2026-05-28 15:56:21 +00:00
Aiirondev_dev 25b7fe986c fix: improve ISBN validation and update related messages in upload forms 2026-05-28 17:54:20 +02:00
Aiirondev_dev 4b376bc426 fix: update password requirements title for clarity 2026-05-24 13:40:18 +02:00
Aiirondev_dev e41d739636 chore: update license information and remove outdated EULA content 2026-05-24 09:45:12 +02:00
github-actions[bot] 4e73d69245 chore: bump version to v0.7.78 2026-05-23 21:32:20 +00:00
Aiirondev_dev daffeaf379 feat: add trial tenant configuration and management to support auto-deletion 2026-05-23 23:08:32 +02:00
Aiirondev_dev a1ad354ccf chore: remove outdated copyright notice from app.py 2026-05-20 23:01:25 +02:00
github-actions[bot] d4d5cd6436 chore: bump version to v0.7.77 2026-05-20 19:45:38 +00:00
Aiirondev_dev 9fd9d63e31 feat: add configuration path environment variable for app 2026-05-20 21:45:19 +02:00
Aiirondev_dev 08cce1b884 feat: enhance configuration path resolution for Docker deployments 2026-05-20 21:39:56 +02:00
github-actions[bot] adde73dffb chore: bump version to v0.7.76 2026-05-20 19:18:10 +00:00
Aiirondev_dev da17667d90 feat: enhance tenant resolution logic with host candidate handling 2026-05-20 21:17:12 +02:00
12 changed files with 959 additions and 349 deletions
+1 -1
View File
@@ -1 +1 @@
v0.7.75
v0.8.0
+258 -59
View File
@@ -1,11 +1,3 @@
'''
Copyright 2025-2026 AIIrondev
Licensed under the Inventarsystem EULA (Endbenutzer-Lizenzvertrag).
See Legal/LICENSE for the full license text.
Unauthorized commercial use, SaaS hosting, or removal of branding is prohibited.
For commercial licensing inquiries: https://github.com/AIIrondev
'''
"""
Inventarsystem - Flask Web Application
@@ -90,7 +82,7 @@ from Web.modules.inventarsystem.data_protection import (
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
import Web.modules.database.settings as cfg
from Web.modules.database.settings import MongoClient
from tenant import get_tenant_context
from tenant import get_tenant_context, get_tenant_trial_status, purge_expired_trial_tenants
app = Flask(__name__, static_folder='static') # Correctly set static folder
@@ -362,6 +354,54 @@ def _enforce_active_session_user():
flash('Ihre Sitzung ist nicht mehr gültig. Bitte erneut anmelden.', 'error')
return redirect(url_for('login'))
@app.before_request
def _enforce_trial_tenant_expiry():
endpoint = request.endpoint or ''
if endpoint == 'static' or endpoint.startswith('static'):
return None
if endpoint in {'login', 'logout', 'impressum', 'license'}:
return None
try:
ctx = get_tenant_context()
tenant_id = ctx.tenant_id if ctx else session.get('tenant_id')
if not tenant_id:
return None
trial_status = get_tenant_trial_status(tenant_id)
if not trial_status.get('enabled'):
return None
session['trial_status'] = {
'enabled': True,
'expired': bool(trial_status.get('expired')),
'days_left': trial_status.get('days_left'),
'expires_at': trial_status.get('expires_at').isoformat() if trial_status.get('expires_at') else None,
}
if not trial_status.get('expired'):
return None
if request.path.startswith('/api/') or request.is_json:
return jsonify({
'ok': False,
'message': 'Diese Testinstanz ist abgelaufen und wurde deaktiviert.',
'expired': True,
}), 410
session.pop('username', None)
session.pop('admin', None)
session.pop('is_admin', None)
session.pop('favorites', None)
session.pop('favorites_owner', None)
flash('Diese Testinstanz ist abgelaufen und wurde deaktiviert.', 'error')
return redirect(url_for('login'))
except Exception as exc:
app.logger.warning(f'Trial expiry check failed: {exc}')
return None
@app.before_request
def _enforce_module_access():
endpoint = request.endpoint or ''
@@ -380,7 +420,7 @@ def _enforce_module_access():
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'):
@@ -1116,6 +1156,7 @@ def inject_version():
'permission_action_options': PERMISSION_ACTION_OPTIONS,
'permission_page_options': PERMISSION_PAGE_OPTIONS,
'permission_presets': us.get_permission_preset_definitions(),
'trial_status': session.get('trial_status', {}),
}
@@ -1132,6 +1173,16 @@ def create_daily_backup():
except Exception as e:
app.logger.error(f"Daily backup creation failed: {e}")
def cleanup_expired_trial_tenants():
"""Remove expired trial tenants from config and MongoDB."""
try:
purged_tenants = purge_expired_trial_tenants()
if purged_tenants:
app.logger.warning(f"Purged expired trial tenants: {', '.join(purged_tenants)}")
except Exception as exc:
app.logger.error(f"Trial tenant cleanup failed: {exc}")
def update_appointment_statuses():
"""
Aktualisiert automatisch die Status aller Terminplaner-Einträge.
@@ -1327,6 +1378,7 @@ def _initialize_scheduler():
scheduler.add_job(func=create_daily_backup, trigger="interval", hours=cfg.BACKUP_INTERVAL_HOURS)
scheduler.add_job(func=update_appointment_statuses, trigger="interval", minutes=cfg.SCHEDULER_INTERVAL_MIN)
scheduler.add_job(func=create_return_reminders, trigger="interval", minutes=cfg.SCHEDULER_INTERVAL_MIN)
scheduler.add_job(func=cleanup_expired_trial_tenants, trigger="interval", hours=1)
scheduler.start()
_scheduler_initialized = True
app.logger.info(f"Scheduler started successfully (interval={cfg.SCHEDULER_INTERVAL_MIN} min)")
@@ -1976,11 +2028,6 @@ def _upload_excel_items(scope='inventory'):
flash('Nicht angemeldet.', 'error')
return redirect(url_for('login'))
permissions = _get_current_user_permissions() or us.build_default_permission_payload('standard_user')
if not _action_access_allowed(permissions, 'can_insert'):
flash('Einfüge-Rechte erforderlich.', 'error')
return redirect(url_for('home'))
is_library_scope = scope == 'library'
file_field = 'library_excel' if is_library_scope else 'inventory_excel'
fallback_route = 'library_admin' if is_library_scope else 'upload_admin'
@@ -1992,9 +2039,20 @@ def _upload_excel_items(scope='inventory'):
excel_file = request.files.get(file_field)
if not excel_file or not excel_file.filename:
flash('Bitte eine Excel-Datei auswählen.', 'error')
flash('Bitte eine Excel- oder CSV-Datei auswählen.', 'error')
return redirect(url_for(fallback_route))
# Permission handling: allow authenticated normal users to import CSV for inventory scope
permissions = _get_current_user_permissions() or us.build_default_permission_payload('standard_user')
has_insert = _action_access_allowed(permissions, 'can_insert')
filename_lower = (excel_file.filename or '').lower()
is_csv = filename_lower.endswith('.csv')
if not has_insert:
# Allow CSV imports for authenticated non-admin users only for inventory (non-library)
if not (is_csv and not is_library_scope and 'username' in session):
flash('Einfüge-Rechte erforderlich.', 'error')
return redirect(url_for('home'))
filename_lower = excel_file.filename.lower()
if not filename_lower.endswith(('.xlsx', '.csv')):
flash('Nur .xlsx oder .csv Dateien werden unterstützt.', 'error')
@@ -2297,6 +2355,19 @@ def _upload_excel_items(scope='inventory'):
flash(f'Excel-Import abgeschlossen: {created_total} Artikel importiert, {len(import_errors)} Zeilen fehlgeschlagen.', 'warning')
else:
flash(f'Excel-Import erfolgreich: {created_total} Artikel importiert.', 'success')
if is_library_scope:
try:
_append_audit_event_standalone(
event_type='library_bulk_import',
payload={
'channel': 'upload_library_excel',
'created_total': created_total,
'processed_rows': len(planned_rows),
}
)
except Exception:
app.logger.warning('Audit write failed for library_bulk_import')
return redirect(url_for('home_admin'))
@@ -3004,20 +3075,13 @@ def api_library_items():
total_count = items_db.count_documents(query)
library_items = list(
items_db.find(query, projection)
.sort([('Name', 1), ('_id', 1)])
.skip(offset)
.limit(limit)
)
raw_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')]
# Build maps for grouping by parent-child relationship (grouped sub-items)
all_ids = [str(itm.get('_id')) for itm in raw_items if itm.get('_id')]
active_records = []
if item_ids:
active_records = list(ausleihungen_db.find(
{'Item': {'$in': item_ids}, 'Status': 'active'},
{'Item': 1, 'User': 1}
))
if all_ids:
active_records = list(ausleihungen_db.find({'Item': {'$in': all_ids}, 'Status': 'active'}, {'Item': 1, 'User': 1}))
active_item_ids = set()
active_user_by_item = {}
@@ -3028,32 +3092,115 @@ def api_library_items():
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
# Organize children under their parent (ParentItemId) and prepare parent list
items_by_id = {}
children_by_parent = {}
parent_ids = set()
for itm in raw_items:
iid = str(itm.get('_id'))
items_by_id[iid] = itm
parent = str(itm.get('ParentItemId') or '')
if parent:
children_by_parent.setdefault(parent, []).append(itm)
else:
parent_ids.add(iid)
# Build aggregated list: for each parent id, include parent + children as one entry
aggregated = []
processed = set()
for pid in list(parent_ids):
parent = items_by_id.get(pid)
if not parent:
continue
children = children_by_parent.get(pid, [])
# Compute grouped counts and availability
grouped_units = [parent] + children
grouped_all_codes = []
available_units = []
for unit in grouped_units:
unit_id = str(unit.get('_id'))
code = unit.get('Code_4') or unit.get('Code4') or ''
if code:
grouped_all_codes.append(code)
if unit.get('Verfuegbar', True):
available_units.append({'id': unit_id, 'code': code, 'label': f"{code} ({unit.get('Name','')})"})
# Convert ObjectId to string for JSON serialization on parent doc copy
doc = dict(parent)
doc['_id'] = str(parent.get('_id'))
if doc.get('Code4') in (None, '') and doc.get('Code_4') not in (None, ''):
doc['Code4'] = doc.get('Code_4')
condition_value = str(doc.get('Condition', '')).strip().lower()
has_damage = bool(doc.get('HasDamage')) or condition_value == 'destroyed'
has_active_borrow = any(str(unit.get('_id')) in active_item_ids for unit in grouped_units)
if has_damage and not has_active_borrow:
item['LibraryDisplayStatus'] = 'damaged'
elif has_active_borrow or item.get('Verfuegbar') is False:
item['LibraryDisplayStatus'] = 'borrowed'
doc['LibraryDisplayStatus'] = 'damaged'
elif has_active_borrow or doc.get('Verfuegbar') is False:
doc['LibraryDisplayStatus'] = 'borrowed'
else:
item['LibraryDisplayStatus'] = 'available'
doc['LibraryDisplayStatus'] = 'available'
item['BorrowedBy'] = active_user_by_item.get(item_id) or item.get('User', '')
# Determine borrower: prefer any active borrow on group units, fallback to parent.User
borrower = active_user_by_item.get(str(parent.get('_id'))) or ''
if not borrower:
for unit in grouped_units:
u_id = str(unit.get('_id'))
if active_user_by_item.get(u_id):
borrower = active_user_by_item.get(u_id)
break
doc['BorrowedBy'] = borrower or doc.get('User', '')
count = len(library_items)
doc['GroupedDisplayCount'] = 1 + len(children)
doc['Quantity'] = doc['GroupedDisplayCount']
doc['AvailableGroupedCount'] = len(available_units)
doc['GroupedAvailableUnits'] = available_units
doc['GroupedAllCodes'] = grouped_all_codes
aggregated.append(doc)
processed.add(pid)
for c in children:
processed.add(str(c.get('_id')))
# Include any remaining items (orphans or standalones not parented)
for itm in raw_items:
iid = str(itm.get('_id'))
if iid in processed:
continue
doc = dict(itm)
doc['_id'] = iid
if doc.get('Code4') in (None, '') and doc.get('Code_4') not in (None, ''):
doc['Code4'] = doc.get('Code_4')
condition_value = str(doc.get('Condition', '')).strip().lower()
has_damage = bool(doc.get('HasDamage')) or condition_value == 'destroyed'
has_active_borrow = iid in active_item_ids
if has_damage and not has_active_borrow:
doc['LibraryDisplayStatus'] = 'damaged'
elif has_active_borrow or doc.get('Verfuegbar') is False:
doc['LibraryDisplayStatus'] = 'borrowed'
else:
doc['LibraryDisplayStatus'] = 'available'
doc['BorrowedBy'] = active_user_by_item.get(iid) or doc.get('User', '')
# Single item: grouped count = 1
doc['GroupedDisplayCount'] = 1
doc['Quantity'] = 1
doc['AvailableGroupedCount'] = 1 if doc.get('Verfuegbar', True) else 0
doc['GroupedAvailableUnits'] = [{'id': iid, 'code': doc.get('Code_4') or doc.get('Code4') or '', 'label': f"{doc.get('Code_4') or doc.get('Code4') or '-'} ({doc.get('Name','')})"}] if doc.get('Verfuegbar', True) else []
doc['GroupedAllCodes'] = [doc.get('Code_4') or doc.get('Code4') or '']
aggregated.append(doc)
client.close()
count = len(aggregated)
return jsonify({
'items': library_items,
'items': aggregated,
'offset': offset,
'limit': limit,
'count': count,
@@ -3338,6 +3485,19 @@ def api_library_item_update(item_id):
update_doc['ISBN'] = ''
items_col.update_one({'_id': ObjectId(item_id)}, {'$set': update_doc})
try:
_append_audit_event_standalone(
event_type='library_item_updated',
payload={
'channel': 'library_table',
'item_id': item_id,
'updated_fields': list(update_doc.keys()),
'isbn': update_doc.get('ISBN', ''),
}
)
except Exception:
app.logger.warning('Audit write failed for library_item_updated')
client.close()
return jsonify({'ok': True, 'message': 'Bibliotheksmedium aktualisiert.'}), 200
@@ -4749,16 +4909,15 @@ def upload_item():
item_isbn = ''
item_type = 'general'
if cfg.MODULES.is_enabled('library'):
item_isbn = normalize_and_validate_isbn(isbn_raw)
if isbn_raw and not item_isbn:
error_msg = 'Ungültige ISBN. Bitte ISBN-10 oder ISBN-13 verwenden.'
if is_mobile:
return jsonify({'success': False, 'message': error_msg}), 400
flash(error_msg, 'error')
return redirect(url_for(success_redirect_endpoint))
if item_isbn:
if cfg.MODULES.is_enabled('library') and isbn_raw:
normalized_isbn = normalize_and_validate_isbn(isbn_raw)
if normalized_isbn:
item_isbn = normalized_isbn
item_type = 'book'
else:
item_isbn = isbn_raw
if upload_mode == 'library':
item_type = 'book'
if upload_mode == 'library':
if not cfg.MODULES.is_enabled('library'):
@@ -4767,8 +4926,8 @@ def upload_item():
return jsonify({'success': False, 'message': error_msg}), 400
flash(error_msg, 'error')
return redirect(url_for(success_redirect_endpoint))
if not item_isbn:
error_msg = 'Für Bücher ist eine gültige ISBN erforderlich.'
if not isbn_raw:
error_msg = 'Für Bücher ist eine ISBN oder ein Barcode erforderlich.'
if is_mobile:
return jsonify({'success': False, 'message': error_msg}), 400
flash(error_msg, 'error')
@@ -5558,7 +5717,21 @@ def upload_item():
# Create QR code for the item (deactivated)
# create_qr_code(str(item_id))
success_msg = f'Element wurde erfolgreich hinzugefügt ({len(created_item_ids)} erstellt)'
if upload_mode == 'library':
try:
_append_audit_event_standalone(
event_type='library_item_created',
payload={
'channel': 'upload_admin',
'created_item_ids': created_item_ids,
'created_count': len(created_item_ids),
'isbn': item_isbn,
'codes': created_item_ids and [it.get_item(cid).get('Code_4') for cid in created_item_ids] or []
}
)
except Exception:
app.logger.warning('Audit write failed for library_item_created')
if is_mobile:
return jsonify({
'success': True,
@@ -6109,6 +6282,20 @@ def report_damage(id):
except Exception as log_err:
app.logger.warning(f"Damage report log write failed for item {id}: {log_err}")
try:
_append_audit_event_standalone(
event_type='item_damage_reported',
payload={
'item_id': id,
'item_name': item_doc.get('Name', ''),
'reported_by': session.get('username'),
'description': description,
'damage_count': damage_count,
}
)
except Exception:
app.logger.warning('Audit write failed for item_damage_reported')
return jsonify({
'success': True,
'message': 'Schaden erfolgreich erfasst.',
@@ -6191,6 +6378,18 @@ def mark_damage_repaired(id):
except Exception as log_err:
app.logger.warning(f"Damage repair log write failed for item {id}: {log_err}")
try:
_append_audit_event_standalone(
event_type='item_damage_repaired',
payload={
'item_id': id,
'resolved_by': session.get('username'),
'resolved_count': len(open_reports),
}
)
except Exception:
app.logger.warning('Audit write failed for item_damage_repaired')
return jsonify({
'success': True,
'message': 'Schäden als repariert markiert.',
+49 -6
View File
@@ -100,9 +100,29 @@ DEFAULTS = {
}
# Load configuration file
CONFIG_PATH = os.path.join(BASE_DIR, '..', 'config.json')
def _resolve_config_path():
"""Resolve runtime config path with robust fallbacks for Docker deployments."""
env_path = os.getenv('INVENTAR_CONFIG_PATH', '').strip()
if env_path:
return env_path
candidates = [
os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(BASE_DIR))), 'config.json'),
os.path.join(os.path.dirname(os.path.dirname(BASE_DIR)), 'config.json'),
os.path.join(os.path.dirname(BASE_DIR), 'config.json'),
os.path.join(BASE_DIR, '..', 'config.json'),
]
for candidate in candidates:
if os.path.isfile(candidate):
return os.path.abspath(candidate)
return os.path.abspath(candidates[0])
CONFIG_PATH = _resolve_config_path()
try:
with open(CONFIG_PATH, 'r') as f:
with open(CONFIG_PATH, 'r', encoding='utf-8') as f:
_conf = json.load(f)
except Exception:
_conf = {}
@@ -135,10 +155,33 @@ def _get_int_env(name, default):
return int(default)
def get_version():
with open(os.path.join(BASE_DIR, '..', '..', '..', '.docker-build.env'), 'r') as f:
for l in f:
if l.startswith('INVENTAR_APP_IMAGE='):
return l.split(':', 1)[1].strip()
# Prefer an explicit release marker if present (created by release process).
project_root = os.path.abspath(os.path.join(BASE_DIR, '..', '..', '..'))
release_file = os.path.join(project_root, '.release-version')
try:
if os.path.isfile(release_file):
with open(release_file, 'r', encoding='utf-8') as f:
val = f.read().strip()
if val:
return val
except Exception:
pass
# Fallback to .docker-build.env (legacy behaviour)
env_path = os.path.join(project_root, '.docker-build.env')
try:
with open(env_path, 'r', encoding='utf-8') as f:
for l in f:
if l.startswith('INVENTAR_APP_IMAGE='):
return l.split(':', 1)[1].strip()
except Exception:
pass
# Final fallback: use config.json value or packaged default
try:
return _get(_conf, ['ver'], DEFAULTS.get('version', '0.0.0'))
except Exception:
return DEFAULTS.get('version', '0.0.0')
# Expose settings
APP_VERSION = get_version()
+26 -35
View File
@@ -930,6 +930,29 @@
box-shadow: none;
}
.navbar-nav .nav-item.dropdown > .nav-link.dropdown-toggle,
.user-menu-wrap > .user-menu-btn {
display: none !important;
}
.navbar-nav .nav-item.dropdown > .dropdown-menu,
.user-menu-wrap > .dropdown-menu {
display: block !important;
position: static !important;
float: none !important;
transform: none !important;
inset: auto !important;
width: 100%;
}
.navbar-nav .nav-item.dropdown > .dropdown-menu {
margin-top: 0;
}
.user-menu-wrap > .dropdown-menu {
margin-top: 8px;
}
.navbar-nav .dropdown-item {
color: rgba(255, 255, 255, 0.9);
padding: 12px 16px;
@@ -974,6 +997,7 @@
order: 3;
width: 100%;
margin: 0;
display: none !important;
}
.function-search-form {
@@ -2055,42 +2079,9 @@
return;
}
// Mobile menu is collapsed: avoid hiding links preemptively.
if (navCollapse && !navCollapse.classList.contains('show')) {
restoreAllNavItems();
return;
}
// Mobile burger menu should show the full list without overflow pruning.
restoreAllNavItems();
const hiddenSources = [];
let candidates = collectTopLevelNavSources();
const overflowBuffer = 12;
while (isNavOverflowing(overflowBuffer) && candidates.length > 0) {
const toHide = pickNextNavItemToHide(candidates);
if (!toHide) {
break;
}
toHide.style.display = 'none';
hiddenSources.unshift(toHide);
candidates = collectTopLevelNavSources();
}
rebuildOverflowControl(hiddenSources);
// One final pass in case the overflow menu label/count itself changed row width.
candidates = collectTopLevelNavSources();
while (isNavOverflowing(overflowBuffer) && candidates.length > 0) {
const toHide = pickNextNavItemToHide(candidates);
if (!toHide) {
break;
}
toHide.style.display = 'none';
hiddenSources.unshift(toHide);
rebuildOverflowControl(hiddenSources);
candidates = collectTopLevelNavSources();
}
return;
}
let resizeTimer = null;
+17
View File
@@ -72,6 +72,7 @@
</div>
<!-- Table Header (only in table mode) -->
<div class="library-table-wrapper">
<div class="table-header" id="tableHeader" style="display: none;">
<div class="table-row">
<div class="table-cell title-cell">Titel</div>
@@ -166,6 +167,7 @@
</div>
{% endif %}
</div>
</div>
</div>
<!-- Detail Modal -->
@@ -720,5 +722,20 @@ function onScanError(error) {
background: #fce8e6;
color: #b3261e;
}
/* Mobile: allow horizontal scrolling for table-mode views */
@media (max-width: 900px) {
.library-table-wrapper {
overflow-x: auto;
-webkit-overflow-scrolling: touch;
width: 100%;
}
.library-table-wrapper .table-row {
min-width: 720px; /* allow table to have intrinsic width and be scrolled */
}
.table-header, .item-content.table-mode {
display: block; /* keep rows stacked but allow horizontal scroll */
}
}
</style>
{% endblock %}
+7 -5
View File
@@ -513,12 +513,13 @@
<table class="library-items-table">
<thead>
<tr>
<th style="width: 25%;">Titel</th>
<th style="width: 15%;">Autor/Künstler</th>
<th style="width: 24%;">Titel</th>
<th style="width: 14%;">Autor/Künstler</th>
<th style="width: 12%;">ISBN/Code</th>
<th style="width: 10%;">Typ</th>
<th style="width: 8%;">Typ</th>
<th style="width: 8%;">Anzahl</th>
<th style="width: 12%;">Status</th>
<th style="width: 26%;">Aktionen</th>
<th style="width: 22%;">Aktionen</th>
</tr>
</thead>
<tbody id="itemsTableBody">
@@ -603,7 +604,7 @@
}
} catch (error) {
console.error('Error loading library items:', error);
document.getElementById('itemsTableBody').innerHTML = '<tr><td colspan="6" style="text-align:center; color:#999;">Fehler beim Laden der Bibliothekselemente.</td></tr>';
document.getElementById('itemsTableBody').innerHTML = '<tr><td colspan="7" style="text-align:center; color:#999;">Fehler beim Laden der Bibliothekselemente.</td></tr>';
} finally {
pagingState.loading = false;
}
@@ -707,6 +708,7 @@
<td>${escapeHtml(item.Autor || item.Author || '-')}</td>
<td>${escapeHtml(item.ISBN || item.Code_4 || item.Code4 || '-')}</td>
<td>${getItemTypeLabel(item.ItemType || 'book')}</td>
<td style="font-weight:600; text-align:center;">${item.Quantity || item.GroupedDisplayCount || 1}</td>
<td>
<span class="table-status ${statusClass}">
${statusText}
+2 -154
View File
@@ -1,11 +1,3 @@
<!--
Copyright 2025-2026 AIIrondev
Licensed under the Inventarsystem EULA (Endbenutzer-Lizenzvertrag).
See Legal/LICENSE for the full license text.
Unauthorized commercial use, SaaS hosting, or removal of branding is prohibited.
For commercial licensing inquiries: https://github.com/AIIrondev
-->
{% extends "base.html" %}
{% block title %}License - Inventarsystem{% endblock %}
@@ -17,10 +9,6 @@
<!-- Tab navigation -->
<ul class="nav nav-tabs mt-3" id="legalTabs" role="tablist">
<li class="nav-item" role="presentation">
<button class="nav-link active" id="tab-eula" data-bs-toggle="tab" data-bs-target="#pane-eula"
type="button" role="tab">EULA</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="tab-notice" data-bs-toggle="tab" data-bs-target="#pane-notice"
type="button" role="tab">NOTICE</button>
@@ -43,139 +31,12 @@
</li>
</ul>
<div class="tab-content" id="legalTabContent">
<!-- ── EULA ─────────────────────────────────────────────────────── -->
<div class="tab-pane fade show active" id="pane-eula" role="tabpanel">
<div class="license-content">
<!-- EULA -->
<h2>Endbenutzer-Lizenzvertrag (EULA) und Nutzungsbedingungen</h2>
<p><strong>Softwareprojekt:</strong> Inventarsystem<br>
<strong>Urheberrechtshalter (Lizenzgeber):</strong> AIIrondev<br>
<strong>Gültigkeit:</strong> Stand 2026</p>
<hr>
<h3>Präambel</h3>
<p>Dieser Endbenutzer-Lizenzvertrag (im Folgenden „Vertrag") stellt eine rechtsgültige Vereinbarung zwischen Ihnen
(im Folgenden „Lizenznehmer") und dem Urheber <strong>AIIrondev</strong> (im Folgenden „Lizenzgeber") dar.
Durch den Zugriff auf den Quellcode, die Installation, das Kopieren oder die sonstige Nutzung der Software
„Inventarsystem" (im Folgenden „Produkt") erklärt sich der Lizenznehmer mit den nachfolgenden Bedingungen
vollumfänglich einverstanden.</p>
<p>Sollte der Lizenznehmer den Bedingungen dieses Vertrags nicht zustimmen, ist jegliche Nutzung,
Vervielfältigung oder Distribution des Produkts mit sofortiger Wirkung untersagt.</p>
<hr>
<h3>§ 1 Gegenstand der Lizenz und Eigentumsrechte</h3>
<ol>
<li>Das Produkt wird dem Lizenznehmer unter Vorbehalt lizenziert, nicht verkauft. Sämtliche
Eigentumsrechte, Urheberrechte und sonstigen geistigen Eigentumsrechte am Produkt sowie an allen
Kopien davon verbleiben ausschließlich beim Lizenzgeber.</li>
<li>Diese Lizenz gewährt lediglich ein eingeschränktes Nutzungsrecht unter den in diesem Vertrag
explizit genannten Bedingungen.</li>
</ol>
<h3>§ 2 Zulässiger Nutzungskreis (Privatnutzung)</h3>
<ol>
<li>Die unentgeltliche Nutzung des Produkts ist ausschließlich <strong>natürlichen Personen für den
rein privaten, häuslichen Gebrauch</strong> gestattet.</li>
<li>Die Nutzung umfasst die Verwaltung privater Bestände ohne jegliche Gewinnerzielungsabsicht.</li>
<li>Jegliche Nutzung durch <strong>Institutionelle Nutzer</strong> (einschließlich, aber nicht
beschränkt auf: Unternehmen, Einzelunternehmer, Freiberufler, Vereine, Bildungseinrichtungen,
Behörden oder NGOs) ist ausdrücklich <strong>untersagt</strong> und bedarf einer gesonderten,
schriftlichen Lizenzvereinbarung mit dem Lizenzgeber.</li>
</ol>
<h3>§ 3 Funktionale Einschränkungen und Support</h3>
<ol>
<li>Dem Lizenznehmer wird das Produkt in der jeweils vorliegenden Fassung bereitgestellt („As-Is").</li>
<li>Für die kostenlose Privatnutzung besteht <strong>kein Anspruch</strong> auf:
<ul>
<li>Technischen Support oder Beratung.</li>
<li>Bereitstellung von Sicherheits-Updates oder Patches.</li>
<li>Gewährleistung der Kompatibilität mit spezifischen Hardware- oder Softwareumgebungen.</li>
</ul>
</li>
<li>Der Lizenzgeber behält sich das Recht vor, Funktionen in zukünftigen Versionen zu ändern,
einzuschränken oder kostenpflichtig zu gestalten.</li>
</ol>
<h3>§ 4 Wahrung der Urheberbezeichnung (Branding-Klausel)</h3>
<ol>
<li>Das Produkt verfügt über fest integrierte Urheberrechtshinweise, insbesondere die Kennzeichnung
<strong>„Powered by AIIrondev"</strong> in der Benutzeroberfläche (Footer/Menü).</li>
<li>Es ist dem Lizenznehmer untersagt, diese Hinweise zu entfernen, zu modifizieren, zu verdecken oder
deren Sichtbarkeit durch technische Maßnahmen (z. B. Manipulation von CSS, JavaScript oder
Metadaten) zu beeinträchtigen.</li>
<li>Das Entfernen dieser Hinweise führt zum sofortigen und automatischen Erlöschen der
Nutzungslizenz.</li>
</ol>
<h3>§ 5 Verbot der kommerziellen Verwertung &amp; SaaS</h3>
<ol>
<li>Die Bereitstellung des Produkts als Dienstleistung für Dritte (Software-as-a-Service, SaaS),
insbesondere gegen Entgelt oder zur Generierung von Werbeeinnahmen, ist strikt untersagt.</li>
<li>Das Hosting auf öffentlichen Servern mit dem Ziel, Dritten ohne eigene Installation Zugriff auf
die Funktionalität zu gewähren, ist nur mit ausdrücklicher schriftlicher Genehmigung des
Lizenzgebers zulässig.</li>
</ol>
<h3>§ 6 Modifikationen und Contributions</h3>
<ol>
<li>Der Lizenznehmer darf den Quellcode für den privaten Eigenbedarf modifizieren.</li>
<li>Im Falle einer Veröffentlichung von Modifikationen oder der Einreichung von
Verbesserungsvorschlägen (z. B. Pull Requests auf GitHub) räumt der Lizenznehmer dem Lizenzgeber
ein unwiderrufliches, weltweites, zeitlich unbeschränktes und kostenfreies Nutzungs- und
Verwertungsrecht an diesen Änderungen ein. Der Lizenzgeber ist berechtigt, diese Änderungen in
das Hauptprodukt zu übernehmen und unter dieser oder einer anderen Lizenz zu vertreiben.</li>
</ol>
<h3>§ 7 Haftungsbeschränkung</h3>
<ol>
<li>Die Haftung des Lizenzgebers für Schäden, die aus der Nutzung oder Unmöglichkeit der Nutzung des
Produkts entstehen (einschließlich Datenverlust, Betriebsunterbrechung oder entgangener Gewinn),
ist auf Vorsatz und grobe Fahrlässigkeit beschränkt.</li>
<li>Der Lizenzgeber übernimmt keine Haftung für die Richtigkeit der mit dem Produkt verwalteten
Daten.</li>
</ol>
<h3>§ 8 Rechtswahl und Gerichtsstand</h3>
<ol>
<li>Es gilt ausschließlich das Recht der <strong>Bundesrepublik Deutschland</strong> unter Ausschluss
des UN-Kaufrechts (CISG).</li>
<li>Soweit gesetzlich zulässig, wird als Gerichtsstand für alle Streitigkeiten aus diesem Vertrag der
Sitz des Lizenzgebers vereinbart.</li>
<li>Sollten einzelne Bestimmungen dieses Vertrags unwirksam sein, bleibt die Wirksamkeit der übrigen
Bestimmungen unberührt (Salvatorische Klausel).</li>
</ol>
<hr>
<div class="license-exception-notice">
<h3>⚠️ Anfragen für Ausnahmegenehmigungen (Kommerzielle Lizenzen)</h3>
<p>
Bitte kontaktieren Sie den Urheber direkt über das GitHub-Profil:
<a href="https://github.com/AIIrondev" target="_blank" rel="noopener noreferrer">AIIrondev auf GitHub</a>
</p>
</div>
</div><!-- /.license-content -->
</div><!-- /#pane-eula -->
<!-- ── NOTICE ────────────────────────────────────────────────────── -->
<div class="tab-pane fade" id="pane-notice" role="tabpanel">
<div class="license-content">
<h2>NOTICE Urheberrechtliche Hinweise</h2>
<p><strong>Inventarsystem</strong><br>Copyright © 2025-2026 AIIrondev</p>
<p>Dieses Projekt steht unter dem Inventarsystem EULA (Endbenutzer-Lizenzvertrag). Der vollständige Lizenztext
befindet sich in <code>Legal/LICENSE</code>. Die Software wird ausschließlich für den privaten,
nicht-kommerziellen Gebrauch bereitgestellt. Unerlaubte kommerzielle Nutzung, SaaS-Hosting oder das Entfernen
von Branding ist untersagt. Anfragen für kommerzielle Lizenzen:
<a href="https://github.com/AIIrondev" target="_blank" rel="noopener noreferrer">https://github.com/AIIrondev</a></p>
<p><strong>Invario Modul Suite</strong><br>Copyright © 2026 Invario UG</p>
<hr>
<h3>Drittanbieter-Hinweise</h3>
<p>Dieses Projekt nutzt oder referenziert die folgende Drittanbieter-Software. Deren jeweilige Lizenzen gelten
wie angegeben. Dieser NOTICE-Abschnitt dient ausschließlich der Attribution und ändert keine Lizenzbedingungen.</p>
@@ -200,13 +61,6 @@
<ul>
<li>html5-qrcode (MIT) © 2020-2025 Manoj Brahmbhatt (mebjas)</li>
</ul>
<h4>Systemkomponenten (nicht im Repository enthalten, ggf. im Deployment genutzt)</h4>
<ul>
<li>NGINX (2-clause BSD) © Nginx, Inc. and/or its licensors</li>
<li>FFmpeg (LGPL/GPL, je nach Konfiguration) © 2000-2025 FFmpeg developers</li>
</ul>
<hr>
</div>
</div><!-- /#pane-notice -->
@@ -258,9 +112,6 @@
<div class="license-content">
<h2>Sicherheitsrichtlinie (Security Policy)</h2>
<h3>Unterstützte Versionen</h3>
<p>Bitte nutzen Sie immer die neueste Version aus dem <code>main</code>-Branch, um Sicherheitsupdates zu erhalten.</p>
<h3>Datenschutzrechtliche Mechanismen</h3>
<ul>
<li><strong>Passwort-Hashing:</strong> Passwörter werden niemals im Klartext gespeichert.</li>
@@ -272,10 +123,7 @@
<h3>Meldung von Sicherheitslücken</h3>
<div class="license-exception-notice">
<h3>⚠️ Responsible Disclosure</h3>
<p>Falls Sie eine Sicherheitslücke entdecken, öffnen Sie bitte <strong>kein öffentliches Issue</strong>.
Kontaktieren Sie den Maintainer direkt über die
<a href="https://github.com/AIIrondev" target="_blank" rel="noopener noreferrer">GitHub Security Advisory-Funktion</a>
oder per E-Mail.</p>
<p>Falls Sie eine Sicherheitslücke entdecken, wenden sie sich umgehend an die Email: info@invario.eu .
</div>
</div>
</div><!-- /#pane-security -->
+1 -1
View File
@@ -55,7 +55,7 @@
<div class="form-group">
<label for="password">Passwort</label>
<div class="password-rules" id="password-rules" aria-live="polite">
<p class="password-rules-title">Passwort-Anforderungen (live):</p>
<p class="password-rules-title">Passwort-Anforderungen:</p>
<ul>
<li id="pw-rule-length" class="pw-rule">Mindestens 12 Zeichen</li>
<li id="pw-rule-lower" class="pw-rule">Mindestens ein Kleinbuchstabe</li>
+93 -39
View File
@@ -716,6 +716,8 @@
<p style="margin:0 0 10px 0; color:#555;">Laden Sie eine <strong>.xlsx</strong>- oder <strong>.csv</strong>-Datei hoch. Spalten werden automatisch erkannt (z.B. Name, Ort, Beschreibung, ISBN, Code, Anzahl). Für den Bibliotheksimport ist eine gültige ISBN je Zeile erforderlich.</p>
<form method="POST" action="{{ url_for('upload_library_excel') }}" enctype="multipart/form-data" style="display:flex; gap:10px; flex-wrap:wrap; align-items:center;">
<input type="file" name="library_excel" accept=".xlsx,.csv" required>
<button type="button" class="btn btn-link" onclick="downloadSampleCsv('library')">Beispiel-CSV herunterladen</button>
<button type="button" class="btn btn-outline-secondary" title="Format-Hilfe" onclick="showCsvHelp('library')">?</button>
<button type="submit" class="btn btn-secondary" name="excel_action" value="validate">Nur validieren</button>
<button type="submit" class="btn btn-primary" name="excel_action" value="import">Bibliothek importieren</button>
</form>
@@ -726,6 +728,8 @@
<p style="margin:0 0 10px 0; color:#555;">Laden Sie eine <strong>.xlsx</strong>- oder <strong>.csv</strong>-Datei hoch. Spalten werden automatisch erkannt (z.B. Name, Ort, Beschreibung, Filter1/2/3, Kosten, Jahr, Code, Anzahl).</p>
<form method="POST" action="{{ url_for('upload_inventory_excel') }}" enctype="multipart/form-data" style="display:flex; gap:10px; flex-wrap:wrap; align-items:center;">
<input type="file" name="inventory_excel" accept=".xlsx,.csv" required>
<button type="button" class="btn btn-link" onclick="downloadSampleCsv('inventory')">Beispiel-CSV herunterladen</button>
<button type="button" class="btn btn-outline-secondary" title="Format-Hilfe" onclick="showCsvHelp('inventory')">?</button>
<button type="submit" class="btn btn-secondary" name="excel_action" value="validate">Nur validieren</button>
<button type="submit" class="btn btn-primary" name="excel_action" value="import">Inventar importieren</button>
</form>
@@ -883,7 +887,7 @@
<div class="form-group">
<label for="individual_codes">Einzelcodes (optional, je Zeile ein Code)</label>
<textarea id="individual_codes" name="individual_codes" rows="4" placeholder="z.B.\nABC-001\nABC-002"></textarea>
<small style="display:block; color:#666;">Bei Anzahl > 1 können hier individuelle Codes pro Item gesetzt werden. Scanner-Eingaben werden bei Mehrfachanzahl automatisch hier angefügt.</small>
<small style="display:block; color:#666;">Bei Anzahl > 1 können hier individuelle Codes pro Item gesetzt werden. Der Scanner setzt immer zuerst den Basis-Code im Feld oben; weitere Einzelcodes können hier bei Bedarf manuell ergänzt werden.</small>
</div>
<!-- Image upload (hidden for library mode) -->
<div class="form-group" {% if show_library_features %}style="display:none;"{% endif %}>
@@ -904,7 +908,7 @@
<button type="button" class="fetch-isbn-button" onclick="fetchBookInfo('upload')">Bild abrufen</button>
</div>
<div id="isbn-scanner" style="width:100%; max-width:520px; display:none; margin-top:10px;"></div>
<small id="isbn-scan-status" style="display:block; color:#666; margin-top:6px;">Scannen oder manuell eingeben. Das Buchcover wird automatisch heruntergeladen.</small>
<small id="isbn-scan-status" style="display:block; color:#666; margin-top:6px;">Scannen oder manuell eingeben. Gültige ISBNs helfen beim Abruf von Buchdaten, andere Codes werden trotzdem akzeptiert.</small>
<div id="book-info-container" class="book-info-container"></div>
</div>
@@ -920,6 +924,53 @@
</div>
</div>
<!-- CSV Help Modal -->
<div id="csv-help-modal" class="popup-overlay" style="display:none;">
<div class="duplicate-code-popup" style="max-width:760px;">
<button class="popup-close-x" onclick="hideCsvHelp()">×</button>
<div class="popup-content">
<h3>CSV Import Format</h3>
<p id="csv-help-text" style="text-align:left; color:var(--ui-text);"></p>
<div style="margin-top:12px; text-align:center;">
<button class="popup-close-button" onclick="hideCsvHelp()">Schließen</button>
</div>
</div>
</div>
</div>
<script>
function downloadSampleCsv(scope) {
let headers = ['name','ort','beschreibung','filter1','filter2','filter3','anschaffungsjahr','anschaffungskosten','code_4','anzahl','isbn','item_type'];
let exampleInventory = ['Projektor','Aula','HD-Projektor für Präsentationen','Medien','Technik','','2019','450.00','PRJ-001','1','','general'];
let exampleLibrary = ['Harry Potter und der Stein der Weisen','Bibliothek','Kinderbuch von J.K. Rowling','Belletristik','','','1997','12.99','HP-001','1','9783551354013','book'];
let row = exampleInventory;
if (scope === 'library') row = exampleLibrary;
const csvContent = [headers.join(','), row.map(v=> '"'+String(v).replace(/"/g,'""')+'"').join(',')].join('\n');
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = scope === 'library' ? 'sample_library_import.csv' : 'sample_inventory_import.csv';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
function showCsvHelp(scope) {
const invText = `Erlaubte Spalten (Reihenfolge beliebig): name, ort, beschreibung, filter1, filter2, filter3, anschaffungsjahr, anschaffungskosten, code_4, anzahl, isbn, item_type.\n\n` +
`Hinweise:\n- Trenne Felder per Komma. Textfelder sollten in Anführungszeichen stehen, wenn sie Kommas enthalten.\n- Für Bibliotheks-Import ist eine gültige ISBN pro Zeile erforderlich (ISBN-10 oder ISBN-13).\n- 'code_4' kann leer bleiben; das System erzeugt in diesem Fall Codes.\n- 'anzahl' legt die Anzahl physischer Exemplare für diese Zeile fest.`;
const libText = invText;
document.getElementById('csv-help-text').textContent = scope === 'library' ? libText : invText;
document.getElementById('csv-help-modal').style.display = 'flex';
}
function hideCsvHelp() {
document.getElementById('csv-help-modal').style.display = 'none';
}
</script>
<script src="https://unpkg.com/html5-qrcode@2.0.9/dist/html5-qrcode.min.js"></script>
<script>
const libraryModuleEnabled = {{ 'true' if library_module_enabled else 'false' }};
@@ -965,33 +1016,30 @@
return '';
}
function appendIndividualCode(scannedCode) {
const textarea = document.getElementById('individual_codes');
const itemCountInput = document.getElementById('item_count');
if (!textarea || !itemCountInput) return false;
function updateIsbnLiveValidation() {
const isbnField = document.getElementById('isbn');
const statusEl = document.getElementById('isbn-scan-status');
if (!isbnField || !statusEl) return;
const itemCount = Math.max(1, parseInt(itemCountInput.value || '1', 10));
if (itemCount <= 1) return false;
const rawValue = isbnField.value.trim();
isbnField.classList.remove('code-valid', 'code-invalid');
const existing = textarea.value
.split(/\r?\n/)
.map(line => line.trim())
.filter(Boolean);
if (existing.includes(scannedCode)) {
setCode4ScanStatus(`Code ${scannedCode} ist bereits eingetragen.`, true);
return true;
if (!rawValue) {
statusEl.textContent = 'Scannen oder manuell eingeben. Gültige ISBNs helfen beim Abruf von Buchdaten, andere Codes werden trotzdem akzeptiert.';
statusEl.style.color = '#666';
return;
}
if (existing.length >= itemCount) {
setCode4ScanStatus('Es sind bereits genug Einzelcodes für die gewählte Anzahl eingetragen.', true);
return true;
if (normalizeIsbnClient(rawValue)) {
isbnField.classList.add('code-valid');
statusEl.textContent = 'Gültiges ISBN-Format erkannt. Der Wert kann gespeichert werden.';
statusEl.style.color = '#666';
return;
}
existing.push(scannedCode);
textarea.value = existing.join('\n');
setCode4ScanStatus(`Code ${scannedCode} hinzugefügt (${existing.length}/${itemCount}).`);
return true;
isbnField.classList.add('code-invalid');
statusEl.textContent = 'Kein gültiges ISBN-10/13-Format erkannt. Der Wert wird trotzdem akzeptiert.';
statusEl.style.color = '#b00020';
}
function startCode4Scanner() {
@@ -1027,7 +1075,7 @@
rememberLastUsedCamera: true
});
code4ScannerRunning = true;
setCode4ScanStatus('Scanner läuft. Mehrere Codes nacheinander möglich.');
setCode4ScanStatus('Scanner läuft. Der Scan wird im Basis-Codefeld übernommen.');
code4ScannerInstance.render((decodedText) => {
const scannedCode = String(decodedText || '').trim();
@@ -1040,12 +1088,9 @@
code4LastScanned = scannedCode;
code4LastScannedAt = now;
const usedForIndividual = appendIndividualCode(scannedCode);
if (!usedForIndividual) {
codeField.value = scannedCode;
validateCodeField(codeField);
setCode4ScanStatus(`Code_4 gesetzt: ${scannedCode}`);
}
codeField.value = scannedCode;
validateCodeField(codeField);
setCode4ScanStatus(`Code_4 gesetzt: ${scannedCode}`);
}, () => {});
}
@@ -1094,21 +1139,23 @@
if (!scannedCode) return;
const normalizedIsbn = normalizeIsbnClient(scannedCode);
if (!normalizedIsbn) {
setIsbnScanStatus('Kein gültiger ISBN-Barcode erkannt. Bitte erneut scannen.', true);
return;
isbnField.value = normalizedIsbn || scannedCode;
updateIsbnLiveValidation();
if (normalizedIsbn) {
setIsbnScanStatus(`ISBN gesetzt: ${normalizedIsbn}`);
} else {
setIsbnScanStatus('Kein gültiges ISBN-Format erkannt, der gescannte Wert bleibt aber im Feld.', true);
}
isbnField.value = normalizedIsbn;
setIsbnScanStatus(`ISBN gesetzt: ${normalizedIsbn}`);
isbnScannerInstance.clear().catch(() => {});
scannerBox.style.display = 'none';
scanButton.textContent = 'ISBN scannen';
isbnScannerRunning = false;
// Automatically load book metadata after scan.
fetchBookInfo('upload');
if (normalizedIsbn) {
// Automatically load book metadata after a valid ISBN scan.
fetchBookInfo('upload');
}
}, () => {});
}
@@ -1231,7 +1278,7 @@
const isbn = normalizeIsbnClient(isbnField.value);
if (!isbn) {
alert('Bitte eine gültige ISBN-10 oder ISBN-13 eingeben.');
infoContainer.innerHTML = '<div class="error-message">Bitte geben Sie eine ISBN oder einen Barcode ein.</div>';
return;
}
@@ -1645,6 +1692,13 @@
if (scanIsbnBtn) {
scanIsbnBtn.addEventListener('click', startIsbnScanner);
}
const isbnField = document.getElementById('isbn');
if (isbnField) {
isbnField.addEventListener('input', updateIsbnLiveValidation);
isbnField.addEventListener('blur', updateIsbnLiveValidation);
updateIsbnLiveValidation();
}
// Setup add new location button
const addLocationBtn = document.getElementById('add-new-location-btn');
+302 -49
View File
@@ -9,6 +9,8 @@ Each tenant can support up to 20+ users with isolated data and resource pools.
from flask import request, g, session, has_request_context
from functools import wraps
import datetime
import json
import logging
import os
import re
@@ -18,10 +20,48 @@ from Web.modules.database.settings import MongoClient
logger = logging.getLogger(__name__)
_TENANT_REGISTRY_MTIME = None
def _load_tenant_registry_from_config():
registry = {}
config_path = getattr(cfg, 'CONFIG_PATH', None)
if config_path and os.path.isfile(config_path):
try:
with open(config_path, 'r', encoding='utf-8') as handle:
config = json.load(handle)
tenants = config.get('tenants', {})
if isinstance(tenants, dict):
registry.update(tenants)
except Exception as exc:
logger.warning("Failed to load tenant registry from config: %s", exc)
elif isinstance(getattr(cfg, 'TENANT_CONFIGS', None), dict):
registry.update(cfg.TENANT_CONFIGS)
return registry
def _refresh_tenant_registry():
global TENANT_REGISTRY, _TENANT_REGISTRY_MTIME
config_path = getattr(cfg, 'CONFIG_PATH', None)
current_mtime = None
if config_path and os.path.isfile(config_path):
try:
current_mtime = os.path.getmtime(config_path)
except OSError:
current_mtime = None
if current_mtime == _TENANT_REGISTRY_MTIME:
return TENANT_REGISTRY
TENANT_REGISTRY.clear()
TENANT_REGISTRY.update(_load_tenant_registry_from_config())
_TENANT_REGISTRY_MTIME = current_mtime
return TENANT_REGISTRY
# Tenant registry: maps subdomain/tenant_id to database name
TENANT_REGISTRY = {}
if isinstance(getattr(cfg, 'TENANT_CONFIGS', None), dict):
TENANT_REGISTRY.update(cfg.TENANT_CONFIGS)
TENANT_REGISTRY = _load_tenant_registry_from_config()
def _get_nested_value(source, path, default=None):
@@ -66,6 +106,27 @@ def _parse_port_from_host(host):
return host, None
def _first_host_token(value):
raw = str(value or '').strip()
if not raw:
return ''
return raw.split(',', 1)[0].strip().lower()
def _request_host_candidates():
candidates = []
for header_name in ('X-Forwarded-Host', 'X-Original-Host', 'Host'):
token = _first_host_token(request.headers.get(header_name, ''))
if token and token not in candidates:
candidates.append(token)
direct_host = _first_host_token(getattr(request, 'host', ''))
if direct_host and direct_host not in candidates:
candidates.append(direct_host)
return candidates
def _tenant_id_for_port(port):
"""Map a host port to a registered tenant ID via tenant configs or env overrides."""
for tenant_id, config in TENANT_REGISTRY.items():
@@ -118,6 +179,8 @@ def _is_ip_host(hostname):
def get_tenant_config(tenant_id=None):
"""Return the registered config for a tenant, falling back to default."""
_refresh_tenant_registry()
if tenant_id is None:
ctx = get_tenant_context()
tenant_id = ctx.tenant_id if ctx and ctx.tenant_id else 'default'
@@ -211,6 +274,183 @@ def is_tenant_module_enabled(module_name, tenant_id=None, default=False):
return bool(default)
def _parse_datetime_value(value):
if value is None or value == '':
return None
if isinstance(value, datetime.datetime):
parsed = value
else:
text = str(value).strip()
if not text:
return None
try:
parsed = datetime.datetime.fromisoformat(text.replace('Z', '+00:00'))
except ValueError:
return None
if parsed.tzinfo is not None:
parsed = parsed.astimezone(datetime.timezone.utc).replace(tzinfo=None)
return parsed
def get_tenant_trial_config(tenant_id=None):
"""Return the optional trial/demo lifecycle settings for a tenant."""
config = get_tenant_config(tenant_id)
trial_config = _get_nested_value(config, ['trial'], {})
if not isinstance(trial_config, dict):
trial_config = {}
demo_config = _get_nested_value(config, ['demo'], {})
if isinstance(demo_config, dict):
merged = dict(demo_config)
merged.update(trial_config)
trial_config = merged
return trial_config
def get_tenant_trial_status(tenant_id=None, now=None):
"""Compute the current trial status for a tenant.
The config may define either an absolute "expires_at" timestamp or a
relative lifetime via "started_at"/"created_at" plus one of
"expires_after_days", "ttl_days", or "days".
"""
trial_config = get_tenant_trial_config(tenant_id)
now = now or datetime.datetime.now()
enabled = bool(trial_config.get('enabled') or trial_config.get('active'))
if not enabled:
return {
'enabled': False,
'expired': False,
'auto_delete': bool(trial_config.get('auto_delete', False)),
'started_at': None,
'expires_at': None,
'days_left': None,
}
started_at = _parse_datetime_value(
trial_config.get('started_at')
or trial_config.get('created_at')
or trial_config.get('activated_at')
)
expires_at = _parse_datetime_value(trial_config.get('expires_at'))
if expires_at is None:
duration_days = trial_config.get('expires_after_days')
if duration_days is None:
duration_days = trial_config.get('ttl_days')
if duration_days is None:
duration_days = trial_config.get('days')
try:
duration_days = int(duration_days) if duration_days is not None else None
except (TypeError, ValueError):
duration_days = None
if duration_days is not None:
base_time = started_at or now
expires_at = base_time + datetime.timedelta(days=max(0, duration_days))
expired = bool(expires_at and now >= expires_at)
days_left = None
if expires_at:
remaining = expires_at - now
days_left = max(0, int(remaining.total_seconds() // 86400))
return {
'enabled': True,
'expired': expired,
'auto_delete': bool(trial_config.get('auto_delete', False)),
'started_at': started_at,
'expires_at': expires_at,
'days_left': days_left,
}
def _get_tenant_db_name_from_config(tenant_id):
config = get_tenant_config(tenant_id)
explicit_db = None
if isinstance(config, dict):
explicit_db = config.get('db') or config.get('db_name')
if explicit_db:
return _normalize_db_name(explicit_db)
sanitized = ''.join(c if c.isalnum() or c == '_' else '' for c in str(tenant_id).lower())
return f'inventar_{sanitized}' if sanitized else cfg.MONGODB_DB
def delete_tenant(tenant_id, *, drop_database=True, remove_from_config=True):
"""Delete a tenant's runtime data and optionally remove its config entry."""
tenant_id = str(tenant_id or '').strip()
if not tenant_id:
return False
db_name = _get_tenant_db_name_from_config(tenant_id)
if drop_database:
try:
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
try:
client.drop_database(db_name)
finally:
client.close()
except Exception as exc:
logger.warning("Failed to drop tenant database %s for %s: %s", db_name, tenant_id, exc)
if remove_from_config:
try:
config_path = getattr(cfg, 'CONFIG_PATH', None)
if config_path and os.path.isfile(config_path):
with open(config_path, 'r', encoding='utf-8') as handle:
config = json.load(handle)
tenants = config.get('tenants', {})
if not isinstance(tenants, dict):
tenants = {}
aliases = set(_tenant_db_aliases(tenant_id))
aliases.add(tenant_id)
lowered = tenant_id.lower()
if lowered.startswith('schule'):
aliases.add('school' + lowered[len('schule'):])
elif lowered.startswith('school'):
aliases.add('schule' + lowered[len('school'):])
removed_any = False
for alias in aliases:
if alias in tenants:
tenants.pop(alias, None)
removed_any = True
if removed_any:
config['tenants'] = tenants
with open(config_path, 'w', encoding='utf-8') as handle:
json.dump(config, handle, indent=4, ensure_ascii=False)
for alias in [tenant_id, *_tenant_db_aliases(tenant_id)]:
TENANT_REGISTRY.pop(alias, None)
except Exception as exc:
logger.warning("Failed to remove tenant config for %s: %s", tenant_id, exc)
return True
def purge_expired_trial_tenants(now=None):
"""Delete expired trial tenants that opted into auto-delete."""
now = now or datetime.datetime.now()
purged_tenants = []
for tenant_id in list(TENANT_REGISTRY.keys()):
status = get_tenant_trial_status(tenant_id, now=now)
if status.get('enabled') and status.get('expired') and status.get('auto_delete'):
if delete_tenant(tenant_id):
purged_tenants.append(tenant_id)
return purged_tenants
class TenantContext:
"""
Manages current tenant context for request lifecycle.
@@ -241,54 +481,67 @@ class TenantContext:
session['tenant_id'] = matched_tenant
return self._get_db_name(matched_tenant)
# Priority 2: Port-based tenant mapping
host = request.host.lower()
hostname, port = _parse_port_from_host(host)
self.port = port
logger.info(f"Tenant resolution start: request.host={host} request.headers={dict(request.headers)}")
if port:
tenant_from_port = _tenant_id_for_port(port)
if tenant_from_port:
self.tenant_id = tenant_from_port
self.config = get_tenant_config(tenant_from_port)
session['tenant_id'] = tenant_from_port
logger.info(
f"Tenant resolution by port: host={host} port={port} tenant={tenant_from_port} config={self.config}"
)
return self._get_db_name(tenant_from_port)
logger.info(f"Tenant port not mapped: host={host} port={port}")
# Priority 2: Port/host based tenant mapping
host_candidates = _request_host_candidates()
primary_host = host_candidates[0] if host_candidates else _first_host_token(getattr(request, 'host', ''))
logger.info(
"Tenant resolution start: request.host=%s host_candidates=%s request.headers=%s",
getattr(request, 'host', ''),
host_candidates,
dict(request.headers),
)
for host in host_candidates:
hostname, port = _parse_port_from_host(host)
if port:
self.port = port
tenant_from_port = _tenant_id_for_port(port)
if tenant_from_port:
self.tenant_id = tenant_from_port
self.config = get_tenant_config(tenant_from_port)
session['tenant_id'] = tenant_from_port
logger.info(
f"Tenant resolution by port: host={host} port={port} tenant={tenant_from_port} config={self.config}"
)
return self._get_db_name(tenant_from_port)
logger.info(f"Tenant port not mapped: host={host} port={port}")
# Priority 3: Subdomain extraction
host_without_port = (hostname or '').strip().lower()
direct_host_match = _find_registered_tenant_id(host_without_port)
if direct_host_match:
self.subdomain = host_without_port
self.tenant_id = direct_host_match
self.config = get_tenant_config(direct_host_match)
session['tenant_id'] = direct_host_match
logger.info(
f"Tenant resolution by direct host match: host={host} tenant={direct_host_match} config={self.config}"
)
return self._get_db_name(direct_host_match)
for host in host_candidates:
host_without_port, _ = _parse_port_from_host(host)
host_without_port = (host_without_port or '').strip().lower()
if not host_without_port:
continue
if host_without_port and not _is_ip_host(host_without_port):
parts = host_without_port.split('.')
if len(parts) >= 2:
potential_subdomain = parts[0]
if potential_subdomain not in ('www', 'api', 'admin', 'app', 'mail'):
matched_tenant = _find_registered_tenant_id(potential_subdomain)
if matched_tenant:
self.subdomain = potential_subdomain
self.tenant_id = matched_tenant
self.config = get_tenant_config(matched_tenant)
session['tenant_id'] = matched_tenant
logger.info(
f"Tenant resolution by subdomain: host={host} tenant={matched_tenant} config={self.config}"
)
return self._get_db_name(matched_tenant)
logger.info(f"Tenant subdomain not registered: {potential_subdomain}")
else:
logger.info(f"Tenant subdomain ignored: {potential_subdomain}")
direct_host_match = _find_registered_tenant_id(host_without_port)
if direct_host_match:
self.subdomain = host_without_port
self.tenant_id = direct_host_match
self.config = get_tenant_config(direct_host_match)
session['tenant_id'] = direct_host_match
logger.info(
f"Tenant resolution by direct host match: host={host} tenant={direct_host_match} config={self.config}"
)
return self._get_db_name(direct_host_match)
if host_without_port and not _is_ip_host(host_without_port):
parts = host_without_port.split('.')
if len(parts) >= 2:
potential_subdomain = parts[0]
if potential_subdomain not in ('www', 'api', 'admin', 'app', 'mail'):
matched_tenant = _find_registered_tenant_id(potential_subdomain)
if matched_tenant:
self.subdomain = potential_subdomain
self.tenant_id = matched_tenant
self.config = get_tenant_config(matched_tenant)
session['tenant_id'] = matched_tenant
logger.info(
f"Tenant resolution by subdomain: host={host} tenant={matched_tenant} config={self.config}"
)
return self._get_db_name(matched_tenant)
logger.info(f"Tenant subdomain not registered: {potential_subdomain}")
else:
logger.info(f"Tenant subdomain ignored: {potential_subdomain}")
# Priority 4: sticky tenant from the authenticated session
session_tenant = session.get('tenant_id', '').strip() if session.get('tenant_id') else ''
@@ -296,7 +549,7 @@ class TenantContext:
self.tenant_id = session_tenant
self.config = get_tenant_config(session_tenant)
logger.info(
f"Tenant resolution by session: host={host} tenant={session_tenant} config={self.config}"
f"Tenant resolution by session: host={primary_host} tenant={session_tenant} config={self.config}"
)
return self._get_db_name(session_tenant)
+1
View File
@@ -95,6 +95,7 @@ services:
INVENTAR_MONGODB_HOST: mongodb
INVENTAR_MONGODB_PORT: "27017"
INVENTAR_MONGODB_DB: inventar_default
INVENTAR_CONFIG_PATH: /app/config.json
# Redis Configuration (for sessions and caching)
INVENTAR_REDIS_HOST: redis
+202
View File
@@ -56,6 +56,7 @@ show_help() {
echo ""
echo "Commands:"
echo " add <tenant_id> [port] Add a new tenant (initializes database)"
echo " trial <tenant_id> [port] [days] Create a 7-day demo tenant that auto-deletes after expiry"
echo " remove <tenant_id> Remove a tenant completely (deletes data!)"
echo " restart-tenant <id> 'Restart' a single tenant (clears cache/sessions)"
echo " restart-all Restart all application containers (zero-downtime reload)"
@@ -65,6 +66,7 @@ show_help() {
echo ""
echo "Examples:"
echo " ./manage-tenant.sh add school_a 10001"
echo " ./manage-tenant.sh trial school_demo 10002 7"
echo " ./manage-tenant.sh remove test_tenant"
echo " ./manage-tenant.sh module school_a inventory=off library=on"
echo " ./manage-tenant.sh restart-all"
@@ -137,6 +139,176 @@ PY
fi
}
write_trial_tenant_config() {
local tenant_id="$1"
local port="$2"
local trial_days="$3"
if python3 - <<'PY' "$CONFIG_FILE" "$tenant_id" "$port" "$trial_days"
import json, sys, os, datetime
path, tenant_id, port_str, trial_days_str = sys.argv[1], sys.argv[2], sys.argv[3], sys.argv[4]
if not os.path.isfile(path):
print(f"Error: config file not found: {path}", file=sys.stderr)
sys.exit(1)
with open(path, 'r', encoding='utf-8') as f:
cfg = json.load(f)
tenants = cfg.get('tenants')
if tenants is None or not isinstance(tenants, dict):
tenants = {}
aliases = {tenant_id}
normalized = tenant_id.lower()
if normalized.startswith('schule'):
aliases.add('school' + normalized[len('schule'):])
elif normalized.startswith('school'):
aliases.add('schule' + normalized[len('school'):])
for tid, conf in tenants.items():
if port_str and isinstance(conf, dict) and str(conf.get('port')) == port_str and tid not in aliases:
print(f"Error: port {port_str} is already mapped to tenant {tid}", file=sys.stderr)
sys.exit(2)
try:
trial_days = max(1, int(trial_days_str))
except ValueError:
print(f"Error: trial days must be numeric, got {trial_days_str!r}", file=sys.stderr)
sys.exit(3)
now = datetime.datetime.now(datetime.timezone.utc).isoformat()
trial_config = {
'enabled': True,
'auto_delete': True,
'days': trial_days,
'ttl_days': trial_days,
'expires_after_days': trial_days,
'started_at': now,
'created_at': now,
}
existing = {}
for alias in aliases:
alias_cfg = tenants.get(alias)
if isinstance(alias_cfg, dict):
existing = alias_cfg
break
if port_str:
existing['port'] = int(port_str)
existing['modules'] = {
'inventory': {'enabled': True},
'library': {'enabled': True},
'student_cards': {'enabled': True, 'default_borrow_days': 14, 'max_borrow_days': 365},
}
existing['trial'] = trial_config
for alias in aliases:
tenants[alias] = dict(existing)
cfg['tenants'] = tenants
with open(path, 'w', encoding='utf-8') as f:
json.dump(cfg, f, indent=4, ensure_ascii=False)
print(f"Configured trial tenant {tenant_id} with {trial_days} day(s) and auto-delete enabled")
PY
then
echo "Trial tenant $tenant_id configured in config.json"
else
echo "Failed to configure trial tenant $tenant_id"
exit 1
fi
}
initialize_tenant_database() {
local tenant_id="$1"
local mode="$2"
APP_CONTAINER=$(docker ps -qf "name=app" | head -n 1)
if [ -z "$APP_CONTAINER" ]; then
echo "Warning: Application container is not running. Please start the multi-tenant system first."
echo "Data will be initialized upon first access by the tenant."
return 0
fi
docker exec "$APP_CONTAINER" python3 -c '
import sys, re, datetime, hashlib
sys.path.insert(0, "/app")
sys.path.insert(0, "/app/Web")
from Web.modules.database import settings
from pymongo import MongoClient
tenant_id = sys.argv[1].lower()
mode = sys.argv[2]
sanitized = "".join(c for c in tenant_id if c.isalnum() or c == "_")
db_name = f"inventar_{sanitized}"
client = MongoClient(settings.MONGODB_HOST, int(settings.MONGODB_PORT))
db = client[db_name]
hashed_pw = hashlib.sha512("admin123".encode()).hexdigest()
action_permissions = {
"can_borrow": True,
"can_insert": True,
"can_edit": True,
"can_delete": True,
"can_manage_users": True,
"can_manage_settings": True,
"can_view_logs": True,
}
page_permissions = {
"home": True,
"tutorial_page": True,
"my_borrowed_items": True,
"notifications_view": True,
"impressum": True,
"license": True,
"library_view": True,
"terminplan": True,
"home_admin": True,
"upload_admin": True,
"library_admin": True,
"admin_borrowings": True,
"library_loans_admin": True,
"admin_damaged_items": True,
"admin_audit_dashboard": True,
"logs": True,
"manage_filters": True,
"manage_locations": True,
}
if db.users.count_documents({"Username": "admin"}) == 0:
db.users.insert_one({
"Username": "admin",
"Password": hashed_pw,
"Admin": True,
"active_ausleihung": None,
"name": "Admin",
"last_name": "User",
"IsStudent": False,
"PermissionPreset": "full_access",
"ActionPermissions": action_permissions,
"PagePermissions": page_permissions,
})
if mode == "trial":
db.settings.update_one(
{"setting_type": "tenant_trial"},
{"$set": {
"setting_type": "tenant_trial",
"enabled": True,
"auto_delete": True,
"days": 7,
"created_at": datetime.datetime.now(datetime.timezone.utc).isoformat(),
}},
upsert=True,
)
print(f"Tenant {sys.argv[1]} database initialized. Default admin: admin / admin123")
' "$tenant_id" "$mode"
}
update_runtime_ports() {
local new_port="$1"
local env_file="$PWD/.docker-build.env"
@@ -471,6 +643,36 @@ print(f'Tenant {sys.argv[1]} database initialized. Default admin: admin / admin1
echo "Data will be initialized upon first access by the tenant."
fi
;;
trial)
if [ -z "$TENANT_ID" ]; then
echo "Error: Please provide a tenant_id."
exit 1
fi
PORT_ARG="${3:-}"
DAYS_ARG="${4:-7}"
if [ -n "$PORT_ARG" ]; then
if ! printf '%s\n' "$PORT_ARG" | grep -qE '^[0-9]+$'; then
echo "Error: Port must be a numeric value."
exit 1
fi
register_tenant_port "$TENANT_ID" "$PORT_ARG"
update_runtime_ports "$PORT_ARG"
sync_tenant_port_map
fi
write_trial_tenant_config "$TENANT_ID" "$PORT_ARG" "$DAYS_ARG"
if [ -n "$(docker ps -qf 'name=app' | head -n 1)" ]; then
restart_app_container
fi
echo "Initializing trial database for $TENANT_ID..."
initialize_tenant_database "$TENANT_ID" "trial"
echo "Trial tenant '$TENANT_ID' successfully configured. It will expire after $DAYS_ARG day(s) and self-delete."
;;
remove)
if [ -z "$TENANT_ID" ]; then