Compare commits

...

15 Commits

Author SHA1 Message Date
Aiirondev_dev 0edb87c347 Enhance user registration: implement generation of truly anonymized usernames for new users 2026-04-28 23:20:59 +02:00
Aiirondev_dev 678cc61cef Enhance Docker Compose and startup script: implement auto-scaling settings based on server capacity and environment variables for worker configuration 2026-04-28 23:05:55 +02:00
Aiirondev_dev f26ab5c5fb Enhance user retrieval: implement tenant-aware user search with fallback to default and tenant-specific databases 2026-04-28 22:59:14 +02:00
Aiirondev_dev abb09fbdb5 Enhance user authentication: improve tenant database handling and implement fallback user search across tenant databases 2026-04-28 22:26:02 +02:00
Aiirondev_dev 96f5b9e3cd Enhance tutorial functionality: add tooltip toggle feature and improve tooltip display across the system 2026-04-28 21:59:26 +02:00
Aiirondev_dev 3ab2b075da Enhance tenant database management: integrate TenantContext for dynamic database naming and improve database drop functionality 2026-04-28 21:29:06 +02:00
Aiirondev_dev 821d4d0ad8 Enhance user authentication: improve password validation and tenant context handling 2026-04-28 21:21:48 +02:00
Aiirondev_dev 6cc8dabf72 Enhance runtime override generation: add app image retrieval and improve port handling 2026-04-28 20:55:58 +02:00
Aiirondev_dev 0d7fa2c511 Enhance tenant management script: improve port parsing and runtime override generation 2026-04-28 20:38:19 +02:00
Aiirondev_dev 4a18460178 Enhance tenant management script: add functionality to remove tenant ports and update runtime port configuration 2026-04-28 19:00:08 +02:00
Aiirondev_dev d2b3d4f6ea Enhance tenant management script: improve tenant listing functionality and handle missing config file 2026-04-28 18:44:47 +02:00
Aiirondev_dev f3d327f28f Enhance tenant management scripts: add runtime port update functionality and handle occupied ports 2026-04-28 18:18:04 +02:00
Aiirondev_dev 2b05d8c952 Refactor manage-tenant script: improve parameter handling and ensure default values 2026-04-28 17:54:43 +02:00
Aiirondev_dev 6cb41c1277 Enhance tenant management script: add help option to display usage instructions 2026-04-28 17:49:44 +02:00
Aiirondev_dev f0d02d6af1 Refactor restart script: streamline Docker compose argument handling and improve readability 2026-04-28 17:20:10 +02:00
8 changed files with 687 additions and 55 deletions
+2 -2
View File
@@ -7116,8 +7116,8 @@ def register():
name = (request.form.get('name') or '').strip()
last_name = (request.form.get('last-name') or '').strip()
# Always generate username from abbreviation logic and auto-extend on collisions.
username = us.build_unique_username_from_name(name, last_name)
# Generate a truly anonymized username for new users.
username = us.build_anonymous_username()
permission_preset = (request.form.get('permission_preset') or 'standard_user').strip()
use_custom_permissions = request.form.get('use_custom_permissions') == 'on'
+106 -8
View File
@@ -206,6 +206,82 @@
white-space: nowrap;
}
body.tutorial-tooltips-active [data-tutorial-tip] {
position: relative;
border-radius: 10px;
outline: 1px dashed rgba(59, 130, 246, 0.35);
outline-offset: 2px;
cursor: help;
}
body.tutorial-tooltips-active [data-tutorial-tip]::before {
content: '💡';
position: absolute;
top: 50%;
right: -26px;
transform: translateY(-50%);
width: 20px;
height: 20px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 999px;
background: rgba(59, 130, 246, 0.15);
color: #0f172a;
font-size: 0.75rem;
pointer-events: none;
}
body.tutorial-tooltips-active [data-tutorial-tip]::after {
content: attr(data-tutorial-tip);
position: absolute;
top: calc(100% + 10px);
left: 50%;
transform: translateX(-50%) translateY(6px);
min-width: 220px;
max-width: 280px;
padding: 10px 12px;
border-radius: 14px;
background: rgba(15, 23, 42, 0.92);
color: #f8fafc;
font-size: 0.9rem;
line-height: 1.4;
text-align: left;
opacity: 0;
visibility: hidden;
pointer-events: none;
box-shadow: 0 24px 48px rgba(15, 23, 42, 0.22);
z-index: 9999;
white-space: normal;
}
body.tutorial-tooltips-active [data-tutorial-tip]:hover::after {
opacity: 1;
visibility: visible;
transform: translateX(-50%) translateY(10px);
}
.tutorial-tooltip-indicator {
position: fixed;
top: 92px;
right: 16px;
z-index: 2000;
display: none;
align-items: center;
gap: 10px;
padding: 10px 14px;
border-radius: 999px;
background: rgba(56, 189, 248, 0.12);
border: 1px solid rgba(56, 189, 248, 0.25);
color: #0f172a;
font-size: 0.95rem;
box-shadow: 0 10px 30px rgba(15, 23, 42, 0.12);
}
body.tutorial-tooltips-active .tutorial-tooltip-indicator {
display: inline-flex;
}
/* iPad/Tablet responsive adjustments */
@media (max-width: 1024px) {
.navbar .container-fluid {
@@ -952,24 +1028,24 @@
<ul class="navbar-nav me-auto mb-2 mb-lg-0" id="inventoryNavList">
{% if current_permissions.pages.get('home', True) %}
<li class="nav-item" data-nav-fixed="true">
<a class="nav-link {% if current_path == url_for('home') %}nav-active{% endif %}" href="{{ url_for('home') }}">Artikel</a>
<a class="nav-link {% if current_path == url_for('home') %}nav-active{% endif %}" href="{{ url_for('home') }}" data-tutorial-tip="Starten Sie hier mit dem Materialbestand und suchen Sie nach Artikeln.">Artikel</a>
</li>
{% endif %}
{% if 'username' in session %}
{% if current_permissions.pages.get('my_borrowed_items', True) %}
<li class="nav-item">
<a class="nav-link quick-link-pill {% if current_path == url_for('my_borrowed_items') %}nav-active{% endif %}" href="{{ url_for('my_borrowed_items') }}">Meine Ausleihen</a>
<a class="nav-link quick-link-pill {% if current_path == url_for('my_borrowed_items') %}nav-active{% endif %}" href="{{ url_for('my_borrowed_items') }}" data-tutorial-tip="Ihre aktuellen Ausleihen und Rückgaben finden Sie hier.">Meine Ausleihen</a>
</li>
{% endif %}
{% if current_permissions.pages.get('tutorial_page', True) %}
<li class="nav-item">
<a class="nav-link quick-link-pill {% if current_path == url_for('tutorial_page') %}nav-active{% endif %}" href="{{ url_for('tutorial_page') }}">Tutorial</a>
<a class="nav-link quick-link-pill {% if current_path == url_for('tutorial_page') %}nav-active{% endif %}" href="{{ url_for('tutorial_page') }}" data-tutorial-tip="Öffnen Sie das Tutorial, um das System Schritt für Schritt kennenzulernen.">Tutorial</a>
</li>
{% endif %}
{% endif %}
{% if 'username' in session and current_permissions.pages.get('upload_admin', True) and current_permissions.actions.get('can_insert', True) %}
<li class="nav-item">
<a class="nav-link nav-priority-link {% if current_path == url_for('upload_admin') %}nav-active{% endif %}" href="{{ url_for('upload_admin') }}"> Hochladen</a>
<a class="nav-link nav-priority-link {% if current_path == url_for('upload_admin') %}nav-active{% endif %}" href="{{ url_for('upload_admin') }}" data-tutorial-tip="Hier können Sie neue Artikel ins Inventar einpflegen."> Hochladen</a>
</li>
{% endif %}
<li class="nav-item" data-nav-fixed="true">
@@ -1071,24 +1147,24 @@
<ul class="navbar-nav me-auto mb-2 mb-lg-0" id="libraryNavList">
{% if current_permissions.pages.get('library_view', True) %}
<li class="nav-item" data-nav-fixed="true">
<a class="nav-link {% if current_path == url_for('library_view') %}nav-active{% endif %}" href="{{ url_for('library_view') }}">Medien</a>
<a class="nav-link {% if current_path == url_for('library_view') %}nav-active{% endif %}" href="{{ url_for('library_view') }}" data-tutorial-tip="Die Bibliothek zeigt Ihnen alle Medien und verfügbaren Bücher.">Medien</a>
</li>
{% endif %}
{% if 'username' in session %}
{% if current_permissions.pages.get('my_borrowed_items', True) %}
<li class="nav-item">
<a class="nav-link quick-link-pill {% if current_path == url_for('my_borrowed_items') %}nav-active{% endif %}" href="{{ url_for('my_borrowed_items') }}">Meine Medien</a>
<a class="nav-link quick-link-pill {% if current_path == url_for('my_borrowed_items') %}nav-active{% endif %}" href="{{ url_for('my_borrowed_items') }}" data-tutorial-tip="Hier sehen Sie Ihre bibliotheksbezogenen Ausleihen.">Meine Medien</a>
</li>
{% endif %}
{% if current_permissions.pages.get('tutorial_page', True) %}
<li class="nav-item">
<a class="nav-link quick-link-pill {% if current_path == url_for('tutorial_page') %}nav-active{% endif %}" href="{{ url_for('tutorial_page') }}">Tutorial</a>
<a class="nav-link quick-link-pill {% if current_path == url_for('tutorial_page') %}nav-active{% endif %}" href="{{ url_for('tutorial_page') }}" data-tutorial-tip="Nutzen Sie das Tutorial, um die Bibliotheksfunktionen kennenzulernen.">Tutorial</a>
</li>
{% endif %}
{% endif %}
{% if 'username' in session and current_permissions.actions.get('can_insert', True) and current_permissions.pages.get('library_admin', True) %}
<li class="nav-item">
<a class="nav-link nav-priority-link {% if current_path == url_for('library_admin') %}nav-active{% endif %}" href="{{ url_for('library_admin') }}">📖 Hochladen</a>
<a class="nav-link nav-priority-link {% if current_path == url_for('library_admin') %}nav-active{% endif %}" href="{{ url_for('library_admin') }}" data-tutorial-tip="Neue Bibliotheksmedien können hier aufgenommen werden.">📖 Hochladen</a>
</li>
{% endif %}
<li class="nav-item" data-nav-fixed="true">
@@ -1999,6 +2075,28 @@
});
});
</script>
<script>
(function () {
const username = {{ (session['username'] if 'username' in session else '')|tojson }};
const tooltipModeKey = username ? ('inventarsystem_tutorial_tooltips_enabled_' + username) : 'inventarsystem_tutorial_tooltips_enabled';
const enabled = localStorage.getItem(tooltipModeKey) === '1';
function applyTooltipMode(active) {
document.body.classList.toggle('tutorial-tooltips-active', active);
let indicator = document.getElementById('tutorialTooltipIndicator');
if (!indicator) {
indicator = document.createElement('div');
indicator.id = 'tutorialTooltipIndicator';
indicator.className = 'tutorial-tooltip-indicator';
indicator.textContent = 'Tutorial-Modus aktiv: Tooltips sind eingeschaltet';
document.body.appendChild(indicator);
}
indicator.style.display = active ? 'inline-flex' : 'none';
}
applyTooltipMode(enabled);
})();
</script>
</body>
</html>
+63
View File
@@ -189,6 +189,38 @@
font-size: 1rem;
}
.tutorial-tooltip-toggle {
display: flex;
flex-direction: column;
gap: 8px;
margin-top: 20px;
padding: 16px;
border-radius: 18px;
background: rgba(224, 242, 254, 0.9);
border: 1px solid #bae6fd;
}
.tutorial-tooltip-toggle label {
display: flex;
align-items: center;
gap: 12px;
font-weight: 700;
color: #0f172a;
}
.tutorial-tooltip-toggle input[type="checkbox"] {
width: 20px;
height: 20px;
accent-color: #2563eb;
}
.tooltip-description {
margin: 0;
color: #475569;
font-size: 0.95rem;
line-height: 1.5;
}
.workflow-controls {
display: flex;
gap: 8px;
@@ -230,6 +262,13 @@
<span class="tutorial-pill">Format: Ruhig und geführt</span>
<span class="tutorial-pill">Benutzer: {{ username }}</span>
</div>
<div class="tutorial-tooltip-toggle">
<label for="toggleTutorialTooltips">
<input type="checkbox" id="toggleTutorialTooltips" />
Tutorial-Tooltips für das gesamte System aktivieren
</label>
<p class="tooltip-description">Wenn aktiviert, erscheinen auf allen Seiten erklärende Hinweise und Icons für die wichtigsten Navigationselemente.</p>
</div>
</section>
<div class="tutorial-grid">
@@ -438,8 +477,32 @@
renderStep();
});
const tooltipToggle = document.getElementById('toggleTutorialTooltips');
const tooltipModeKey = 'inventarsystem_tutorial_tooltips_enabled_{{ username }}';
function setTooltipMode(enabled) {
localStorage.setItem(tooltipModeKey, enabled ? '1' : '0');
document.body.classList.toggle('tutorial-tooltips-active', enabled);
}
function loadTooltipMode() {
const saved = localStorage.getItem(tooltipModeKey);
const enabled = saved === '1';
if (tooltipToggle) {
tooltipToggle.checked = enabled;
}
setTooltipMode(enabled);
}
if (tooltipToggle) {
tooltipToggle.addEventListener('change', function () {
setTooltipMode(this.checked);
});
}
loadState();
renderStep();
loadTooltipMode();
})();
</script>
{% endblock %}
+92 -12
View File
@@ -13,6 +13,8 @@ Provides methods for creating, validating, and retrieving user information.
import hashlib
import copy
import re
import secrets
import string
from bson.objectid import ObjectId
import settings as cfg
from settings import MongoClient
@@ -57,6 +59,12 @@ def build_name_synonym(first_name, last_name=''):
return combined[:6].title()
def _generate_anonymous_username(prefix='user', length=8):
"""Generate a random anonymized username using lowercase letters and digits."""
alphabet = string.ascii_lowercase + string.digits
return prefix + ''.join(secrets.choice(alphabet) for _ in range(length))
def build_username_from_name(first_name, last_name=''):
"""
Build a deterministic username abbreviation from first and last name.
@@ -73,6 +81,22 @@ def build_username_from_name(first_name, last_name=''):
return alias.lower()
def build_anonymous_username():
"""
Build a truly anonymized username that is not derived from the user's
first name or last name.
"""
for _ in range(10):
candidate = _generate_anonymous_username()
if not get_user(candidate):
return candidate
suffix = 2
while get_user(f"user{suffix}"):
suffix += 1
return f"user{suffix}"
def build_unique_username_from_name(first_name, last_name=''):
"""
Build a unique username based on the abbreviation logic.
@@ -417,13 +441,41 @@ def check_nm_pwd(username, password):
Returns:
dict: User document if credentials are valid, None otherwise
"""
db_name = cfg.MONGODB_DB
tenant_db = 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:
pass
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
db = client[cfg.MONGODB_DB]
users = db['users']
hashed_password = hashlib.sha512(password.encode()).hexdigest()
user = users.find_one({'Username': username, 'Password': hashed_password})
client.close()
return user
try:
hashed_password = hashing(password)
def find_user_in_db(database_name):
db = client[database_name]
users = db['users']
return users.find_one({'Username': username, 'Password': hashed_password}) or users.find_one({'username': username, 'Password': hashed_password})
user = find_user_in_db(db_name)
if user:
return user
# Fallback: tenant context may not be available yet, so search all tenant databases.
for database_name in client.list_database_names():
if database_name == db_name or not database_name.startswith('inventar_'):
continue
user = find_user_in_db(database_name)
if user:
return user
finally:
client.close()
return None
def add_user(
@@ -471,7 +523,7 @@ def add_user(
'Admin': False,
'active_ausleihung': None,
'name': name_alias,
'last_name': '',
'last_name': last_name.strip() if last_name else '',
'IsStudent': bool(is_student),
'PermissionPreset': permission_defaults['preset'],
'ActionPermissions': permission_defaults['actions'],
@@ -564,11 +616,39 @@ def get_user(username):
dict: User document or None if not found
"""
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
db = client[cfg.MONGODB_DB]
users = db['users']
users_return = users.find_one({'Username': username})
client.close()
return users_return
try:
def find_in_db(database_name):
db = client[database_name]
users = db['users']
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
# Fallback to default configured database
user = find_in_db(cfg.MONGODB_DB)
if user:
return user
# Last fallback: search all tenant databases if the user belongs to a tenant-specific DB
for database_name in client.list_database_names():
if database_name == cfg.MONGODB_DB or not database_name.startswith('inventar_'):
continue
user = find_in_db(database_name)
if user:
return user
return None
finally:
client.close()
def check_admin(username):
+13 -8
View File
@@ -106,11 +106,16 @@ services:
INVENTAR_DELETED_ARCHIVE_FOLDER: /data/deleted-archives
# Performance Tuning
INVENTAR_WORKER_CLASS: gevent
INVENTAR_WORKERS: "4"
INVENTAR_THREADS: "2"
INVENTAR_WORKER_TIMEOUT: "30"
INVENTAR_WORKER_CONNECTIONS: "100"
INVENTAR_WORKER_CLASS: ${INVENTAR_WORKER_CLASS:-gevent}
INVENTAR_WORKERS: "${INVENTAR_WORKERS:-4}"
INVENTAR_THREADS: "${INVENTAR_THREADS:-2}"
INVENTAR_WORKER_TIMEOUT: "${INVENTAR_WORKER_TIMEOUT:-30}"
INVENTAR_WORKER_CONNECTIONS: "${INVENTAR_WORKER_CONNECTIONS:-100}"
# Auto-scaled runtime settings
INVENTAR_APP_CPUS: ${INVENTAR_APP_CPUS:-1.0}
INVENTAR_APP_MEM_LIMIT: ${INVENTAR_APP_MEM_LIMIT:-256m}
INVENTAR_APP_MEM_SWAP_LIMIT: ${INVENTAR_APP_MEM_SWAP_LIMIT:-512m}
# Multi-Tenant
INVENTAR_MULTITENANT_ENABLED: "true"
@@ -131,9 +136,9 @@ services:
# Resource Limits (per instance)
# With 10 instances: each gets ~150MB, totaling ~1.5GB
# Adjust based on server resources
mem_limit: 256m
memswap_limit: 512m
cpus: "1.0"
mem_limit: "${INVENTAR_APP_MEM_LIMIT:-256m}"
memswap_limit: "${INVENTAR_APP_MEM_SWAP_LIMIT:-512m}"
cpus: "${INVENTAR_APP_CPUS:-1.0}"
# Health check for load balancer
healthcheck:
+255 -17
View File
@@ -20,11 +20,13 @@ show_help() {
echo " restart-tenant <id> 'Restart' a single tenant (clears cache/sessions)"
echo " restart-all Restart all application containers (zero-downtime reload)"
echo " list List active tenants"
echo " -h, --help Show this help message"
echo ""
echo "Examples:"
echo " ./manage-tenant.sh add school_a 10001"
echo " ./manage-tenant.sh remove test_tenant"
echo " ./manage-tenant.sh restart-all"
echo " ./manage-tenant.sh -h"
exit 1
}
@@ -65,14 +67,164 @@ PY
fi
}
if [ -z "$1" ]; then
update_runtime_ports() {
local new_port="$1"
local env_file="$PWD/.docker-build.env"
local current_ports=""
local port_list=()
local unique_ports=()
local first_port=""
if [ -n "$new_port" ]; then
if [ -f "$env_file" ]; then
current_ports="$(awk -F= '/^INVENTAR_HTTP_PORTS=/{print $2; exit}' "$env_file" | tr -d ' ' || true)"
if [ -z "$current_ports" ]; then
current_ports="$(awk -F= '/^INVENTAR_HTTP_PORT=/{print $2; exit}' "$env_file" | tr -d ' ' || true)"
fi
fi
if [ -n "$current_ports" ]; then
IFS=',' read -r -a port_list <<<"${current_ports// /,}"
fi
port_list+=("$new_port")
for port in "${port_list[@]}"; do
if [ -n "$port" ] && ! printf '%s\n' "${unique_ports[@]}" | grep -qx "$port"; then
unique_ports+=("$port")
fi
done
if [ ${#unique_ports[@]} -eq 0 ]; then
unique_ports=("$new_port")
fi
first_port="${unique_ports[0]}"
local ports_csv
ports_csv="$(IFS=,; echo "${unique_ports[*]}")"
if [ ! -f "$env_file" ]; then
cat > "$env_file" <<EOF
NUITKA_BUILD=0
INVENTAR_HTTP_PORT=$first_port
INVENTAR_HTTP_PORTS=$ports_csv
EOF
else
if grep -q '^INVENTAR_HTTP_PORTS=' "$env_file" 2>/dev/null; then
sed -i "s|^INVENTAR_HTTP_PORTS=.*|INVENTAR_HTTP_PORTS=$ports_csv|" "$env_file"
else
printf '\nINVENTAR_HTTP_PORTS=%s\n' "$ports_csv" >> "$env_file"
fi
if grep -q '^INVENTAR_HTTP_PORT=' "$env_file" 2>/dev/null; then
sed -i "s|^INVENTAR_HTTP_PORT=.*|INVENTAR_HTTP_PORT=$first_port|" "$env_file"
else
printf '\nINVENTAR_HTTP_PORT=%s\n' "$first_port" >> "$env_file"
fi
fi
echo "Updated runtime env ports: $ports_csv"
fi
}
remove_tenant_port() {
local tenant_id="$1"
local config_path="$CONFIG_FILE"
local port
port="$(python3 - "$config_path" "$tenant_id" <<'PY'
import json, sys, os
path, tenant_id = sys.argv[1], sys.argv[2]
if not os.path.isfile(path):
sys.exit(1)
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):
sys.exit(2)
removed = tenants.pop(tenant_id, None)
cfg['tenants'] = tenants
with open(path, 'w', encoding='utf-8') as f:
json.dump(cfg, f, indent=4, ensure_ascii=False)
if removed is not None:
port = removed.get('port')
if port is not None:
print(port)
PY
)"
local status=$?
if [ $status -eq 1 ]; then
echo "Error: config file not found: $config_path"
return 1
fi
if [ $status -eq 2 ]; then
return 2
fi
if [ -n "$port" ]; then
echo "$port"
fi
return 0
}
remove_runtime_port() {
local removed_port="$1"
local env_file="$PWD/.docker-build.env"
local current_ports=""
local port_list=()
local new_ports=()
local first_port=""
if [ ! -f "$env_file" ]; then
return 0
fi
current_ports="$(awk -F= '/^INVENTAR_HTTP_PORTS=/{print $2; exit}' "$env_file" | tr -d ' ' || true)"
if [ -z "$current_ports" ]; then
current_ports="$(awk -F= '/^INVENTAR_HTTP_PORT=/{print $2; exit}' "$env_file" | tr -d ' ' || true)"
fi
if [ -z "$current_ports" ]; then
return 0
fi
IFS=',' read -r -a port_list <<<"${current_ports// /,}"
for port in "${port_list[@]}"; do
if [ -n "$port" ] && [ "$port" != "$removed_port" ]; then
new_ports+=("$port")
fi
done
if [ ${#new_ports[@]} -eq 0 ]; then
sed -i '/^INVENTAR_HTTP_PORTS=/d;/^INVENTAR_HTTP_PORT=/d' "$env_file"
return 0
fi
first_port="${new_ports[0]}"
local ports_csv="$(IFS=,; echo "${new_ports[*]}")"
if grep -q '^INVENTAR_HTTP_PORTS=' "$env_file" 2>/dev/null; then
sed -i "s|^INVENTAR_HTTP_PORTS=.*|INVENTAR_HTTP_PORTS=$ports_csv|" "$env_file"
else
printf '\nINVENTAR_HTTP_PORTS=%s\n' "$ports_csv" >> "$env_file"
fi
if grep -q '^INVENTAR_HTTP_PORT=' "$env_file" 2>/dev/null; then
sed -i "s|^INVENTAR_HTTP_PORT=.*|INVENTAR_HTTP_PORT=$first_port|" "$env_file"
else
printf '\nINVENTAR_HTTP_PORT=%s\n' "$first_port" >> "$env_file"
fi
}
if [ -z "${1:-}" ]; then
show_help
fi
COMMAND=$1
TENANT_ID=$2
COMMAND="$1"
TENANT_ID="${2:-}"
case "$COMMAND" in
-h|--help)
show_help
;;
add)
if [ -z "$TENANT_ID" ]; then
echo "Error: Please provide a tenant_id."
@@ -86,6 +238,7 @@ case "$COMMAND" in
exit 1
fi
register_tenant_port "$TENANT_ID" "$PORT_ARG"
update_runtime_ports "$PORT_ARG"
fi
echo "Adding new tenant '$TENANT_ID'..."
@@ -94,11 +247,53 @@ 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; sys.path.insert(0, '/app/Web'); import settings; from pymongo import MongoClient; import hashlib
import sys, re; sys.path.insert(0, '/app/Web'); 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}'
client = MongoClient(settings.MONGODB_HOST, int(settings.MONGODB_PORT))
db = client[f'{settings.MONGODB_DB}_{sys.argv[1]}']
db = client[db_name]
hashed_pw = hashlib.sha512('admin123'.encode()).hexdigest()
db.users.insert_one({'Username': 'admin', 'Password': hashed_pw, 'Role': 'admin', 'Name': 'Admin', 'Nachname': 'User'})
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': {
'can_borrow': True,
'can_insert': True,
'can_edit': True,
'can_delete': True,
'can_manage_users': True,
'can_manage_settings': True,
'can_view_logs': True,
},
'PagePermissions': {
'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,
},
})
print(f'Tenant {sys.argv[1]} database initialized. Default admin: admin / admin123')
" "$TENANT_ID"
echo "Tenant '$TENANT_ID' successfully added. Ready to use."
@@ -120,14 +315,28 @@ 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'); import settings; from pymongo import MongoClient
import sys; sys.path.insert(0, '/app/Web'); from tenant import TenantContext; 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))
client.drop_database(f'{settings.MONGODB_DB}_{sys.argv[1]}')
client.drop_database(db_name)
print(f'Database for tenant {sys.argv[1]} dropped.')
" "$TENANT_ID"
echo "Tenant '$TENANT_ID' removed successfully."
echo "Tenant '$TENANT_ID' database removed."
else
echo "Error: Application container not running."
echo "Warning: Application container not running. Tenant database may still exist in MongoDB."
fi
port_to_remove=""
if port_to_remove="$(remove_tenant_port "$TENANT_ID" 2>/dev/null)"; then
if [ -n "$port_to_remove" ]; then
remove_runtime_port "$port_to_remove"
echo "Removed tenant '$TENANT_ID' from config.json and cleaned runtime port $port_to_remove."
else
echo "Removed tenant '$TENANT_ID' from config.json. No port mapping was present."
fi
else
echo "Warning: tenant '$TENANT_ID' was not configured in config.json or could not be removed."
fi
else
echo "Removal canceled."
@@ -164,17 +373,46 @@ print(f'Tenant {sys.argv[1]} session cache cleared. Tenant restarted.')
;;
list)
echo "Listing active tenants (Databases):"
echo "Listing configured tenants (config.json):"
if [ -f "$CONFIG_FILE" ]; then
python3 - "$CONFIG_FILE" <<'PY'
import json, sys
path = sys.argv[1]
with open(path, 'r', encoding='utf-8') as f:
cfg = json.load(f)
tenants = cfg.get('tenants', {})
if isinstance(tenants, dict) and tenants:
for tid, conf in tenants.items():
port = conf.get('port') if isinstance(conf, dict) else None
if port is not None:
print(f'- {tid} (port {port})')
else:
print(f'- {tid}')
else:
print(' (no tenants configured)')
PY
else
echo " (config.json not found)"
fi
echo ""
echo "Listing active tenant databases (MongoDB):"
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
docker exec -i "$APP_CONTAINER" python3 - <<'PY'
import sys
sys.path.insert(0, '/app/Web')
import settings
from pymongo import MongoClient
client = MongoClient(settings.MONGODB_HOST, int(settings.MONGODB_PORT))
prefix = f'{settings.MONGODB_DB}_'
prefix = 'inventar_'
dbs = [d for d in client.list_database_names() if d.startswith(prefix)]
for db in dbs:
print(f'- {db.replace(prefix, \"\")}')
"
if dbs:
for db in dbs:
print(f'- {db.replace(prefix, "")}')
else:
print(' (no tenant databases found)')
PY
else
echo "Error: Application container not running."
fi
+71 -2
View File
@@ -5,6 +5,62 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" >/dev/null 2>&1 && pwd)"
cd "$SCRIPT_DIR"
COMPOSE_FILE="docker-compose-multitenant.yml"
ENV_FILE="$SCRIPT_DIR/.docker-build.env"
RUNTIME_COMPOSE_OVERRIDE_FILE="$SCRIPT_DIR/.docker-compose.runtime.override.yml"
parse_port_list() {
local raw="$1"
local port
local ports=()
raw="${raw//,/ }"
for port in $raw; do
port="${port//[[:space:]]/}"
if [ -n "$port" ] && printf '%s\n' "$port" | grep -qE '^[0-9]+$'; then
ports+=("$port")
fi
done
printf '%s\n' "${ports[@]}"
}
write_runtime_override() {
local current_ports=""
local app_image=""
local ports=()
if [ -f "$ENV_FILE" ]; then
app_image="$(awk -F= '/^INVENTAR_APP_IMAGE=/{print $2; exit}' "$ENV_FILE" | tr -d ' ' || true)"
current_ports="$(awk -F= '/^INVENTAR_HTTP_PORTS=/{print $2; exit}' "$ENV_FILE" | tr -d ' ' || true)"
if [ -z "$current_ports" ]; then
current_ports="$(awk -F= '/^INVENTAR_HTTP_PORT=/{print $2; exit}' "$ENV_FILE" | tr -d ' ' || true)"
fi
fi
if [ -n "$app_image" ]; then
if [ -n "$current_ports" ]; then
mapfile -t ports < <(parse_port_list "$current_ports")
fi
cat > "$RUNTIME_COMPOSE_OVERRIDE_FILE" <<EOF
services:
app:
image: $app_image
build: null
EOF
if [ ${#ports[@]} -gt 1 ]; then
cat >> "$RUNTIME_COMPOSE_OVERRIDE_FILE" <<EOF
ports:
EOF
for port in "${ports[@]}"; do
cat >> "$RUNTIME_COMPOSE_OVERRIDE_FILE" <<EOF
- "$port:8000"
EOF
done
fi
else
rm -f "$RUNTIME_COMPOSE_OVERRIDE_FILE"
fi
}
while [[ $# -gt 0 ]]; do
case "$1" in
--multitenant)
@@ -27,11 +83,24 @@ if ! command -v docker >/dev/null 2>&1; then
fi
echo "Rebuilding and/or restarting app container using $COMPOSE_FILE..."
write_runtime_override
compose_args=(-f "$COMPOSE_FILE")
if [ -f "$RUNTIME_COMPOSE_OVERRIDE_FILE" ]; then
compose_args+=( -f "$RUNTIME_COMPOSE_OVERRIDE_FILE" )
fi
if [ -f "$ENV_FILE" ]; then
compose_args+=( --env-file "$ENV_FILE" )
fi
if [ -f "$SCRIPT_DIR/Dockerfile" ]; then
docker compose -f "$COMPOSE_FILE" up -d --build app
docker compose "${compose_args[@]}" up -d --build app
else
echo "Warning: Dockerfile not found in $SCRIPT_DIR. Skipping build and restarting existing app container."
docker compose -f "$COMPOSE_FILE" up -d --no-build app
if ! docker compose "${compose_args[@]}" up -d --no-build app; then
echo "Warning: app image not found or not available locally. Attempting docker compose pull app..."
docker compose "${compose_args[@]}" pull app
docker compose "${compose_args[@]}" up -d --no-build app
fi
fi
echo "Cleaning up unused Docker images..."
+85 -6
View File
@@ -419,6 +419,7 @@ configure_host_ports() {
local requested_http
local requested_ports
local ports=()
local occupied_ports=()
requested_http=""
requested_ports=""
@@ -442,15 +443,33 @@ configure_host_ports() {
if [ ${#ports[@]} -gt 0 ]; then
HTTP_PORTS_VALUE="${ports[*]}"
HTTP_PORT_VALUE="${ports[0]}"
for port in "${ports[@]}"; do
if port_in_use "$port"; then
occupied_ports+=("$port")
fi
done
if [ ${#occupied_ports[@]} -gt 0 ]; then
if [ ${#ports[@]} -eq 1 ]; then
HTTP_PORT_VALUE="$(find_free_port "$DEFAULT_TENANT_PORT_START")"
echo "Host port ${ports[0]} is already occupied. Assigned new tenant port: $HTTP_PORT_VALUE"
HTTP_PORTS_VALUE="$HTTP_PORT_VALUE"
else
echo "Error: requested host port(s) already in use: ${occupied_ports[*]}"
echo "Remove the occupied port(s) from INVENTAR_HTTP_PORTS or stop the conflicting service."
exit 1
fi
fi
else
HTTP_PORT_VALUE="$DEFAULT_TENANT_PORT_START"
HTTP_PORTS_VALUE="$HTTP_PORT_VALUE"
fi
if port_in_use "$HTTP_PORT_VALUE"; then
HTTP_PORT_VALUE="$(find_free_port "$DEFAULT_TENANT_PORT_START")"
echo "Host port ${ports[0]:-$DEFAULT_TENANT_PORT_START} is already occupied. Assigned new tenant port: $HTTP_PORT_VALUE"
HTTP_PORTS_VALUE="$HTTP_PORT_VALUE"
if port_in_use "$HTTP_PORT_VALUE"; then
HTTP_PORT_VALUE="$(find_free_port "$DEFAULT_TENANT_PORT_START")"
echo "Host port $DEFAULT_TENANT_PORT_START is already occupied. Assigned new tenant port: $HTTP_PORT_VALUE"
HTTP_PORTS_VALUE="$HTTP_PORT_VALUE"
fi
fi
}
@@ -485,12 +504,71 @@ ensure_min_docker_disk_space() {
fi
}
detect_server_capacity() {
local cpus mem_kb mem_gb app_workers app_cpus app_mem app_mem_swap
cpus="$(nproc 2>/dev/null || echo 1)"
mem_kb="$(awk '/MemAvailable:/ {print $2; exit}' /proc/meminfo 2>/dev/null || awk '/MemTotal:/ {print $2; exit}' /proc/meminfo || echo 0)"
mem_gb=$(( (mem_kb + 1024*1024 - 1) / (1024*1024) ))
if [ "$cpus" -ge 8 ] && [ "$mem_gb" -ge 16 ]; then
app_workers=8
app_cpus="2.0"
app_mem="512m"
app_mem_swap="1024m"
elif [ "$cpus" -ge 4 ] && [ "$mem_gb" -ge 8 ]; then
app_workers=4
app_cpus="1.0"
app_mem="384m"
app_mem_swap="768m"
elif [ "$cpus" -ge 2 ] && [ "$mem_gb" -ge 4 ]; then
app_workers=2
app_cpus="0.75"
app_mem="320m"
app_mem_swap="640m"
else
app_workers=1
app_cpus="0.5"
app_mem="256m"
app_mem_swap="512m"
fi
if [ -f "$ENV_FILE" ]; then
local env_app_cpus env_app_mem env_app_mem_swap env_workers
env_app_cpus="$(awk -F= '/^INVENTAR_APP_CPUS=/{print $2; exit}' "$ENV_FILE" | tr -d ' ' || true)"
env_app_mem="$(awk -F= '/^INVENTAR_APP_MEM_LIMIT=/{print $2; exit}' "$ENV_FILE" | tr -d ' ' || true)"
env_app_mem_swap="$(awk -F= '/^INVENTAR_APP_MEM_SWAP_LIMIT=/{print $2; exit}' "$ENV_FILE" | tr -d ' ' || true)"
env_workers="$(awk -F= '/^INVENTAR_WORKERS=/{print $2; exit}' "$ENV_FILE" | tr -d ' ' || true)"
[ -n "$env_app_cpus" ] && app_cpus="$env_app_cpus"
[ -n "$env_app_mem" ] && app_mem="$env_app_mem"
[ -n "$env_app_mem_swap" ] && app_mem_swap="$env_app_mem_swap"
[ -n "$env_workers" ] && app_workers="$env_workers"
fi
INVENTAR_APP_CPUS="${INVENTAR_APP_CPUS:-$app_cpus}"
INVENTAR_APP_MEM_LIMIT="${INVENTAR_APP_MEM_LIMIT:-$app_mem}"
INVENTAR_APP_MEM_SWAP_LIMIT="${INVENTAR_APP_MEM_SWAP_LIMIT:-$app_mem_swap}"
INVENTAR_WORKERS="${INVENTAR_WORKERS:-$app_workers}"
INVENTAR_THREADS="${INVENTAR_THREADS:-2}"
echo "Detected host capacity: CPUs=$cpus, RAM=${mem_gb}GB"
echo "Configured app runtime: INVENTAR_WORKERS=$INVENTAR_WORKERS, INVENTAR_APP_CPUS=$INVENTAR_APP_CPUS, INVENTAR_APP_MEM_LIMIT=$INVENTAR_APP_MEM_LIMIT"
}
write_env_file() {
cat > "$ENV_FILE" <<EOF
NUITKA_BUILD=$NUITKA_BUILD_VALUE
INVENTAR_HTTP_PORT=$HTTP_PORT_VALUE
INVENTAR_HTTP_PORTS=${HTTP_PORTS_VALUE// /,}
INVENTAR_APP_IMAGE=$APP_IMAGE_VALUE
INVENTAR_APP_CPUS=$INVENTAR_APP_CPUS
INVENTAR_APP_MEM_LIMIT=$INVENTAR_APP_MEM_LIMIT
INVENTAR_APP_MEM_SWAP_LIMIT=$INVENTAR_APP_MEM_SWAP_LIMIT
INVENTAR_WORKERS=$INVENTAR_WORKERS
INVENTAR_THREADS=$INVENTAR_THREADS
INVENTAR_WORKER_TIMEOUT=${INVENTAR_WORKER_TIMEOUT:-30}
INVENTAR_WORKER_CONNECTIONS=${INVENTAR_WORKER_CONNECTIONS:-100}
EOF
}
@@ -499,7 +577,7 @@ write_runtime_compose_override() {
services:
app:
working_dir: /app/Web
command: ["gunicorn", "app:app", "--bind", "0.0.0.0:8000", "--workers", "2", "--timeout", "30", "--graceful-timeout", "20", "--max-requests", "200", "--max-requests-jitter", "50", "--log-level", "info", "--access-logfile", "-", "--error-logfile", "-"]
command: ["gunicorn", "app:app", "--bind", "0.0.0.0:8000", "--workers", "${INVENTAR_WORKERS:-2}", "--threads", "${INVENTAR_THREADS:-2}", "--timeout", "${INVENTAR_WORKER_TIMEOUT:-30}", "--graceful-timeout", "20", "--worker-connections", "${INVENTAR_WORKER_CONNECTIONS:-100}", "--max-requests", "200", "--max-requests-jitter", "50", "--log-level", "info", "--access-logfile", "-", "--error-logfile", "-"]
image: ${APP_IMAGE_VALUE}
build: null
EOF
@@ -576,6 +654,7 @@ configure_nuitka_mode
resolve_app_image
configure_host_ports
ensure_min_docker_disk_space
detect_server_capacity
ensure_app_image_loaded
write_env_file
write_runtime_compose_override