Compare commits

...

9 Commits

14 changed files with 262 additions and 118 deletions
+1 -1
View File
@@ -1,4 +1,4 @@
NUITKA_BUILD=0
INVENTAR_HTTP_PORT=80
INVENTAR_HTTPS_PORT=443
INVENTAR_APP_IMAGE=ghcr.io/aiirondev/inventarsystem:latest
INVENTAR_APP_IMAGE=ghcr.io/aiirondev/legendary-octo-garbanzo:latest
-1
View File
@@ -1 +0,0 @@
[2026-04-19 20:20:39,502] ERROR in app: Error during scheduler shutdown: name '__file__' is not defined
+1
View File
@@ -15,3 +15,4 @@ python-barcode
openpyxl
cryptography
pywebpush
py-vapid==1.9.0
+59
View File
@@ -34,6 +34,25 @@
--ui-shadow-md: 0 10px 24px rgba(0, 0, 0, 0.5);
}
/* Safe Area Support for iPad notches, Dynamic Island, and rounded corners */
html {
/* Ensure viewport-fit is respected */
viewport-fit: cover;
}
body {
/* Respect safe areas on mobile devices */
position: relative;
}
/* Future-proof: Support for various viewport and safe-area features */
@supports (padding: max(0px)) {
body {
/* Ensure body padding never conflicts with safe areas */
padding: 0;
}
}
/* Make inputs visible in dark mode */
:root[data-theme="dark"] input,
:root[data-theme="dark"] select,
@@ -172,11 +191,16 @@ select:focus {
box-shadow: none;
-webkit-backdrop-filter: blur(10px);
backdrop-filter: blur(10px);
/* Safe area insets for iPad notches and Dynamic Island */
padding-left: env(safe-area-inset-left, 0);
padding-right: env(safe-area-inset-right, 0);
}
.navbar-brand {
font-weight: 800;
letter-spacing: 0.02em;
/* Prevent navbar brand from extending into unsafe areas */
word-break: break-word;
}
@media (max-width: 900px) {
@@ -190,6 +214,41 @@ select:focus {
}
}
/* Mobile and tablet safe area handling for navbar */
@media (max-width: 992px) {
.navbar {
/* Ensure navbar spans full width but respects safe areas */
width: 100vw;
margin-left: calc(-50vw + 50%);
/* Safe area padding is already handled by navbar class */
}
.navbar .container-fluid {
width: 100%;
box-sizing: border-box;
/* Ensure padding accounts for safe areas on small screens */
padding-left: max(env(safe-area-inset-left, 0), 12px);
padding-right: max(env(safe-area-inset-right, 0), 12px);
}
.navbar-collapse {
/* Ensure collapsed navbar respects safe areas */
padding-left: env(safe-area-inset-left, 0);
padding-right: env(safe-area-inset-right, 0);
}
}
/* iPad-specific landscape and portrait handling */
@media (max-height: 600px) and (orientation: landscape) {
.navbar {
padding-top: max(env(safe-area-inset-top, 0), 4px);
}
.navbar .container-fluid {
padding-bottom: 4px;
}
}
/* Edit and generate buttons with theme-aware text for better visibility */
.edit-button, .duplicate-button, .generate-qr-button {
color: var(--ui-text) !important; /* Override any existing color */
+116 -74
View File
@@ -169,31 +169,87 @@
background-color: var(--module-primary-color) !important;
position: sticky;
top: 0;
padding-top: env(safe-area-inset-top, 0); /* iOS Safe Area */
/* iOS Safe Area insets for notches and rounded corners */
padding-top: env(safe-area-inset-top, 0);
padding-left: env(safe-area-inset-left, 0);
padding-right: env(safe-area-inset-right, 0);
z-index: 1900;
}
.navbar .container-fluid {
gap: 10px;
padding-bottom: env(safe-area-inset-bottom, 0); /* Fallback */
gap: 8px;
/* Ensure container respects safe areas on all sides */
padding-left: max(env(safe-area-inset-left, 0), 12px);
padding-right: max(env(safe-area-inset-right, 0), 12px);
padding-bottom: env(safe-area-inset-bottom, 0);
}
.navbar-brand {
font-weight: bold;
font-size: 1.4rem;
font-size: 1.3rem;
padding-right: 10px;
white-space: nowrap;
flex-shrink: 0;
}
.navbar-brand::before {
content: attr(data-icon);
margin-right: 8px;
margin-right: 6px;
}
.navbar-nav .nav-link {
font-size: 1rem;
font-size: 0.95rem;
font-weight: 600;
padding: 0.6rem 0.85rem;
border-radius: 8px;
padding: 0.5rem 0.75rem;
border-radius: 6px;
transition: padding .18s ease, font-size .18s ease;
white-space: nowrap;
}
/* iPad/Tablet responsive adjustments */
@media (max-width: 1024px) {
.navbar .container-fluid {
gap: 6px;
padding-left: max(env(safe-area-inset-left, 0), 10px);
padding-right: max(env(safe-area-inset-right, 0), 10px);
}
.navbar-brand {
font-size: 1.1rem;
padding-right: 8px;
}
.navbar-brand::before {
margin-right: 4px;
}
.navbar-nav .nav-link {
font-size: 0.85rem;
padding: 0.45rem 0.6rem;
}
}
/* Small phones responsive adjustments */
@media (max-width: 576px) {
.navbar .container-fluid {
gap: 4px;
padding-left: max(env(safe-area-inset-left, 0), 8px);
padding-right: max(env(safe-area-inset-right, 0), 8px);
}
.navbar-brand {
font-size: 0.95rem;
padding-right: 6px;
}
.navbar-brand::before {
display: none;
}
.navbar-nav .nav-link {
font-size: 0.75rem;
padding: 0.4rem 0.5rem;
}
}
.navbar.nav-compact .navbar-brand {
@@ -855,25 +911,11 @@
<a class="nav-link {% if current_path == url_for('home') %}nav-active{% endif %}" href="{{ url_for('home') }}">Artikel</a>
</li>
{% endif %}
{% if 'username' in session %}
{% if current_permissions.pages.get('my_borrowed_items', True) %}
<li class="nav-item" data-nav-fixed="true">
<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>
</li>
{% endif %}
{% if current_permissions.pages.get('tutorial_page', True) %}
<li class="nav-item" data-nav-fixed="true">
<a class="nav-link quick-link-pill {% if current_path == url_for('tutorial_page') %}nav-active{% endif %}" href="{{ url_for('tutorial_page') }}">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>
</li>
{% endif %}
<li class="nav-item">
<button id="themeToggleBtn" class="btn btn-link nav-link px-3" aria-label="Dark Mode umschalten" title="Theme umschalten">
<span class="theme-icon-light" style="display: none;">☀️</span>
@@ -985,25 +1027,11 @@
<a class="nav-link {% if current_path == url_for('library_view') %}nav-active{% endif %}" href="{{ url_for('library_view') }}">Medien</a>
</li>
{% endif %}
{% if 'username' in session %}
{% if current_permissions.pages.get('my_borrowed_items', True) %}
<li class="nav-item" data-nav-fixed="true">
<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>
</li>
{% endif %}
{% if current_permissions.pages.get('tutorial_page', True) %}
<li class="nav-item" data-nav-fixed="true">
<a class="nav-link quick-link-pill {% if current_path == url_for('tutorial_page') %}nav-active{% endif %}" href="{{ url_for('tutorial_page') }}">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>
</li>
{% endif %}
<li class="nav-item">
<button id="themeToggleBtn" class="btn btn-link nav-link px-3" aria-label="Dark Mode umschalten" title="Theme umschalten">
<span class="theme-icon-light" style="display: none;">☀️</span>
@@ -1602,8 +1630,15 @@
const libraryNavList = document.getElementById('libraryNavList');
const libNavOverflowAnchor = document.getElementById('lib-nav-overflow-anchor');
function initNavbarOverflow(navList, navOverflowAnchor) {
if (!navList || !navOverflowAnchor) return;
function initNavbarOverflow(navList, navOverflowAnchor, options) {
if (!navList) return;
const moreToggleId = options && options.moreToggleId ? options.moreToggleId : '';
const moreToggle = moreToggleId ? document.getElementById(moreToggleId) : null;
const moreControlItem = moreToggle ? moreToggle.closest('li.nav-item.dropdown') : null;
const moreMenu = moreControlItem ? moreControlItem.querySelector(':scope > ul.dropdown-menu') : null;
const moreLabelDefault = moreToggle ? moreToggle.textContent.trim() : 'Mehr Optionen';
const moreMenuOriginal = moreMenu ? moreMenu.innerHTML : '';
const navRoot = navList.closest('nav.navbar');
const navCollapse = navList.closest('.navbar-collapse');
@@ -1631,45 +1666,35 @@
return Array.from(navList.children).filter(function(li){
if (!(li instanceof HTMLElement)) return false;
if (!li.classList.contains('nav-item')) return false;
if (li.id === navOverflowAnchor.id) return false;
if (navOverflowAnchor && li.id === navOverflowAnchor.id) return false;
if (li.dataset.overflowControl === 'true') return false;
if (li.dataset.navFixed === 'true') return false;
if (moreControlItem && li === moreControlItem) return false;
return li.style.display !== 'none';
});
}
function clearOverflowControls() {
if (!navList) return;
navList.querySelectorAll('[data-overflow-control="true"]').forEach(function(node){
node.remove();
});
if (!moreMenu) return;
moreMenu.innerHTML = moreMenuOriginal;
if (moreControlItem) {
moreControlItem.style.display = '';
}
if (moreToggle) {
moreToggle.textContent = moreLabelDefault;
}
}
function restoreAllNavItems() {
if (!navList) return;
navList.querySelectorAll('li.nav-item').forEach(function(li){
if (li.id === navOverflowAnchor.id) return;
if (navOverflowAnchor && li.id === navOverflowAnchor.id) return;
if (li.dataset.overflowControl === 'true') return;
li.style.display = '';
});
clearOverflowControls();
}
function createOverflowControl() {
const li = document.createElement('li');
li.className = 'nav-item dropdown';
li.dataset.overflowControl = 'true';
const toggleId = navOverflowAnchor.id + '-toggle';
const menuId = navOverflowAnchor.id + '-menu';
li.innerHTML =
'<a class="nav-link dropdown-toggle" href="#" id="' + toggleId + '" role="button" data-bs-toggle="dropdown" aria-expanded="false" aria-controls="' + menuId + '">⋮ Weitere</a>' +
'<ul class="dropdown-menu" aria-labelledby="' + toggleId + '" id="' + menuId + '"></ul>';
return li;
}
function getNavCandidatePriority(item) {
if (!(item instanceof HTMLElement)) {
return -1;
@@ -1752,29 +1777,46 @@
function rebuildOverflowControl(hiddenSources) {
clearOverflowControls();
if (!navList || !navOverflowAnchor || hiddenSources.length === 0) return;
const control = createOverflowControl();
const menu = control.querySelector('ul.dropdown-menu');
const controlLink = control.querySelector(':scope > a.nav-link');
if (controlLink) {
controlLink.textContent = '⋮ Weitere (' + hiddenSources.length + ')';
}
if (!moreMenu || hiddenSources.length === 0) return;
const overflowFragment = document.createDocumentFragment();
hiddenSources.forEach(function(source){
appendSourceToOverflowMenu(source, menu);
appendSourceToOverflowMenu(source, overflowFragment);
});
const trailingDivider = menu.querySelector('li:last-child .dropdown-divider');
if (trailingDivider) {
const overflowWrap = document.createElement('div');
overflowWrap.appendChild(overflowFragment);
const trailingDivider = overflowWrap.querySelector('li:last-child .dropdown-divider');
if (trailingDivider && trailingDivider.parentElement) {
trailingDivider.parentElement.remove();
}
navList.insertBefore(control, navOverflowAnchor);
const overflowEntries = Array.from(overflowWrap.childNodes);
if (overflowEntries.length > 0) {
const marker = document.createElement('li');
marker.innerHTML = '<h6 class="dropdown-header">Ausgeblendete Navigation</h6>';
moreMenu.insertBefore(marker, moreMenu.firstChild);
overflowEntries.reverse().forEach(function(node){
moreMenu.insertBefore(node, marker.nextSibling);
});
const divider = document.createElement('li');
divider.innerHTML = '<hr class="dropdown-divider">';
moreMenu.insertBefore(divider, marker.nextSibling);
if (moreToggle) {
moreToggle.textContent = moreLabelDefault + ' (' + hiddenSources.length + ')';
}
}
if (moreControlItem) {
moreControlItem.style.display = '';
}
}
function adaptNavbarByWidth() {
if (!navList || !navOverflowAnchor) return;
if (!navList) return;
applyCompactMode();
@@ -1839,12 +1881,12 @@
// Initialize overflow control for inventory navbar
if (navList && navOverflowAnchor) {
initNavbarOverflow(navList, navOverflowAnchor);
initNavbarOverflow(navList, navOverflowAnchor, { moreToggleId: 'invMoreDropdown' });
}
// Initialize overflow control for library navbar
if (libraryNavList && libNavOverflowAnchor) {
initNavbarOverflow(libraryNavList, libNavOverflowAnchor);
initNavbarOverflow(libraryNavList, libNavOverflowAnchor, { moreToggleId: 'libMoreDropdown' });
}
})();
</script>
+15 -19
View File
@@ -8,9 +8,19 @@
# Scale example: To support 10 tenants with 20 users each:
# docker-compose -f docker-compose-multitenant.yml up -d --scale app=10
version: '3.9'
services:
# Management Container for multi-tenant scripts
tenant-manager:
image: docker:cli
container_name: tenant-manager
restart: "no"
profiles:
- tools
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- .:/workspace
entrypoint: ["sh", "-c", "cd /workspace && ./manage-tenant.sh \"$$@\"", "--"]
nginx:
image: nginx:1.27-alpine
container_name: inventarsystem-nginx
@@ -59,9 +69,9 @@ services:
image: mongo:7.0
container_name: inventarsystem-mongodb
restart: unless-stopped
command: mongod --wiredTigerCacheSizeGB 2 --journal --dbPath /data/db
ports:
- "27017:27017"
command: mongod --wiredTigerCacheSizeGB 2
expose:
- "27017"
volumes:
- mongodb_data:/data/db
- ./docker/mongo:/docker-entrypoint-initdb.d:ro
@@ -181,9 +191,6 @@ volumes:
networks:
inventar-net:
driver: bridge
ipam:
config:
- subnet: 172.20.0.0/16
# Scale guidance:
# 1 tenant (20 users): 1 app instance + redis + mongodb = ~500MB
@@ -197,14 +204,3 @@ networks:
# - Large (10-20 tenants): 8GB RAM, 4-8 CPU cores
# - Jumbo (20+ tenants): 16GB+ RAM, 8+ CPU cores with clustering
# Management Container for multi-tenant scripts
tenant-manager:
image: bash
container_name: tenant-manager
restart: "no"
profiles:
- tools
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- .:/workspace
entrypoint: ["bash", "-c", "cd /workspace && ./manage-tenant.sh \"$$@\"", "--"]
+5 -4
View File
@@ -37,7 +37,8 @@ upstream inventar_backend {
# Main HTTPS server block
server {
listen 443 ssl http2;
listen 443 ssl;
http2 on;
server_name ~^(?<tenant>[a-z0-9-]+)?\.?example\.com$;
# SSL Configuration (wildcard certificate)
@@ -75,9 +76,9 @@ server {
application/x-javascript application/xml+rss application/json;
gzip_comp_level 5;
# Access/Error logs with tenant in filename
access_log /var/log/nginx/inventar_access_${tenant}.log combined buffer=32k;
error_log /var/log/nginx/inventar_error_${tenant}.log warn;
# Access/Error logs
access_log /var/log/nginx/inventar_access.log combined buffer=32k;
error_log /var/log/nginx/inventar_error.log warn;
# Root location - proxy to app with tenant context
location / {
+5 -4
View File
@@ -1,4 +1,4 @@
#!/bin/bash
#!/bin/sh
# Script to manage multitenant deployment
# Allows adding, removing, and restarting tenants without downtime for others
@@ -48,11 +48,12 @@ 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 sys; sys.path.insert(0, '/app/Web'); import settings; from pymongo import MongoClient; import hashlib
client = MongoClient(settings.MONGODB_HOST, int(settings.MONGODB_PORT))
db = client[f'{settings.MONGODB_DB}_{sys.argv[1]}']
db.users.insert_one({'username': 'admin', 'password': 'hashed_password_here', 'role': 'admin'})
print(f'Tenant {sys.argv[1]} database initialized.')
hashed_pw = hashlib.sha512('admin123'.encode()).hexdigest()
db.users.insert_one({'Username': 'admin', 'Password': hashed_pw, 'Role': 'admin', 'Name': 'Admin', 'Nachname': 'User'})
print(f'Tenant {sys.argv[1]} database initialized. Default admin: admin / admin123')
" "$TENANT_ID"
echo "Tenant '$TENANT_ID' successfully added. Ready to use."
else
+1
View File
@@ -14,3 +14,4 @@ python-barcode
openpyxl
cryptography
pywebpush
py-vapid==1.9.0
+2 -1
View File
@@ -3,5 +3,6 @@ set -euo pipefail
SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null 2>&1 && pwd )"
"$SCRIPT_DIR/stop.sh"
"$SCRIPT_DIR/stop.sh" "$@"
"$SCRIPT_DIR/start.sh" "$@"
"$SCRIPT_DIR/start.sh"
+1 -1
View File
@@ -1,3 +1,3 @@
#!/bin/bash
# Wrapper to run tenant management fully containerized via docker-compose
docker-compose -f docker-compose-multitenant.yml --profile tools run --rm tenant-manager "$@"
docker compose -f docker-compose-multitenant.yml --profile tools run --rm tenant-manager "$@"
+17 -11
View File
@@ -14,10 +14,11 @@ if [ "$(id -u)" -ne 0 ] && command -v sudo >/dev/null 2>&1; then
fi
NUITKA_BUILD_VALUE="0"
HTTP_PORT_VALUE="80"
HTTPS_PORT_VALUE="443"
HTTP_PORT_VALUE="8001"
HTTPS_PORT_VALUE="8443"
CRON_SETUP_VALUE="${INVENTAR_SETUP_CRON:-1}"
APP_IMAGE_VALUE="${INVENTAR_APP_IMAGE:-$APP_IMAGE_REPO:latest}"
COMPOSE_FILE="docker-compose.yml"
usage() {
cat <<EOF
@@ -26,6 +27,7 @@ Usage: $0 [options]
Options:
--no-cron Do not create or update cron jobs
--with-cron Create/update cron jobs (default)
--multitenant Use the multi-tenant architecture deployment
-h, --help Show this help message
EOF
}
@@ -41,6 +43,10 @@ parse_args() {
CRON_SETUP_VALUE="1"
shift
;;
--multitenant)
COMPOSE_FILE="docker-compose-multitenant.yml"
shift
;;
-h|--help)
usage
exit 0
@@ -450,7 +456,7 @@ stack_owns_host_port() {
local container_port="$2"
local mapped_port
mapped_port="$(docker compose --env-file "$ENV_FILE" port nginx "$container_port" 2>/dev/null | tail -n1 || true)"
mapped_port="$(docker compose -f "$COMPOSE_FILE" --env-file "$ENV_FILE" port nginx "$container_port" 2>/dev/null | tail -n1 || true)"
if [ -z "$mapped_port" ]; then
return 1
fi
@@ -493,20 +499,20 @@ configure_host_ports() {
requested_http="$(awk -F= '/^INVENTAR_HTTP_PORT=/{print $2}' "$ENV_FILE" | tr -d ' ' || true)"
fi
if [ -z "$requested_http" ]; then
requested_http="80"
requested_http="8001"
fi
if stack_owns_host_port "$requested_http" "80"; then
HTTP_PORT_VALUE="$requested_http"
elif port_in_use "$requested_http"; then
stop_host_nginx_services || true
if ! port_in_use "$requested_http"; then
HTTP_PORT_VALUE="$requested_http"
echo "Freed HTTP port $requested_http by stopping host nginx service"
else
HTTP_PORT_VALUE="$(find_free_port 8080)"
echo "HTTP port 80 is in use. Using fallback HTTP port: $HTTP_PORT_VALUE"
echo "HTTP port is in use. Using fallback HTTP port: $HTTP_PORT_VALUE"
fi
else
HTTP_PORT_VALUE="$requested_http"
@@ -518,13 +524,13 @@ configure_host_ports() {
fi
if [ -z "$requested_https" ]; then
requested_https="443"
requested_https="8443"
fi
if stack_owns_host_port "$requested_https" "443"; then
HTTPS_PORT_VALUE="$requested_https"
elif port_in_use "$requested_https"; then
stop_host_nginx_services || true
if ! port_in_use "$requested_https"; then
HTTPS_PORT_VALUE="$requested_https"
@@ -533,7 +539,7 @@ configure_host_ports() {
fi
HTTPS_PORT_VALUE="$(find_free_port 8443)"
echo "HTTPS port 443 is in use. Using fallback HTTPS port: $HTTPS_PORT_VALUE"
echo "HTTPS port is in use. Using fallback HTTPS port: $HTTPS_PORT_VALUE"
else
HTTPS_PORT_VALUE="$requested_https"
fi
@@ -550,7 +556,7 @@ EOF
verify_stack_health() {
local compose_args running_services retry_count=0
compose_args=(--env-file "$ENV_FILE")
compose_args=(-f "$COMPOSE_FILE" --env-file "$ENV_FILE")
# Try health check with optional restart on first failure
while [[ $retry_count -lt 2 ]]; do
@@ -605,7 +611,7 @@ ensure_app_image_loaded
write_env_file
echo "Starting Inventarsystem Docker stack (app + mongodb)..."
docker compose --env-file "$ENV_FILE" up -d --remove-orphans
docker compose -f "$COMPOSE_FILE" --env-file "$ENV_FILE" up -d --remove-orphans
verify_stack_health
+15 -1
View File
@@ -4,12 +4,26 @@ set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$SCRIPT_DIR"
COMPOSE_FILE="docker-compose.yml"
while [[ $# -gt 0 ]]; do
case "$1" in
--multitenant)
COMPOSE_FILE="docker-compose-multitenant.yml"
shift
;;
*)
shift
;;
esac
done
if ! command -v docker >/dev/null 2>&1; then
echo "Error: docker command not found. Install Docker first."
exit 1
fi
echo "Stopping Inventarsystem Docker stack..."
docker compose down
docker compose -f "$COMPOSE_FILE" down
echo "Stack stopped."
+24 -1
View File
@@ -325,12 +325,35 @@ download_and_extract_bundle() {
cp -f "$tmp_dir/update.sh" "$PROJECT_DIR/update.sh"
fi
# Neu: Multi-Tenant Ressourcen kopieren, falls vorhanden
if [ -f "$tmp_dir/docker-compose-multitenant.yml" ]; then
cp -f "$tmp_dir/docker-compose-multitenant.yml" "$PROJECT_DIR/docker-compose-multitenant.yml"
fi
if [ -f "$tmp_dir/docker/nginx/multitenant.conf" ]; then
cp -f "$tmp_dir/docker/nginx/multitenant.conf" "$PROJECT_DIR/docker/nginx/multitenant.conf"
fi
if [ -f "$tmp_dir/manage-tenant.sh" ]; then
cp -f "$tmp_dir/manage-tenant.sh" "$PROJECT_DIR/manage-tenant.sh"
fi
if [ -f "$tmp_dir/run-tenant-cmd.sh" ]; then
cp -f "$tmp_dir/run-tenant-cmd.sh" "$PROJECT_DIR/run-tenant-cmd.sh"
fi
for file in "MULTITENANT_DEPLOYMENT.md" "MULTITENANT_PYTHON_API.md"; do
if [ -f "$tmp_dir/$file" ]; then
cp -f "$tmp_dir/$file" "$PROJECT_DIR/$file"
fi
done
# Ensure executable permissions on all copied scripts
chmod +x "$PROJECT_DIR/start.sh" "$PROJECT_DIR/stop.sh" "$PROJECT_DIR/restart.sh" "$PROJECT_DIR/update.sh" "$PROJECT_DIR/backup.sh" "$PROJECT_DIR/init-admin.sh" "$PROJECT_DIR/manage-tenant.sh" "$PROJECT_DIR/run-tenant-cmd.sh" 2>/dev/null || true 2>/dev/null || true
chmod +x "$PROJECT_DIR"/manage-tenant.sh "$PROJECT_DIR"/run-tenant-cmd.sh 2>/dev/null || true
if [ ! -f "$PROJECT_DIR/config.json" ] && [ -f "$tmp_dir/config.json" ]; then
cp -f "$tmp_dir/config.json" "$PROJECT_DIR/config.json"
log_message "Installed default config.json from release bundle"
fi
chmod +x "$PROJECT_DIR/start.sh" "$PROJECT_DIR/stop.sh" "$PROJECT_DIR/restart.sh" "$PROJECT_DIR/update.sh" "$PROJECT_DIR/backup.sh"
chmod +x "$PROJECT_DIR/start.sh" "$PROJECT_DIR/stop.sh" "$PROJECT_DIR/restart.sh" "$PROJECT_DIR/update.sh" "$PROJECT_DIR/backup.sh" "$PROJECT_DIR/init-admin.sh" "$PROJECT_DIR/manage-tenant.sh" "$PROJECT_DIR/run-tenant-cmd.sh" 2>/dev/null || true
}
deploy() {