Compare commits
28 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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.7.79-dev
|
||||
|
||||
+75
-27
@@ -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)")
|
||||
@@ -2415,11 +2467,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 +2846,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,
|
||||
@@ -4181,6 +4229,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 +4801,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 +4818,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')
|
||||
|
||||
@@ -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 = {}
|
||||
|
||||
@@ -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;
|
||||
|
||||
+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>
|
||||
|
||||
@@ -883,7 +883,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 +904,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>
|
||||
|
||||
@@ -965,33 +965,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 +1024,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 +1037,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 +1088,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 +1227,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 +1641,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