Compare commits
30 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 451b7bd3c5 | |||
| 84543c8734 | |||
| 8a64a1adcd | |||
| 25b7fe986c | |||
| 4b376bc426 | |||
| e41d739636 | |||
| 4e73d69245 | |||
| daffeaf379 | |||
| a1ad354ccf | |||
| d4d5cd6436 | |||
| 9fd9d63e31 | |||
| 08cce1b884 | |||
| adde73dffb | |||
| da17667d90 | |||
| c69231fa77 | |||
| ac43178b29 | |||
| a2d58208e6 | |||
| f8171a5f18 | |||
| 6f898ffea4 | |||
| b29cc38a3d | |||
| 17745c64e0 | |||
| a0279f82e1 | |||
| 8d1e3865d3 | |||
| 79be502aa1 | |||
| d0f54f0dca | |||
| 2b00aab1fa | |||
| 6d4ac073a5 | |||
| 73ab1a37d2 | |||
| 0eb1ffe9d9 | |||
| 0ea1294237 |
@@ -134,14 +134,22 @@ jobs:
|
||||
echo "push_dev=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Update .release-version file
|
||||
run: |
|
||||
echo "${{ steps.meta.outputs.tag }}" > .release-version
|
||||
cat .release-version
|
||||
|
||||
- name: Create and push tag for manual releases
|
||||
if: github.event_name == 'workflow_dispatch'
|
||||
run: |
|
||||
TAG="${{ steps.meta.outputs.tag }}"
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git add .release-version
|
||||
git commit -m "chore: bump version to $TAG" || true
|
||||
git tag "$TAG"
|
||||
git push origin "$TAG"
|
||||
git push origin HEAD:${{ github.ref_name }}
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
@@ -198,6 +206,17 @@ jobs:
|
||||
|
||||
# development tar omitted: dev releases will be versioned (vX.Y.Z-dev) and handled by update.sh using the tag
|
||||
|
||||
- name: Commit .release-version for tag pushes
|
||||
if: github.event_name == 'push'
|
||||
run: |
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
if ! git diff --quiet .release-version; then
|
||||
git add .release-version
|
||||
git commit -m "chore: update version to ${{ steps.meta.outputs.tag }}"
|
||||
git push origin HEAD:${{ github.ref_name }}
|
||||
fi
|
||||
|
||||
- name: Create release-only docker bundle
|
||||
run: |
|
||||
mkdir -p release-bundle
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
v0.7.42
|
||||
v0.8.0
|
||||
|
||||
+261
-65
@@ -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
|
||||
@@ -120,7 +112,7 @@ app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1, x_port=1)
|
||||
"""--------------------------------------------------------------Path Init-------------------------------------------------------"""
|
||||
|
||||
if not os.path.exists(app.config['UPLOAD_FOLDER']):
|
||||
os.makedirs(app.config['UPLOAD_FOLDER'])
|
||||
os.makedirs(app.config['UPLOAD_FOLDER'], exist_ok=True)
|
||||
# QR Code directory creation deactivated
|
||||
# if not os.path.exists(app.config['QR_CODE_FOLDER']):
|
||||
# os.makedirs(app.config['QR_CODE_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'))
|
||||
|
||||
|
||||
@@ -2415,11 +2486,7 @@ def preview_file(filename):
|
||||
if denied:
|
||||
return denied
|
||||
|
||||
# Check production path first
|
||||
prod_path = "/var/Inventarsystem/Web/previews"
|
||||
dev_path = app.config['PREVIEW_FOLDER']
|
||||
if os.path.exists(os.path.join(prod_path, filename)):
|
||||
return send_from_directory(prod_path, filename)
|
||||
if os.path.exists(os.path.join(dev_path, filename)):
|
||||
return send_from_directory(dev_path, filename)
|
||||
|
||||
@@ -2798,7 +2865,7 @@ def library_export_excel(scope='all'):
|
||||
client.close()
|
||||
|
||||
excel_file = excel_export.generate_library_excel(items)
|
||||
return flask.send_file(
|
||||
return send_file(
|
||||
excel_file,
|
||||
mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
as_attachment=True,
|
||||
@@ -3008,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 = {}
|
||||
@@ -3032,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,
|
||||
@@ -3342,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
|
||||
@@ -4181,6 +4337,7 @@ def logout():
|
||||
session.pop('is_admin', None)
|
||||
session.pop('favorites', None)
|
||||
session.pop('favorites_owner', None)
|
||||
session.pop('tenant_id', None)
|
||||
return redirect(url_for('login'))
|
||||
|
||||
|
||||
@@ -4752,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'):
|
||||
@@ -4770,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')
|
||||
@@ -5561,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,
|
||||
@@ -6112,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.',
|
||||
@@ -6194,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.',
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -12,6 +12,7 @@ Provides methods for creating, validating, and retrieving user information.
|
||||
'''
|
||||
import hashlib
|
||||
import copy
|
||||
import importlib
|
||||
import logging
|
||||
import re
|
||||
import secrets
|
||||
@@ -59,6 +60,35 @@ def _get_tenant_db(client):
|
||||
return client[cfg.MONGODB_DB]
|
||||
|
||||
|
||||
def _has_tenant_configs():
|
||||
for module_name in ('tenant', 'Web.tenant'):
|
||||
try:
|
||||
tenant_module = importlib.import_module(module_name)
|
||||
tenant_registry = getattr(tenant_module, 'TENANT_REGISTRY', None)
|
||||
if isinstance(tenant_registry, dict) and tenant_registry:
|
||||
return True
|
||||
except Exception:
|
||||
continue
|
||||
return isinstance(getattr(cfg, 'TENANT_CONFIGS', None), dict) and bool(cfg.TENANT_CONFIGS)
|
||||
|
||||
|
||||
def _resolve_request_tenant_db():
|
||||
for module_name in ('tenant', 'Web.tenant'):
|
||||
try:
|
||||
tenant_module = importlib.import_module(module_name)
|
||||
get_tenant_context = getattr(tenant_module, 'get_tenant_context', None)
|
||||
if not callable(get_tenant_context):
|
||||
continue
|
||||
ctx = get_tenant_context()
|
||||
if ctx and ctx.tenant_id:
|
||||
return ctx.db_name or ctx.resolve_tenant(), ctx.tenant_id
|
||||
if ctx and ctx.db_name and not _has_tenant_configs():
|
||||
return ctx.db_name, None
|
||||
except Exception as exc:
|
||||
logger.debug("Tenant context import %s failed: %s", module_name, exc)
|
||||
return None, None
|
||||
|
||||
|
||||
def build_name_synonym(first_name, last_name=''):
|
||||
"""Build a deterministic, non-personalized short alias from 2 letters each."""
|
||||
first = _clean_name_fragment(first_name)
|
||||
@@ -424,22 +454,26 @@ def check_nm_pwd(username, password):
|
||||
Returns:
|
||||
dict: User document if credentials are valid, None otherwise
|
||||
"""
|
||||
db_name = cfg.MONGODB_DB
|
||||
tenant_db = None
|
||||
db_name, tenant_id = _resolve_request_tenant_db()
|
||||
ctx = None
|
||||
try:
|
||||
from tenant import get_tenant_context
|
||||
ctx = get_tenant_context()
|
||||
if ctx and ctx.tenant_id:
|
||||
tenant_db = ctx.db_name or ctx.resolve_tenant()
|
||||
db_name = tenant_db
|
||||
except Exception as exc:
|
||||
logger.exception(f"Failed to resolve tenant context in check_nm_pwd: {exc}")
|
||||
except Exception:
|
||||
ctx = None
|
||||
|
||||
if not db_name:
|
||||
if _has_tenant_configs():
|
||||
logger.warning(
|
||||
"Refusing default DB fallback for login because tenant configs exist and no tenant was resolved."
|
||||
)
|
||||
return None
|
||||
db_name = cfg.MONGODB_DB
|
||||
|
||||
logger.info(
|
||||
"check_nm_pwd start: username=%r tenant=%r db=%r host=%r port=%r uri=%r",
|
||||
username,
|
||||
ctx.tenant_id if ctx else None,
|
||||
tenant_id,
|
||||
db_name,
|
||||
cfg.MONGODB_HOST,
|
||||
cfg.MONGODB_PORT,
|
||||
@@ -644,15 +678,17 @@ def get_user(username):
|
||||
return users.find_one({'Username': username}) or users.find_one({'username': username})
|
||||
|
||||
# Try current tenant first when available
|
||||
try:
|
||||
from tenant import get_tenant_context
|
||||
ctx = get_tenant_context()
|
||||
if ctx and ctx.db_name:
|
||||
user = find_in_db(ctx.db_name)
|
||||
if user:
|
||||
return user
|
||||
except Exception:
|
||||
pass
|
||||
tenant_db, tenant_id = _resolve_request_tenant_db()
|
||||
if tenant_db:
|
||||
user = find_in_db(tenant_db)
|
||||
if user:
|
||||
return user
|
||||
|
||||
if _has_tenant_configs() and tenant_db is None:
|
||||
logger.warning(
|
||||
"Refusing default DB fallback for user lookup because tenant configs exist and no tenant was resolved."
|
||||
)
|
||||
return None
|
||||
|
||||
# Fallback to default configured database
|
||||
user = find_in_db(cfg.MONGODB_DB)
|
||||
|
||||
@@ -1 +1 @@
|
||||
# Web.modules.logs package initialization
|
||||
# Web.modules.log package initialization
|
||||
|
||||
+26
-35
@@ -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;
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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
@@ -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 & 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 -->
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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');
|
||||
|
||||
+371
-39
@@ -7,20 +7,61 @@ Supports subdomain-based tenant identification and per-tenant database namespaci
|
||||
Each tenant can support up to 20+ users with isolated data and resource pools.
|
||||
"""
|
||||
|
||||
from flask import request, g, has_request_context
|
||||
from flask import request, g, session, has_request_context
|
||||
from functools import wraps
|
||||
import datetime
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import ipaddress
|
||||
import Web.modules.database.settings as cfg
|
||||
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):
|
||||
@@ -65,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():
|
||||
@@ -88,8 +150,37 @@ def _tenant_id_for_port(port):
|
||||
return None
|
||||
|
||||
|
||||
def _find_registered_tenant_id(candidate):
|
||||
candidate = str(candidate or '').strip()
|
||||
if not candidate:
|
||||
return None
|
||||
|
||||
if candidate in TENANT_REGISTRY:
|
||||
return candidate
|
||||
|
||||
lowered = candidate.lower()
|
||||
for tenant_id in TENANT_REGISTRY:
|
||||
if str(tenant_id).lower() == lowered:
|
||||
return tenant_id
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _is_ip_host(hostname):
|
||||
hostname = str(hostname or '').strip()
|
||||
if not hostname:
|
||||
return False
|
||||
try:
|
||||
ipaddress.ip_address(hostname)
|
||||
return True
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
|
||||
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'
|
||||
@@ -113,6 +204,28 @@ def _normalize_db_name(db_name):
|
||||
return db_name
|
||||
|
||||
|
||||
def _module_name_candidates(module_name):
|
||||
normalized = str(module_name or '').strip().lower().replace('-', '_')
|
||||
if not normalized:
|
||||
return []
|
||||
|
||||
candidates = [normalized]
|
||||
alias_groups = {
|
||||
'inventory': {'inventory', 'inventar'},
|
||||
'library': {'library', 'bib', 'bibliothek'},
|
||||
'student_cards': {'student_cards', 'studentcards', 'schuelerausweise', 'schueler_ausweise'},
|
||||
}
|
||||
|
||||
for canonical_name, aliases in alias_groups.items():
|
||||
if normalized in aliases:
|
||||
for alias in (canonical_name, *sorted(aliases)):
|
||||
if alias not in candidates:
|
||||
candidates.append(alias)
|
||||
break
|
||||
|
||||
return candidates
|
||||
|
||||
|
||||
def _tenant_db_aliases(tenant_id):
|
||||
aliases = []
|
||||
env_map = _parse_tenant_db_map()
|
||||
@@ -153,8 +266,189 @@ def _resolve_db_alias(tenant_id, db_name):
|
||||
def is_tenant_module_enabled(module_name, tenant_id=None, default=False):
|
||||
"""Resolve whether a feature module is enabled for the current tenant."""
|
||||
config = get_tenant_config(tenant_id)
|
||||
enabled = _get_nested_value(config, ['modules', module_name, 'enabled'], default)
|
||||
return bool(enabled)
|
||||
for candidate_name in _module_name_candidates(module_name):
|
||||
enabled = _get_nested_value(config, ['modules', candidate_name, 'enabled'], None)
|
||||
if enabled is not None:
|
||||
return bool(enabled)
|
||||
|
||||
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:
|
||||
@@ -181,45 +475,83 @@ class TenantContext:
|
||||
# Priority 1: X-Tenant-ID header (for testing/internal APIs)
|
||||
tenant_from_header = request.headers.get('X-Tenant-ID', '').strip()
|
||||
if tenant_from_header:
|
||||
self.tenant_id = tenant_from_header
|
||||
self.config = get_tenant_config(tenant_from_header)
|
||||
return self._get_db_name(tenant_from_header)
|
||||
matched_tenant = _find_registered_tenant_id(tenant_from_header) or tenant_from_header
|
||||
self.tenant_id = matched_tenant
|
||||
self.config = get_tenant_config(matched_tenant)
|
||||
session['tenant_id'] = matched_tenant
|
||||
return self._get_db_name(matched_tenant)
|
||||
|
||||
# Priority 2: Port-based tenant mapping
|
||||
host = request.host.lower()
|
||||
_, 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)
|
||||
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
|
||||
parts = host.split('.')
|
||||
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
|
||||
|
||||
# Extract subdomain from host
|
||||
# Examples: schule1.example.com → schule1
|
||||
# app.example.com → app (skip wildcard/app)
|
||||
if len(parts) >= 3:
|
||||
potential_subdomain = parts[0]
|
||||
|
||||
# Filter out common non-tenant subdomains
|
||||
if potential_subdomain not in ('www', 'api', 'admin', 'app', 'mail'):
|
||||
self.subdomain = potential_subdomain
|
||||
self.tenant_id = potential_subdomain
|
||||
self.config = get_tenant_config(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 subdomain: host={host} tenant={potential_subdomain} config={self.config}"
|
||||
f"Tenant resolution by direct host match: host={host} tenant={direct_host_match} config={self.config}"
|
||||
)
|
||||
return self._get_db_name(potential_subdomain)
|
||||
logger.info(f"Tenant subdomain ignored: {potential_subdomain}")
|
||||
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 ''
|
||||
if session_tenant:
|
||||
self.tenant_id = session_tenant
|
||||
self.config = get_tenant_config(session_tenant)
|
||||
logger.info(
|
||||
f"Tenant resolution by session: host={primary_host} tenant={session_tenant} config={self.config}"
|
||||
)
|
||||
return self._get_db_name(session_tenant)
|
||||
|
||||
# Fallback to default tenant if no tenant identifier found.
|
||||
# If no explicit 'default' tenant config exists, use configured MongoDB DB.
|
||||
|
||||
@@ -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
|
||||
|
||||
+290
-21
@@ -9,7 +9,9 @@ if [ ! -f "docker-compose-multitenant.yml" ]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
CONFIG_FILE="$PWD/config.json"
|
||||
# Resolve script directory so config paths are deterministic even when called via sudo
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
CONFIG_FILE="$SCRIPT_DIR/config.json"
|
||||
|
||||
ensure_runtime_config_json() {
|
||||
local config_path backup_path
|
||||
@@ -54,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)"
|
||||
@@ -63,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"
|
||||
@@ -70,6 +74,24 @@ show_help() {
|
||||
exit 1
|
||||
}
|
||||
|
||||
tenant_aliases() {
|
||||
local tenant_id="$1"
|
||||
local normalized alias
|
||||
normalized="$(printf '%s' "$tenant_id" | tr '[:upper:]' '[:lower:]')"
|
||||
printf '%s\n' "$tenant_id"
|
||||
if [[ "$normalized" == schule* ]]; then
|
||||
alias="school${normalized#schule}"
|
||||
if [[ "$alias" != "$tenant_id" ]]; then
|
||||
printf '%s\n' "$alias"
|
||||
fi
|
||||
elif [[ "$normalized" == school* ]]; then
|
||||
alias="schule${normalized#school}"
|
||||
if [[ "$alias" != "$tenant_id" ]]; then
|
||||
printf '%s\n' "$alias"
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
register_tenant_port() {
|
||||
local tenant_id="$1"
|
||||
local port="$2"
|
||||
@@ -85,15 +107,25 @@ with open(path, 'r', encoding='utf-8') as 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 isinstance(conf, dict) and str(conf.get('port')) == port_str and tid != tenant_id:
|
||||
if 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)
|
||||
existing = tenants.get(tenant_id)
|
||||
if existing is None or not isinstance(existing, dict):
|
||||
existing = {}
|
||||
existing = {}
|
||||
for alias in aliases:
|
||||
alias_cfg = tenants.get(alias)
|
||||
if isinstance(alias_cfg, dict):
|
||||
existing = alias_cfg
|
||||
break
|
||||
existing['port'] = int(port_str)
|
||||
tenants[tenant_id] = existing
|
||||
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)
|
||||
@@ -107,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"
|
||||
@@ -212,13 +414,15 @@ EOF
|
||||
}
|
||||
|
||||
restart_app_container() {
|
||||
local env_file="$PWD/.docker-build.env"
|
||||
local workdir="$SCRIPT_DIR"
|
||||
local env_file="$SCRIPT_DIR/.docker-build.env"
|
||||
local compose_args=()
|
||||
|
||||
ensure_runtime_config_json
|
||||
|
||||
# If HOST_WORKDIR is set (called from container), use absolute paths so docker daemon resolves them correctly
|
||||
if [ -n "$HOST_WORKDIR" ]; then
|
||||
if [ -n "${HOST_WORKDIR:-}" ]; then
|
||||
workdir="$HOST_WORKDIR"
|
||||
compose_args+=( -f "$(readlink -f "$HOST_WORKDIR/docker-compose-multitenant.yml")" )
|
||||
if [ -f "$HOST_WORKDIR/.docker-compose.runtime.override.yml" ]; then
|
||||
compose_args+=( -f "$(readlink -f "$HOST_WORKDIR/.docker-compose.runtime.override.yml")" )
|
||||
@@ -228,9 +432,9 @@ restart_app_container() {
|
||||
fi
|
||||
else
|
||||
# Normal case: called directly from host
|
||||
compose_args+=( -f "$PWD/docker-compose-multitenant.yml" )
|
||||
if [ -f "$PWD/.docker-compose.runtime.override.yml" ]; then
|
||||
compose_args+=( -f "$PWD/.docker-compose.runtime.override.yml" )
|
||||
compose_args+=( -f "$workdir/docker-compose-multitenant.yml" )
|
||||
if [ -f "$workdir/.docker-compose.runtime.override.yml" ]; then
|
||||
compose_args+=( -f "$workdir/.docker-compose.runtime.override.yml" )
|
||||
fi
|
||||
if [ -f "$env_file" ]; then
|
||||
compose_args+=( --env-file "$env_file" )
|
||||
@@ -238,7 +442,7 @@ restart_app_container() {
|
||||
fi
|
||||
|
||||
# Pass along COMPOSE_PROJECT_NAME if set so the internal docker-compose sees it
|
||||
if [ -n "$COMPOSE_PROJECT_NAME" ]; then
|
||||
if [ -n "${COMPOSE_PROJECT_NAME:-}" ]; then
|
||||
compose_args=( -p "$COMPOSE_PROJECT_NAME" "${compose_args[@]}" )
|
||||
fi
|
||||
|
||||
@@ -259,9 +463,20 @@ if not os.path.isfile(path):
|
||||
with open(path, 'r', encoding='utf-8') as f:
|
||||
cfg = json.load(f)
|
||||
tenants = cfg.get('tenants', {})
|
||||
if not isinstance(tenants, dict) or tenant_id not in tenants or not isinstance(tenants[tenant_id], dict):
|
||||
if not isinstance(tenants, dict):
|
||||
sys.exit(2)
|
||||
removed = tenants.pop(tenant_id, None)
|
||||
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'):])
|
||||
removed = None
|
||||
for alias in list(aliases):
|
||||
alias_cfg = tenants.get(alias)
|
||||
if isinstance(alias_cfg, dict):
|
||||
removed = alias_cfg if removed is None else removed
|
||||
tenants.pop(alias, None)
|
||||
cfg['tenants'] = tenants
|
||||
with open(path, 'w', encoding='utf-8') as f:
|
||||
json.dump(cfg, f, indent=4, ensure_ascii=False)
|
||||
@@ -373,7 +588,7 @@ case "$COMMAND" in
|
||||
APP_CONTAINER=$(docker ps -qf "name=app" | head -n 1)
|
||||
if [ -n "$APP_CONTAINER" ]; then
|
||||
docker exec $APP_CONTAINER python3 -c "
|
||||
import sys, re; sys.path.insert(0, '/app/Web'); import settings; from pymongo import MongoClient; import hashlib
|
||||
import sys, re; sys.path.insert(0, '/app'); sys.path.insert(0, '/app/Web'); from Web.modules.database import settings; from pymongo import MongoClient; import hashlib
|
||||
tenant_id = sys.argv[1].lower()
|
||||
sanitized = ''.join(c for c in tenant_id if c.isalnum() or c == '_')
|
||||
db_name = f'inventar_{sanitized}'
|
||||
@@ -428,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
|
||||
@@ -441,7 +686,7 @@ print(f'Tenant {sys.argv[1]} database initialized. Default admin: admin / admin1
|
||||
APP_CONTAINER=$(docker ps -qf "name=app" | head -n 1)
|
||||
if [ -n "$APP_CONTAINER" ]; then
|
||||
docker exec $APP_CONTAINER python3 -c "
|
||||
import sys; sys.path.insert(0, '/app/Web'); from tenant import TenantContext; import settings; from pymongo import MongoClient
|
||||
import sys; sys.path.insert(0, '/app'); sys.path.insert(0, '/app/Web'); from tenant import TenantContext; from Web.modules.database import settings; from pymongo import MongoClient
|
||||
ctx = TenantContext()
|
||||
db_name = ctx._get_db_name(sys.argv[1])
|
||||
client = MongoClient(settings.MONGODB_HOST, int(settings.MONGODB_PORT))
|
||||
@@ -488,7 +733,7 @@ print(f'Database for tenant {sys.argv[1]} dropped.')
|
||||
APP_CONTAINER=$(docker ps -qf "name=app" | head -n 1)
|
||||
if [ -n "$APP_CONTAINER" ]; then
|
||||
docker exec $APP_CONTAINER python3 -c "
|
||||
import sys; sys.path.insert(0, '/app/Web'); import settings; from pymongo import MongoClient
|
||||
import sys; sys.path.insert(0, '/app'); sys.path.insert(0, '/app/Web'); from Web.modules.database import settings; from pymongo import MongoClient
|
||||
client = MongoClient(settings.MONGODB_HOST, int(settings.MONGODB_PORT))
|
||||
db = client[f'{settings.MONGODB_DB}_{sys.argv[1]}']
|
||||
db.sessions.drop() # Force sign-out / session clear
|
||||
@@ -535,8 +780,9 @@ PY
|
||||
if [ -n "$APP_CONTAINER" ]; then
|
||||
docker exec -i "$APP_CONTAINER" python3 - <<'PY'
|
||||
import sys
|
||||
sys.path.insert(0, '/app')
|
||||
sys.path.insert(0, '/app/Web')
|
||||
import settings
|
||||
from Web.modules.database import settings
|
||||
from pymongo import MongoClient
|
||||
client = MongoClient(settings.MONGODB_HOST, int(settings.MONGODB_PORT))
|
||||
prefix = 'inventar_'
|
||||
@@ -575,11 +821,26 @@ with open(path, 'r', encoding='utf-8') as f:
|
||||
cfg = json.load(f)
|
||||
|
||||
tenants = cfg.setdefault('tenants', {})
|
||||
tenant_cfg = tenants.setdefault(tenant_id, {})
|
||||
if 'port' not in tenant_cfg:
|
||||
print(f"Warning: Tenant {tenant_id} doesn't have a port mapping in config.json.", file=sys.stderr)
|
||||
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'):])
|
||||
|
||||
tenant_cfg = None
|
||||
for alias in aliases:
|
||||
alias_cfg = tenants.get(alias)
|
||||
if isinstance(alias_cfg, dict):
|
||||
tenant_cfg = alias_cfg
|
||||
break
|
||||
if tenant_cfg is None:
|
||||
tenant_cfg = {}
|
||||
|
||||
modules = tenant_cfg.setdefault('modules', {})
|
||||
port = tenant_cfg.get('port')
|
||||
if port is None:
|
||||
print(f"Warning: Tenant {tenant_id} doesn't have a port mapping in config.json.", file=sys.stderr)
|
||||
|
||||
for arg in module_args:
|
||||
if '=' not in arg:
|
||||
@@ -591,6 +852,14 @@ for arg in module_args:
|
||||
module_cfg['enabled'] = state
|
||||
print(f"Module '{mod_name}' set to '{'on' if state else 'off'}' for tenant '{tenant_id}'.")
|
||||
|
||||
for alias in aliases:
|
||||
alias_cfg = tenants.get(alias)
|
||||
if not isinstance(alias_cfg, dict):
|
||||
alias_cfg = {}
|
||||
alias_cfg.update(tenant_cfg)
|
||||
alias_cfg['modules'] = modules
|
||||
tenants[alias] = alias_cfg
|
||||
|
||||
with open(path, 'w', encoding='utf-8') as f:
|
||||
json.dump(cfg, f, indent=4, ensure_ascii=False)
|
||||
PY
|
||||
|
||||
Reference in New Issue
Block a user