Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9c24e79bba | |||
| 79c325329c | |||
| 8f81ffb4c5 | |||
| 1d7692ea01 | |||
| 038390b8cd | |||
| f2c1dc2ba5 | |||
| 3eeae76e6c | |||
| 0228e6cb1d | |||
| 4c59cca1a4 | |||
| 8412ae76ee | |||
| ec4483c415 | |||
| 52656c715e | |||
| f6755aad42 |
@@ -322,6 +322,32 @@ INVENTAR_WORKER_CONNECTIONS=100
|
||||
|
||||
---
|
||||
|
||||
## Schul-Konfiguration pro Tenant
|
||||
|
||||
Die Datei `config.json` unterstützt jetzt einen `tenants`-Block. Damit kann jede Schule eigene Modul-Schalter bekommen, ohne dass das ganze System global umgestellt werden muss.
|
||||
|
||||
```json
|
||||
{
|
||||
"tenants": {
|
||||
"schule1": {
|
||||
"modules": {
|
||||
"library": { "enabled": true },
|
||||
"student_cards": { "enabled": false }
|
||||
}
|
||||
},
|
||||
"schule2": {
|
||||
"modules": {
|
||||
"library": { "enabled": false }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Wenn ein Request über Subdomain oder `X-Tenant-ID` aufgelöst wird, liest die App diese Werte automatisch aus und blendet die Bibliothek bzw. andere Module nur für diesen Tenant ein oder aus.
|
||||
|
||||
---
|
||||
|
||||
## Support & Debugging
|
||||
|
||||
**Fragen?**
|
||||
|
||||
+22
@@ -339,6 +339,28 @@ def _enforce_user_permissions():
|
||||
return None
|
||||
|
||||
|
||||
@app.before_request
|
||||
def _enforce_active_session_user():
|
||||
endpoint = request.endpoint or ''
|
||||
if endpoint == 'static' or endpoint.startswith('static'):
|
||||
return None
|
||||
|
||||
username = session.get('username')
|
||||
if not username:
|
||||
return None
|
||||
|
||||
user = us.get_user(username)
|
||||
if user:
|
||||
return None
|
||||
|
||||
session.clear()
|
||||
if request.path.startswith('/api/') or request.is_json:
|
||||
return jsonify({'ok': False, 'message': 'Sitzung ungültig. Bitte erneut anmelden.'}), 401
|
||||
|
||||
flash('Ihre Sitzung ist nicht mehr gültig. Bitte erneut anmelden.', 'error')
|
||||
return redirect(url_for('login'))
|
||||
|
||||
|
||||
def _get_asset_version():
|
||||
"""Return a cache-busting asset version tied to deployment state."""
|
||||
env_version = os.getenv('INVENTAR_ASSET_VERSION', '').strip()
|
||||
|
||||
+30
-2
@@ -157,8 +157,36 @@ SSL_KEY = _get(_conf, ['ssl', 'key'], DEFAULTS['ssl']['key'])
|
||||
SCHOOL_PERIODS = _get(_conf, ['schoolPeriods'], DEFAULTS['schoolPeriods'])
|
||||
|
||||
# Optional feature modules
|
||||
LIBRARY_MODULE_ENABLED = bool(_get(_conf, ['modules', 'library', 'enabled'], DEFAULTS['modules']['library']['enabled']))
|
||||
STUDENT_CARDS_MODULE_ENABLED = bool(_get(_conf, ['modules', 'student_cards', 'enabled'], DEFAULTS['modules']['student_cards']['enabled']))
|
||||
TENANT_CONFIGS = _get(_conf, ['tenants'], {})
|
||||
|
||||
|
||||
class _TenantAwareBool:
|
||||
def __init__(self, module_name, default):
|
||||
self.module_name = module_name
|
||||
self.default = bool(default)
|
||||
|
||||
def resolve(self):
|
||||
try:
|
||||
from tenant import is_tenant_module_enabled
|
||||
return bool(is_tenant_module_enabled(self.module_name, default=self.default))
|
||||
except Exception:
|
||||
return self.default
|
||||
|
||||
def __bool__(self):
|
||||
return self.resolve()
|
||||
|
||||
def __int__(self):
|
||||
return int(self.resolve())
|
||||
|
||||
def __str__(self):
|
||||
return 'True' if self.resolve() else 'False'
|
||||
|
||||
def __repr__(self):
|
||||
return f"_TenantAwareBool(module_name={self.module_name!r}, value={self.resolve()!r})"
|
||||
|
||||
|
||||
LIBRARY_MODULE_ENABLED = _TenantAwareBool('library', _get(_conf, ['modules', 'library', 'enabled'], DEFAULTS['modules']['library']['enabled']))
|
||||
STUDENT_CARDS_MODULE_ENABLED = _TenantAwareBool('student_cards', _get(_conf, ['modules', 'student_cards', 'enabled'], DEFAULTS['modules']['student_cards']['enabled']))
|
||||
STUDENT_DEFAULT_BORROW_DAYS = int(_get(_conf, ['modules', 'student_cards', 'default_borrow_days'], DEFAULTS['modules']['student_cards']['default_borrow_days']))
|
||||
STUDENT_MAX_BORROW_DAYS = int(_get(_conf, ['modules', 'student_cards', 'max_borrow_days'], DEFAULTS['modules']['student_cards']['max_borrow_days']))
|
||||
|
||||
|
||||
+10
-1
@@ -1871,6 +1871,8 @@
|
||||
function adaptNavbarByWidth() {
|
||||
if (!navList) return;
|
||||
|
||||
const isMobileViewport = window.matchMedia('(max-width: 991.98px)').matches;
|
||||
|
||||
function isNavOverflowing(bufferPx) {
|
||||
const buffer = typeof bufferPx === 'number' ? bufferPx : 0;
|
||||
const collapseOverflow = navCollapse ? (navCollapse.scrollWidth > (navCollapse.clientWidth - buffer)) : false;
|
||||
@@ -1881,7 +1883,14 @@
|
||||
|
||||
applyCompactMode();
|
||||
|
||||
if (window.innerWidth < 992) {
|
||||
// Desktop/tablet-large: keep complete navigation visible.
|
||||
if (!isMobileViewport) {
|
||||
restoreAllNavItems();
|
||||
return;
|
||||
}
|
||||
|
||||
// Mobile menu is collapsed: avoid hiding links preemptively.
|
||||
if (navCollapse && !navCollapse.classList.contains('show')) {
|
||||
restoreAllNavItems();
|
||||
return;
|
||||
}
|
||||
|
||||
+237
-1
@@ -208,12 +208,23 @@
|
||||
<script>
|
||||
// View mode persistence
|
||||
const LIBRARY_VIEW_MODE_KEY = 'inventarLibraryViewMode';
|
||||
const MOBILE_LIBRARY_MAX_WIDTH = 900;
|
||||
const MOBILE_LIBRARY_MIN_ITEMS = 24;
|
||||
|
||||
function isMobileViewport() {
|
||||
return window.matchMedia(`(max-width: ${MOBILE_LIBRARY_MAX_WIDTH}px)`).matches;
|
||||
}
|
||||
|
||||
function setViewMode(mode) {
|
||||
localStorage.setItem(LIBRARY_VIEW_MODE_KEY, mode);
|
||||
const container = document.getElementById('itemsContainer');
|
||||
const tableHeader = document.getElementById('tableHeader');
|
||||
const items = container.querySelectorAll('.item-card');
|
||||
const renderedItems = Array.from(container.querySelectorAll('.item-card'));
|
||||
const virtualizationState = window.mobileLibraryVirtualizationState || null;
|
||||
const detachedItems = virtualizationState && virtualizationState.active && Array.isArray(virtualizationState.items)
|
||||
? virtualizationState.items.filter(item => !container.contains(item))
|
||||
: [];
|
||||
const items = [...renderedItems, ...detachedItems.filter(item => !container.contains(item))];
|
||||
|
||||
items.forEach(item => {
|
||||
const cardContent = item.querySelector('.item-content.card-mode');
|
||||
@@ -236,6 +247,229 @@ function setViewMode(mode) {
|
||||
document.getElementById('viewModeToggle').classList.toggle('open', mode === 'table');
|
||||
}
|
||||
|
||||
function initMobileWindowedLibraryLoading() {
|
||||
const container = document.getElementById('itemsContainer');
|
||||
if (!container) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!window.mobileLibraryVirtualizationState) {
|
||||
window.mobileLibraryVirtualizationState = {
|
||||
active: false,
|
||||
items: [],
|
||||
topSpacer: null,
|
||||
bottomSpacer: null,
|
||||
averageItemHeight: Math.max(Math.round(window.innerHeight * 0.35), 230),
|
||||
lastStart: -1,
|
||||
lastEnd: -1,
|
||||
framePending: false,
|
||||
scrollListener: null,
|
||||
resizeListener: null,
|
||||
initialized: false
|
||||
};
|
||||
}
|
||||
const state = window.mobileLibraryVirtualizationState;
|
||||
|
||||
function restoreAllImages() {
|
||||
state.items.forEach(item => {
|
||||
const image = item.querySelector('img.item-image');
|
||||
if (!image) {
|
||||
return;
|
||||
}
|
||||
|
||||
const originalSrc = image.dataset.mobileSrc || '';
|
||||
if (!image.getAttribute('src') && originalSrc) {
|
||||
image.setAttribute('src', originalSrc);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function updateImageMemory(startIndex, endIndex) {
|
||||
const imageBuffer = 4;
|
||||
state.items.forEach((item, index) => {
|
||||
const image = item.querySelector('img.item-image');
|
||||
if (!image) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!image.dataset.mobileSrc) {
|
||||
image.dataset.mobileSrc = image.getAttribute('src') || '';
|
||||
}
|
||||
image.loading = 'lazy';
|
||||
image.decoding = 'async';
|
||||
|
||||
const shouldKeepLoaded = index >= startIndex - imageBuffer && index < endIndex + imageBuffer;
|
||||
if (shouldKeepLoaded) {
|
||||
if (!image.getAttribute('src') && image.dataset.mobileSrc) {
|
||||
image.setAttribute('src', image.dataset.mobileSrc);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (image.getAttribute('src')) {
|
||||
image.removeAttribute('src');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function renderVisibleWindow() {
|
||||
if (!state.active || !state.topSpacer || !state.bottomSpacer) {
|
||||
return;
|
||||
}
|
||||
|
||||
const viewportHeight = window.innerHeight || 800;
|
||||
const scrollTop = window.scrollY || window.pageYOffset || 0;
|
||||
const renderBuffer = viewportHeight * 1.5;
|
||||
|
||||
let startIndex = Math.max(0, Math.floor((scrollTop - renderBuffer) / state.averageItemHeight));
|
||||
let endIndex = Math.min(state.items.length, Math.ceil((scrollTop + viewportHeight + renderBuffer) / state.averageItemHeight));
|
||||
|
||||
if (endIndex - startIndex < 14) {
|
||||
endIndex = Math.min(state.items.length, startIndex + 14);
|
||||
}
|
||||
|
||||
if (startIndex === state.lastStart && endIndex === state.lastEnd) {
|
||||
return;
|
||||
}
|
||||
|
||||
state.lastStart = startIndex;
|
||||
state.lastEnd = endIndex;
|
||||
|
||||
while (state.topSpacer.nextSibling && state.topSpacer.nextSibling !== state.bottomSpacer) {
|
||||
state.topSpacer.nextSibling.remove();
|
||||
}
|
||||
|
||||
const fragment = document.createDocumentFragment();
|
||||
for (let index = startIndex; index < endIndex; index += 1) {
|
||||
fragment.appendChild(state.items[index]);
|
||||
}
|
||||
container.insertBefore(fragment, state.bottomSpacer);
|
||||
|
||||
let measuredHeightSum = 0;
|
||||
let measuredCount = 0;
|
||||
for (let index = startIndex; index < endIndex; index += 1) {
|
||||
const height = state.items[index].getBoundingClientRect().height;
|
||||
if (height > 0) {
|
||||
measuredHeightSum += height;
|
||||
measuredCount += 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (measuredCount > 0) {
|
||||
const measuredAverage = measuredHeightSum / measuredCount;
|
||||
state.averageItemHeight = Math.round((state.averageItemHeight * 3 + measuredAverage) / 4);
|
||||
}
|
||||
|
||||
state.topSpacer.style.height = `${Math.max(0, startIndex * state.averageItemHeight)}px`;
|
||||
state.bottomSpacer.style.height = `${Math.max(0, (state.items.length - endIndex) * state.averageItemHeight)}px`;
|
||||
updateImageMemory(startIndex, endIndex);
|
||||
}
|
||||
|
||||
function scheduleWindowRender() {
|
||||
if (!state.active || state.framePending) {
|
||||
return;
|
||||
}
|
||||
state.framePending = true;
|
||||
requestAnimationFrame(() => {
|
||||
state.framePending = false;
|
||||
renderVisibleWindow();
|
||||
});
|
||||
}
|
||||
|
||||
function activateVirtualization() {
|
||||
if (state.active) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!Array.isArray(state.items) || state.items.length === 0) {
|
||||
state.items = Array.from(container.querySelectorAll('.library-item'));
|
||||
}
|
||||
if (state.items.length <= MOBILE_LIBRARY_MIN_ITEMS) {
|
||||
return;
|
||||
}
|
||||
|
||||
state.topSpacer = document.createElement('div');
|
||||
state.topSpacer.className = 'mobile-window-spacer top';
|
||||
state.bottomSpacer = document.createElement('div');
|
||||
state.bottomSpacer.className = 'mobile-window-spacer bottom';
|
||||
|
||||
state.items.forEach(item => item.remove());
|
||||
container.appendChild(state.topSpacer);
|
||||
container.appendChild(state.bottomSpacer);
|
||||
|
||||
state.lastStart = -1;
|
||||
state.lastEnd = -1;
|
||||
state.framePending = false;
|
||||
state.active = true;
|
||||
|
||||
if (!state.scrollListener) {
|
||||
state.scrollListener = () => scheduleWindowRender();
|
||||
window.addEventListener('scroll', state.scrollListener, { passive: true });
|
||||
}
|
||||
|
||||
scheduleWindowRender();
|
||||
}
|
||||
|
||||
function deactivateVirtualization() {
|
||||
if (!state.active) {
|
||||
if (!Array.isArray(state.items) || state.items.length === 0) {
|
||||
state.items = Array.from(container.querySelectorAll('.library-item'));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (state.scrollListener) {
|
||||
window.removeEventListener('scroll', state.scrollListener);
|
||||
state.scrollListener = null;
|
||||
}
|
||||
|
||||
if (state.topSpacer && state.topSpacer.parentNode === container) {
|
||||
state.topSpacer.remove();
|
||||
}
|
||||
if (state.bottomSpacer && state.bottomSpacer.parentNode === container) {
|
||||
state.bottomSpacer.remove();
|
||||
}
|
||||
|
||||
const fragment = document.createDocumentFragment();
|
||||
state.items.forEach(item => fragment.appendChild(item));
|
||||
container.appendChild(fragment);
|
||||
|
||||
restoreAllImages();
|
||||
|
||||
state.topSpacer = null;
|
||||
state.bottomSpacer = null;
|
||||
state.lastStart = -1;
|
||||
state.lastEnd = -1;
|
||||
state.framePending = false;
|
||||
state.active = false;
|
||||
|
||||
const currentMode = localStorage.getItem(LIBRARY_VIEW_MODE_KEY) || 'card';
|
||||
setViewMode(currentMode);
|
||||
}
|
||||
|
||||
function syncVirtualizationWithViewport() {
|
||||
if (!Array.isArray(state.items) || state.items.length === 0) {
|
||||
state.items = Array.from(container.querySelectorAll('.library-item'));
|
||||
}
|
||||
|
||||
const shouldVirtualize = isMobileViewport() && state.items.length > MOBILE_LIBRARY_MIN_ITEMS;
|
||||
if (shouldVirtualize) {
|
||||
activateVirtualization();
|
||||
scheduleWindowRender();
|
||||
} else {
|
||||
deactivateVirtualization();
|
||||
}
|
||||
}
|
||||
|
||||
if (!state.initialized) {
|
||||
state.resizeListener = () => syncVirtualizationWithViewport();
|
||||
window.addEventListener('resize', state.resizeListener, { passive: true });
|
||||
state.initialized = true;
|
||||
}
|
||||
|
||||
syncVirtualizationWithViewport();
|
||||
}
|
||||
|
||||
// Initialize view mode
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const savedMode = localStorage.getItem(LIBRARY_VIEW_MODE_KEY) || 'card';
|
||||
@@ -311,6 +545,8 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
if (e.target === this) this.style.display = 'none';
|
||||
});
|
||||
});
|
||||
|
||||
initMobileWindowedLibraryLoading();
|
||||
});
|
||||
|
||||
function displayLibraryItemDetail(item) {
|
||||
|
||||
@@ -10,11 +10,43 @@ Each tenant can support up to 20+ users with isolated data and resource pools.
|
||||
from flask import request, g, has_request_context
|
||||
from functools import wraps
|
||||
import logging
|
||||
import settings as cfg
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 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)
|
||||
|
||||
|
||||
def _get_nested_value(source, path, default=None):
|
||||
current = source
|
||||
for key in path:
|
||||
if isinstance(current, dict) and key in current:
|
||||
current = current[key]
|
||||
else:
|
||||
return default
|
||||
return current
|
||||
|
||||
|
||||
def get_tenant_config(tenant_id=None):
|
||||
"""Return the registered config for a tenant, falling back to default."""
|
||||
if tenant_id is None:
|
||||
ctx = get_tenant_context()
|
||||
tenant_id = ctx.tenant_id if ctx and ctx.tenant_id else 'default'
|
||||
|
||||
if tenant_id in TENANT_REGISTRY:
|
||||
return TENANT_REGISTRY[tenant_id] or {}
|
||||
|
||||
return TENANT_REGISTRY.get('default', {}) or {}
|
||||
|
||||
|
||||
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)
|
||||
|
||||
|
||||
class TenantContext:
|
||||
@@ -27,6 +59,7 @@ class TenantContext:
|
||||
self.tenant_id = None
|
||||
self.db_name = None
|
||||
self.subdomain = None
|
||||
self.config = {}
|
||||
|
||||
def resolve_tenant(self):
|
||||
"""
|
||||
@@ -40,6 +73,7 @@ class TenantContext:
|
||||
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)
|
||||
|
||||
# Priority 2: Subdomain extraction
|
||||
@@ -56,10 +90,12 @@ class TenantContext:
|
||||
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)
|
||||
return self._get_db_name(potential_subdomain)
|
||||
|
||||
# Fallback to default tenant if no subdomain detected
|
||||
self.tenant_id = 'default'
|
||||
self.config = get_tenant_config('default')
|
||||
return self._get_db_name('default')
|
||||
|
||||
def _get_db_name(self, tenant_id):
|
||||
|
||||
@@ -33,11 +33,12 @@ LOG_FILE="${LOG_FILE:-$LOG_DIR/backup.log}"
|
||||
COMPRESSION_LEVEL="${COMPRESSION_LEVEL:-9}"
|
||||
KEEP_DAYS="${KEEP_DAYS:-7}"
|
||||
MIN_KEEP="${MIN_KEEP:-7}"
|
||||
DB_NAME="${DB_NAME:-Inventarsystem}"
|
||||
DB_NAME="${DB_NAME:-inventar_default}"
|
||||
MONGO_URI="${MONGO_URI:-mongodb://localhost:27017/}"
|
||||
INVOICE_KEEP_DAYS="${INVOICE_KEEP_DAYS:-3650}"
|
||||
INVOICE_ARCHIVE_DIR="${INVOICE_ARCHIVE_DIR:-$BACKUP_BASE_DIR/invoice-archive}"
|
||||
BACKUP_MODE="${BACKUP_MODE:-auto}"
|
||||
COMPOSE_FILE="${COMPOSE_FILE:-docker-compose-multitenant.yml}"
|
||||
DOCKER_AVAILABLE=0
|
||||
DOCKER_COMPOSE_CMD=()
|
||||
USE_NULL_OUTPUT=false
|
||||
@@ -58,6 +59,8 @@ Options:
|
||||
--keep-days <N> Age-based retention in days (default: $KEEP_DAYS; 0 disables age filter)
|
||||
--min-keep <N> Always keep at least this many backups (default: $MIN_KEEP)
|
||||
--mode <auto|host|docker> Backup mode (default: $BACKUP_MODE)
|
||||
--multitenant Use docker-compose-multitenant.yml (default)
|
||||
--singletenant Use docker-compose.yml
|
||||
-h|--help Show this help and exit
|
||||
EOF
|
||||
}
|
||||
@@ -87,6 +90,10 @@ while [[ $# -gt 0 ]]; do
|
||||
MIN_KEEP="$2"; shift 2;;
|
||||
--mode)
|
||||
BACKUP_MODE="$2"; shift 2;;
|
||||
--multitenant)
|
||||
COMPOSE_FILE="docker-compose-multitenant.yml"; shift;;
|
||||
--singletenant)
|
||||
COMPOSE_FILE="docker-compose.yml"; shift;;
|
||||
-h|--help)
|
||||
usage; exit 0;;
|
||||
*)
|
||||
@@ -102,12 +109,12 @@ log_message() {
|
||||
init_docker_compose() {
|
||||
if docker compose version >/dev/null 2>&1; then
|
||||
DOCKER_AVAILABLE=1
|
||||
DOCKER_COMPOSE_CMD=(docker compose -f "$PROJECT_DIR/docker-compose.yml")
|
||||
DOCKER_COMPOSE_CMD=(docker compose -f "$PROJECT_DIR/$COMPOSE_FILE")
|
||||
return 0
|
||||
fi
|
||||
if sudo docker compose version >/dev/null 2>&1; then
|
||||
DOCKER_AVAILABLE=1
|
||||
DOCKER_COMPOSE_CMD=(sudo docker compose -f "$PROJECT_DIR/docker-compose.yml")
|
||||
DOCKER_COMPOSE_CMD=(sudo docker compose -f "$PROJECT_DIR/$COMPOSE_FILE")
|
||||
return 0
|
||||
fi
|
||||
|
||||
|
||||
@@ -50,6 +50,8 @@
|
||||
}
|
||||
},
|
||||
|
||||
"tenants": {},
|
||||
|
||||
"allowed_extensions": [
|
||||
"png", "jpg", "jpeg", "gif",
|
||||
"hevc", "heif",
|
||||
|
||||
@@ -9,6 +9,22 @@
|
||||
# docker-compose -f docker-compose-multitenant.yml up -d --scale app=10
|
||||
|
||||
services:
|
||||
cloudflared:
|
||||
image: cloudflare/cloudflared:latest
|
||||
container_name: cloudflared
|
||||
restart: unless-stopped
|
||||
profiles:
|
||||
- cloudflare
|
||||
command: ["--config", "/etc/cloudflared/config.yml", "tunnel", "run"]
|
||||
volumes:
|
||||
- ./docker/cloudflared/config.yml:/etc/cloudflared/config.yml:ro
|
||||
- /etc/cloudflared/credentials.json:/etc/cloudflared/credentials.json:ro
|
||||
depends_on:
|
||||
nginx:
|
||||
condition: service_started
|
||||
networks:
|
||||
- inventar-net
|
||||
|
||||
# Management Container for multi-tenant scripts
|
||||
tenant-manager:
|
||||
image: docker:cli
|
||||
@@ -92,6 +108,8 @@ services:
|
||||
args:
|
||||
PYTHON_VERSION: "3.13"
|
||||
OPTIMIZATION_LEVEL: 2
|
||||
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", "-"]
|
||||
restart: unless-stopped
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
@@ -106,7 +124,6 @@ services:
|
||||
- "8000"
|
||||
volumes:
|
||||
- ./config.json:/app/config.json:ro
|
||||
- ./Web:/app/Web:ro
|
||||
- app_uploads:/app/Web/uploads:cached
|
||||
- app_thumbnails:/app/Web/thumbnails:cached
|
||||
- app_previews:/app/Web/previews:cached
|
||||
|
||||
+16
-1
@@ -1,4 +1,18 @@
|
||||
services:
|
||||
cloudflared:
|
||||
image: cloudflare/cloudflared:latest
|
||||
container_name: cloudflared
|
||||
restart: unless-stopped
|
||||
profiles:
|
||||
- cloudflare
|
||||
command: ["--config", "/etc/cloudflared/config.yml", "tunnel", "run"]
|
||||
volumes:
|
||||
- ./docker/cloudflared/config.yml:/etc/cloudflared/config.yml:ro
|
||||
- /etc/cloudflared/credentials.json:/etc/cloudflared/credentials.json:ro
|
||||
depends_on:
|
||||
nginx:
|
||||
condition: service_started
|
||||
|
||||
nginx:
|
||||
image: nginx:1.27-alpine
|
||||
container_name: inventarsystem-nginx
|
||||
@@ -42,6 +56,8 @@ services:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
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", "-"]
|
||||
container_name: inventarsystem-app
|
||||
restart: unless-stopped
|
||||
security_opt:
|
||||
@@ -66,7 +82,6 @@ services:
|
||||
- "8000"
|
||||
volumes:
|
||||
- ./config.json:/app/config.json:ro
|
||||
- ./Web:/app/Web:ro
|
||||
- app_uploads:/app/Web/uploads
|
||||
- app_thumbnails:/app/Web/thumbnails
|
||||
- app_previews:/app/Web/previews
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
tunnel: homeserver
|
||||
credentials-file: /etc/cloudflared/credentials.json
|
||||
|
||||
ingress:
|
||||
- service: https://nginx:443
|
||||
originRequest:
|
||||
noTLSVerify: true
|
||||
- service: http_status:404
|
||||
+27
-8
@@ -22,6 +22,7 @@ LEGACY_BACKUP_ARCHIVE=""
|
||||
CLEANUP_OLD_SERVICES=true
|
||||
CLEANUP_OLD_REMOVE_CRON=false
|
||||
TMP_DIR=""
|
||||
COMPOSE_FILE="docker-compose-multitenant.yml"
|
||||
|
||||
cleanup_tmp_dir() {
|
||||
if [ -n "${TMP_DIR:-}" ] && [ -d "$TMP_DIR" ]; then
|
||||
@@ -50,14 +51,13 @@ refresh_start_script_from_main() {
|
||||
pin_compose_app_image() {
|
||||
local tag="$1"
|
||||
local compose_file
|
||||
compose_file="$PROJECT_DIR/docker-compose.yml"
|
||||
|
||||
if [ ! -f "$compose_file" ]; then
|
||||
echo "Warning: $compose_file not found; cannot pin app image"
|
||||
return 0
|
||||
fi
|
||||
for compose_file in "$PROJECT_DIR/docker-compose-multitenant.yml" "$PROJECT_DIR/docker-compose.yml"; do
|
||||
if [ ! -f "$compose_file" ]; then
|
||||
continue
|
||||
fi
|
||||
|
||||
python3 - <<'PY' "$compose_file" "$tag"
|
||||
python3 - <<'PY' "$compose_file" "$tag"
|
||||
import re
|
||||
import sys
|
||||
|
||||
@@ -151,6 +151,8 @@ if in_app_service and build_found and app_service_indent is not None:
|
||||
with open(compose_file, "w", encoding="utf-8") as f:
|
||||
f.writelines(out)
|
||||
PY
|
||||
|
||||
done
|
||||
}
|
||||
|
||||
install_docker_if_missing() {
|
||||
@@ -173,6 +175,8 @@ Options:
|
||||
--remove-legacy-system Remove old host MongoDB/system after successful import
|
||||
--skip-cleanup-old Do not run cleanup-old.sh after install
|
||||
--cleanup-old-remove-cron Also remove matching cron entries during old-system cleanup
|
||||
--multitenant Use docker-compose-multitenant.yml (default)
|
||||
--singletenant Use docker-compose.yml
|
||||
--legacy-db-name <name> Legacy database name (default: $LEGACY_DB_NAME)
|
||||
--legacy-mongo-uri <uri> Legacy Mongo URI (default: $LEGACY_MONGO_URI)
|
||||
--legacy-system-dir <path> Optional old system directory to remove after migration
|
||||
@@ -199,6 +203,14 @@ parse_args() {
|
||||
CLEANUP_OLD_REMOVE_CRON=true
|
||||
shift
|
||||
;;
|
||||
--multitenant)
|
||||
COMPOSE_FILE="docker-compose-multitenant.yml"
|
||||
shift
|
||||
;;
|
||||
--singletenant)
|
||||
COMPOSE_FILE="docker-compose.yml"
|
||||
shift
|
||||
;;
|
||||
--legacy-db-name)
|
||||
LEGACY_DB_NAME="$2"
|
||||
shift 2
|
||||
@@ -280,14 +292,21 @@ backup_legacy_database() {
|
||||
|
||||
restore_legacy_backup_into_docker() {
|
||||
local i
|
||||
local compose_path
|
||||
|
||||
if [ "$MIGRATE_LEGACY_DB" != "true" ] || [ -z "$LEGACY_BACKUP_ARCHIVE" ]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
compose_path="$PROJECT_DIR/$COMPOSE_FILE"
|
||||
if [ ! -f "$compose_path" ]; then
|
||||
echo "Error: compose file not found: $compose_path"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Waiting for Docker MongoDB to become ready..."
|
||||
for i in $(seq 1 60); do
|
||||
if sudo docker compose -f "$PROJECT_DIR/docker-compose.yml" exec -T mongodb mongosh --quiet --eval "db.adminCommand({ping:1}).ok" >/dev/null 2>&1; then
|
||||
if sudo docker compose -f "$compose_path" exec -T mongodb mongosh --quiet --eval "db.adminCommand({ping:1}).ok" >/dev/null 2>&1; then
|
||||
break
|
||||
fi
|
||||
sleep 2
|
||||
@@ -299,7 +318,7 @@ restore_legacy_backup_into_docker() {
|
||||
fi
|
||||
|
||||
echo "Importing legacy backup into Docker MongoDB..."
|
||||
sudo docker compose -f "$PROJECT_DIR/docker-compose.yml" exec -T mongodb mongorestore --archive --gzip --drop --nsInclude "${LEGACY_DB_NAME}.*" < "$LEGACY_BACKUP_ARCHIVE"
|
||||
sudo docker compose -f "$compose_path" exec -T mongodb mongorestore --archive --gzip --drop --nsInclude "${LEGACY_DB_NAME}.*" < "$LEGACY_BACKUP_ARCHIVE"
|
||||
echo "Legacy DB import completed."
|
||||
}
|
||||
|
||||
|
||||
+1
-2
@@ -4,5 +4,4 @@ set -euo pipefail
|
||||
SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null 2>&1 && pwd )"
|
||||
|
||||
"$SCRIPT_DIR/stop.sh" "$@"
|
||||
"$SCRIPT_DIR/start.sh" "$@"
|
||||
"$SCRIPT_DIR/start.sh"
|
||||
"$SCRIPT_DIR/start.sh" "$@"
|
||||
+14
-3
@@ -6,12 +6,13 @@ LOG_DIR="$SCRIPT_DIR/logs"
|
||||
LOG_FILE="$LOG_DIR/restore.log"
|
||||
WORK_DIR="$(mktemp -d /tmp/inventarsystem-restore-XXXXXX)"
|
||||
|
||||
DB_NAME="${INVENTAR_MONGODB_DB:-Inventarsystem}"
|
||||
DB_NAME="${INVENTAR_MONGODB_DB:-inventar_default}"
|
||||
SOURCE_PATH=""
|
||||
BACKUP_DATE=""
|
||||
DROP_DATABASE=false
|
||||
RESTART_SERVICES=false
|
||||
STAGED_PATH=""
|
||||
COMPOSE_FILE="docker-compose-multitenant.yml"
|
||||
|
||||
BACKUP_ROOT_LOCAL="$SCRIPT_DIR/backups"
|
||||
BACKUP_ROOT_SYSTEM="/var/backups"
|
||||
@@ -56,6 +57,8 @@ Options:
|
||||
- latest: newest from local/system backup roots
|
||||
--drop-database Drop target DB before import (recommended for full restore)
|
||||
--restart-services Restart stack after restore
|
||||
--multitenant Use docker-compose-multitenant.yml (default)
|
||||
--singletenant Use docker-compose.yml
|
||||
--list List detected backup candidates
|
||||
--help Show this help
|
||||
|
||||
@@ -68,12 +71,12 @@ EOF
|
||||
|
||||
setup_compose() {
|
||||
if docker compose version >/dev/null 2>&1 && docker info >/dev/null 2>&1; then
|
||||
DOCKER_COMPOSE=(docker compose)
|
||||
DOCKER_COMPOSE=(docker compose -f "$SCRIPT_DIR/$COMPOSE_FILE")
|
||||
return 0
|
||||
fi
|
||||
|
||||
if [ -n "$SUDO" ] && $SUDO docker compose version >/dev/null 2>&1 && $SUDO docker info >/dev/null 2>&1; then
|
||||
DOCKER_COMPOSE=($SUDO docker compose)
|
||||
DOCKER_COMPOSE=($SUDO docker compose -f "$SCRIPT_DIR/$COMPOSE_FILE")
|
||||
return 0
|
||||
fi
|
||||
|
||||
@@ -459,6 +462,14 @@ parse_args() {
|
||||
RESTART_SERVICES=true
|
||||
shift
|
||||
;;
|
||||
--multitenant)
|
||||
COMPOSE_FILE="docker-compose-multitenant.yml"
|
||||
shift
|
||||
;;
|
||||
--singletenant)
|
||||
COMPOSE_FILE="docker-compose.yml"
|
||||
shift
|
||||
;;
|
||||
--list)
|
||||
list_backups
|
||||
exit 0
|
||||
|
||||
@@ -7,6 +7,7 @@ cd "$SCRIPT_DIR"
|
||||
ENV_FILE="$SCRIPT_DIR/.docker-build.env"
|
||||
APP_IMAGE_REPO="ghcr.io/aiirondev/legendary-octo-garbanzo"
|
||||
DIST_DIR="$SCRIPT_DIR/dist"
|
||||
RUNTIME_COMPOSE_OVERRIDE_FILE="$SCRIPT_DIR/.docker-compose.runtime.override.yml"
|
||||
|
||||
SUDO=""
|
||||
if [ "$(id -u)" -ne 0 ] && command -v sudo >/dev/null 2>&1; then
|
||||
@@ -18,7 +19,8 @@ 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"
|
||||
COMPOSE_FILE="docker-compose-multitenant.yml"
|
||||
COMPOSE_PROFILES_VALUE=""
|
||||
|
||||
usage() {
|
||||
cat <<EOF
|
||||
@@ -28,6 +30,7 @@ Options:
|
||||
--no-cron Do not create or update cron jobs
|
||||
--with-cron Create/update cron jobs (default)
|
||||
--multitenant Use the multi-tenant architecture deployment
|
||||
--singletenant Use the legacy single-tenant compose deployment
|
||||
-h, --help Show this help message
|
||||
EOF
|
||||
}
|
||||
@@ -47,6 +50,10 @@ parse_args() {
|
||||
COMPOSE_FILE="docker-compose-multitenant.yml"
|
||||
shift
|
||||
;;
|
||||
--singletenant)
|
||||
COMPOSE_FILE="docker-compose.yml"
|
||||
shift
|
||||
;;
|
||||
-h|--help)
|
||||
usage
|
||||
exit 0
|
||||
@@ -433,6 +440,16 @@ resolve_app_image() {
|
||||
fi
|
||||
}
|
||||
|
||||
configure_cloudflared_profile() {
|
||||
if [ -f /etc/cloudflared/credentials.json ]; then
|
||||
COMPOSE_PROFILES_VALUE="cloudflare"
|
||||
echo "Cloudflared tunnel: enabled (homeserver)"
|
||||
else
|
||||
COMPOSE_PROFILES_VALUE=""
|
||||
echo "Cloudflared tunnel: disabled (missing /etc/cloudflared/credentials.json)"
|
||||
fi
|
||||
}
|
||||
|
||||
port_in_use() {
|
||||
local port="$1"
|
||||
|
||||
@@ -554,9 +571,24 @@ INVENTAR_APP_IMAGE=$APP_IMAGE_VALUE
|
||||
EOF
|
||||
}
|
||||
|
||||
write_runtime_compose_override() {
|
||||
cat > "$RUNTIME_COMPOSE_OVERRIDE_FILE" <<EOF
|
||||
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", "-"]
|
||||
image: ${APP_IMAGE_VALUE}
|
||||
build: null
|
||||
EOF
|
||||
}
|
||||
|
||||
verify_stack_health() {
|
||||
local compose_args running_services retry_count=0
|
||||
compose_args=(-f "$COMPOSE_FILE" --env-file "$ENV_FILE")
|
||||
compose_args=(-f "$COMPOSE_FILE")
|
||||
if [ -f "$RUNTIME_COMPOSE_OVERRIDE_FILE" ]; then
|
||||
compose_args+=(-f "$RUNTIME_COMPOSE_OVERRIDE_FILE")
|
||||
fi
|
||||
compose_args+=(--env-file "$ENV_FILE")
|
||||
|
||||
# Try health check with optional restart on first failure
|
||||
while [[ $retry_count -lt 2 ]]; do
|
||||
@@ -606,12 +638,26 @@ ensure_runtime_config_json
|
||||
setup_scheduled_jobs
|
||||
configure_nuitka_mode
|
||||
resolve_app_image
|
||||
configure_cloudflared_profile
|
||||
configure_host_ports
|
||||
ensure_app_image_loaded
|
||||
write_env_file
|
||||
write_runtime_compose_override
|
||||
|
||||
echo "Starting Inventarsystem Docker stack (app + mongodb)..."
|
||||
docker compose -f "$COMPOSE_FILE" --env-file "$ENV_FILE" up -d --remove-orphans
|
||||
compose_up_args=(-f "$COMPOSE_FILE")
|
||||
if [ -f "$RUNTIME_COMPOSE_OVERRIDE_FILE" ]; then
|
||||
compose_up_args+=(-f "$RUNTIME_COMPOSE_OVERRIDE_FILE")
|
||||
fi
|
||||
compose_up_args+=(--env-file "$ENV_FILE")
|
||||
if [ -n "$COMPOSE_PROFILES_VALUE" ]; then
|
||||
export COMPOSE_PROFILES="$COMPOSE_PROFILES_VALUE"
|
||||
fi
|
||||
if ! docker compose "${compose_up_args[@]}" up -d --remove-orphans; then
|
||||
echo "Docker Compose startup failed once. Waiting briefly and retrying..."
|
||||
sleep 5
|
||||
docker compose "${compose_up_args[@]}" up -d --remove-orphans
|
||||
fi
|
||||
|
||||
verify_stack_health
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ set -euo pipefail
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
cd "$SCRIPT_DIR"
|
||||
|
||||
COMPOSE_FILE="docker-compose.yml"
|
||||
COMPOSE_FILE="docker-compose-multitenant.yml"
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
@@ -12,6 +12,10 @@ while [[ $# -gt 0 ]]; do
|
||||
COMPOSE_FILE="docker-compose-multitenant.yml"
|
||||
shift
|
||||
;;
|
||||
--singletenant)
|
||||
COMPOSE_FILE="docker-compose.yml"
|
||||
shift
|
||||
;;
|
||||
*)
|
||||
shift
|
||||
;;
|
||||
|
||||
@@ -15,6 +15,7 @@ APP_IMAGE_ASSET_PREFIX="inventarsystem-image-"
|
||||
ENV_FILE="$PROJECT_DIR/.docker-build.env"
|
||||
APP_IMAGE_REPO="ghcr.io/aiirondev/legendary-octo-garbanzo"
|
||||
DIST_DIR="$PROJECT_DIR/dist"
|
||||
COMPOSE_FILE="docker-compose-multitenant.yml"
|
||||
|
||||
mkdir -p "$LOG_DIR"
|
||||
chmod 777 "$LOG_DIR" 2>/dev/null || true
|
||||
@@ -148,6 +149,41 @@ EOF
|
||||
fi
|
||||
}
|
||||
|
||||
usage() {
|
||||
cat <<EOF
|
||||
Usage: $0 [options]
|
||||
|
||||
Options:
|
||||
--multitenant Use docker-compose-multitenant.yml (default)
|
||||
--singletenant Use docker-compose.yml
|
||||
-h, --help Show this help message
|
||||
EOF
|
||||
}
|
||||
|
||||
parse_args() {
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--multitenant)
|
||||
COMPOSE_FILE="docker-compose-multitenant.yml"
|
||||
shift
|
||||
;;
|
||||
--singletenant)
|
||||
COMPOSE_FILE="docker-compose.yml"
|
||||
shift
|
||||
;;
|
||||
-h|--help)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
log_message "ERROR: Unknown option: $1"
|
||||
usage
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
done
|
||||
}
|
||||
|
||||
archive_logs() {
|
||||
log_message "Checking for monthly log archival..."
|
||||
if [ -x "$PROJECT_DIR/archive-logs.sh" ]; then
|
||||
@@ -360,6 +396,13 @@ deploy() {
|
||||
local tag="$1"
|
||||
local meta_file="$2"
|
||||
local app_image="${APP_IMAGE_REPO}:${tag}"
|
||||
local compose_path
|
||||
|
||||
compose_path="$PROJECT_DIR/$COMPOSE_FILE"
|
||||
if [ ! -f "$compose_path" ]; then
|
||||
log_message "ERROR: compose file not found: $compose_path"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cd "$PROJECT_DIR"
|
||||
if [ ! -f "$ENV_FILE" ]; then
|
||||
@@ -385,15 +428,15 @@ EOF
|
||||
fi
|
||||
fi
|
||||
|
||||
docker compose --env-file "$ENV_FILE" pull nginx mongodb >> "$LOG_FILE" 2>&1
|
||||
docker compose --env-file "$ENV_FILE" up -d --remove-orphans >> "$LOG_FILE" 2>&1
|
||||
docker compose -f "$compose_path" --env-file "$ENV_FILE" pull nginx mongodb >> "$LOG_FILE" 2>&1
|
||||
docker compose -f "$compose_path" --env-file "$ENV_FILE" up -d --remove-orphans >> "$LOG_FILE" 2>&1
|
||||
docker tag "$app_image" "$APP_IMAGE_REPO:latest" >> "$LOG_FILE" 2>&1 || true
|
||||
}
|
||||
|
||||
verify_stack_health() {
|
||||
local compose_args running_services
|
||||
local https_port
|
||||
compose_args=(--env-file "$ENV_FILE")
|
||||
compose_args=(-f "$PROJECT_DIR/$COMPOSE_FILE" --env-file "$ENV_FILE")
|
||||
https_port="$(awk -F= '/^INVENTAR_HTTPS_PORT=/{print $2}' "$ENV_FILE" | tr -d ' ')"
|
||||
if [ -z "$https_port" ]; then
|
||||
https_port="443"
|
||||
@@ -418,6 +461,8 @@ verify_stack_health() {
|
||||
}
|
||||
|
||||
main() {
|
||||
parse_args "$@"
|
||||
|
||||
ensure_runtime_dependencies
|
||||
ensure_tls_certificates
|
||||
ensure_nginx_config_mount_source
|
||||
|
||||
Reference in New Issue
Block a user