Compare commits

..

14 Commits

Author SHA1 Message Date
Aiirondev_dev 2b582c8700 fix(tenant-manager): mount config.json into helper container 2026-05-08 20:36:33 +02:00
Aiirondev_dev fd55fa9703 fix(tenant-manager): pass COMPOSE_PROJECT_NAME to inner compose call 2026-05-08 15:53:40 +02:00
Aiirondev_dev 08e13d4f23 feat(ui): replace Invario text with transparent logo 2026-05-08 15:43:50 +02:00
Aiirondev_dev 7c3eadc8cf fix(tenant-manager): pass COMPOSE_PROJECT_NAME to container to avoid container naming conflicts 2026-05-08 15:35:52 +02:00
Aiirondev_dev e7f8b8d76a fix(tenant-manager): add docker-cli-compose package to alpine container 2026-05-08 15:27:25 +02:00
Aiirondev_dev d6ac8a9e16 fix(ui): include /home_admin and other admin routes in inventory module detection 2026-05-08 15:18:34 +02:00
Aiirondev_dev c7cfb0e411 fix(ui): ensure /home route correctly activates the inventory theme and navbar 2026-05-08 14:54:55 +02:00
Aiirondev_dev 6c73f79866 Enhance module navigation: update inventory and library navbar logic for improved module access and user session handling 2026-05-08 12:03:38 +02:00
Aiirondev_dev 5cb59f4b63 Enhance module management: add inventory module support in settings, update templates and app logic for module access control, and extend tenant management script for module configuration 2026-05-08 11:24:53 +02:00
Aiirondev_dev 326fc4ef91 Enhance Docker configuration: update .docker-build.env and .docker-compose.runtime.override.yml for improved resource allocation and service settings 2026-05-01 10:51:43 +02:00
Aiirondev_dev 12cd365f36 Enhance filter retrieval: update get_filter API to return full filter lists and modify frontend to utilize them for improved item filtering 2026-04-29 17:17:54 +02:00
Aiirondev_dev b06828cc7e Enhance logging: change log level to DEBUG and add detailed database connection logging in user and tenant modules
Co-authored-by: Copilot <copilot@github.com>
2026-04-29 13:44:22 +02:00
Aiirondev_dev 79f07d8631 Enhance logging: update logger configuration to use 'app' and set log level to INFO
Co-authored-by: Copilot <copilot@github.com>
2026-04-29 13:30:52 +02:00
Aiirondev_dev 2ef5056727 Enhance logging: add detailed context and error handling in tenant resolution and user authentication
Co-authored-by: Copilot <copilot@github.com>
2026-04-29 12:57:21 +02:00
13 changed files with 651 additions and 295 deletions
+9
View File
@@ -1,3 +1,12 @@
NUITKA_BUILD=0 NUITKA_BUILD=0
INVENTAR_HTTP_PORT=10000 INVENTAR_HTTP_PORT=10000
INVENTAR_HTTP_PORTS=10000
INVENTAR_APP_IMAGE=ghcr.io/aiirondev/legendary-octo-garbanzo:latest INVENTAR_APP_IMAGE=ghcr.io/aiirondev/legendary-octo-garbanzo:latest
INVENTAR_APP_CPUS=1.0
INVENTAR_APP_MEM_LIMIT=384m
INVENTAR_APP_MEM_SWAP_LIMIT=768m
INVENTAR_WORKERS=4
INVENTAR_THREADS=2
INVENTAR_WORKER_TIMEOUT=30
INVENTAR_WORKER_CONNECTIONS=100
+3 -1
View File
@@ -1,6 +1,8 @@
services: services:
app: app:
working_dir: /app/Web 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", "4", "--threads", "2", "--timeout", "30", "--graceful-timeout", "20", "--worker-connections", "100", "--max-requests", "200", "--max-requests-jitter", "50", "--log-level", "info", "--access-logfile", "-", "--error-logfile", "-"]
image: ghcr.io/aiirondev/legendary-octo-garbanzo:latest image: ghcr.io/aiirondev/legendary-octo-garbanzo:latest
build: null build: null
ports:
- "10000:8000"
+74 -4
View File
@@ -81,12 +81,12 @@ from tenant import get_tenant_context
app = Flask(__name__, static_folder='static') # Correctly set static folder app = Flask(__name__, static_folder='static') # Correctly set static folder
app.logger.setLevel(logging.INFO) app.logger.setLevel(logging.DEBUG)
if not os.path.exists(cfg.LOGS_FOLDER): if not os.path.exists(cfg.LOGS_FOLDER):
os.makedirs(cfg.LOGS_FOLDER, exist_ok=True) os.makedirs(cfg.LOGS_FOLDER, exist_ok=True)
log_file_path = os.path.join(cfg.LOGS_FOLDER, 'application.log') log_file_path = os.path.join(cfg.LOGS_FOLDER, 'application.log')
file_handler = RotatingFileHandler(log_file_path, maxBytes=10 * 1024 * 1024, backupCount=5, encoding='utf-8') file_handler = RotatingFileHandler(log_file_path, maxBytes=10 * 1024 * 1024, backupCount=5, encoding='utf-8')
file_handler.setLevel(logging.INFO) file_handler.setLevel(logging.DEBUG)
file_handler.setFormatter(logging.Formatter('%(asctime)s %(levelname)s %(name)s %(message)s')) file_handler.setFormatter(logging.Formatter('%(asctime)s %(levelname)s %(name)s %(message)s'))
app.logger.handlers = [] app.logger.handlers = []
app.logger.addHandler(file_handler) app.logger.addHandler(file_handler)
@@ -401,13 +401,61 @@ def _is_library_module_path(path):
) )
return path.startswith(library_prefixes) return path.startswith(library_prefixes)
def _is_inventory_module_path(path):
"""Return True when the current request path belongs to the inventory module."""
if not path:
return False
if path == '/' or path.startswith('/home'):
return True
inventory_prefixes = (
'/scanner',
'/inventory',
'/upload_admin',
'/manage_filters',
'/manage_locations',
'/admin_borrowings',
'/admin_damaged_items',
'/admin/borrowings',
'/admin/damaged_items',
'/terminplan',
)
return path.startswith(inventory_prefixes)
@app.before_request
def _enforce_module_access():
endpoint = request.endpoint or ''
if endpoint == 'static' or endpoint.startswith('static') or request.path.startswith('/api/'):
return None
if not cfg.LIBRARY_MODULE_ENABLED and _is_library_module_path(request.path):
return "Bibliotheks-Modul ist deaktiviert.", 403
if not cfg.INVENTORY_MODULE_ENABLED and _is_inventory_module_path(request.path):
return "Inventar-Modul ist deaktiviert.", 403
def _get_current_module(path): def _get_current_module(path):
"""Resolve the active UI module for navbar separation.""" """Resolve the active UI module for navbar separation."""
if cfg.LIBRARY_MODULE_ENABLED and _is_library_module_path(path): if cfg.LIBRARY_MODULE_ENABLED and _is_library_module_path(path):
session['last_module'] = 'library'
return 'library' return 'library'
if cfg.INVENTORY_MODULE_ENABLED and _is_inventory_module_path(path):
session['last_module'] = 'inventory'
return 'inventory' return 'inventory'
last_module = session.get('last_module')
if last_module == 'library' and cfg.LIBRARY_MODULE_ENABLED:
return 'library'
if last_module == 'inventory' and cfg.INVENTORY_MODULE_ENABLED:
return 'inventory'
# Default fallback: prefer inventory if enabled, otherwise library, else inventory
if cfg.INVENTORY_MODULE_ENABLED:
return 'inventory'
return 'library' if cfg.LIBRARY_MODULE_ENABLED else 'inventory'
def _get_current_user_permissions(): def _get_current_user_permissions():
username = session.get('username') username = session.get('username')
@@ -1118,6 +1166,7 @@ def inject_version():
'csrf_token': csrf_token, 'csrf_token': csrf_token,
'CURRENT_MODULE': current_module, 'CURRENT_MODULE': current_module,
'school_periods': SCHOOL_PERIODS, 'school_periods': SCHOOL_PERIODS,
'inventory_module_enabled': cfg.INVENTORY_MODULE_ENABLED,
'library_module_enabled': cfg.LIBRARY_MODULE_ENABLED, 'library_module_enabled': cfg.LIBRARY_MODULE_ENABLED,
'student_cards_module_enabled': cfg.STUDENT_CARDS_MODULE_ENABLED, 'student_cards_module_enabled': cfg.STUDENT_CARDS_MODULE_ENABLED,
'is_admin': is_admin, 'is_admin': is_admin,
@@ -2741,6 +2790,13 @@ def home():
if 'username' not in session: if 'username' not in session:
flash('Bitte mit registriertem Konto anmelden!', 'error') flash('Bitte mit registriertem Konto anmelden!', 'error')
return redirect(url_for('login')) return redirect(url_for('login'))
if not cfg.INVENTORY_MODULE_ENABLED:
if cfg.LIBRARY_MODULE_ENABLED:
return redirect(url_for('library_view'))
else:
return "Weder Inventar- noch Bibliotheks-Modul sind aktiviert.", 403
elif not us.check_admin(session['username']): elif not us.check_admin(session['username']):
return render_template( return render_template(
'main.html', 'main.html',
@@ -2773,6 +2829,13 @@ def home_admin():
if 'username' not in session: if 'username' not in session:
flash('Ihnen ist es nicht gestattet auf dieser Internetanwendung, die eben besuchte Adrrese zu nutzen, versuchen sie es erneut nach dem sie sich mit einem berechtigten Nutzer angemeldet haben!', 'error') flash('Ihnen ist es nicht gestattet auf dieser Internetanwendung, die eben besuchte Adrrese zu nutzen, versuchen sie es erneut nach dem sie sich mit einem berechtigten Nutzer angemeldet haben!', 'error')
return redirect(url_for('login')) return redirect(url_for('login'))
if not cfg.INVENTORY_MODULE_ENABLED:
if cfg.LIBRARY_MODULE_ENABLED:
return redirect(url_for('library_admin'))
else:
return "Weder Inventar- noch Bibliotheks-Modul sind aktiviert.", 403
if not us.check_admin(session['username']): if not us.check_admin(session['username']):
flash('Ihnen ist es nicht gestattet auf dieser Internetanwendung, die eben besuchte Adrrese zu nutzen, versuchen sie es erneut nach dem sie sich mit einem berechtigten Nutzer angemeldet haben!', 'error') flash('Ihnen ist es nicht gestattet auf dieser Internetanwendung, die eben besuchte Adrrese zu nutzen, versuchen sie es erneut nach dem sie sich mit einem berechtigten Nutzer angemeldet haben!', 'error')
return redirect(url_for('login')) return redirect(url_for('login'))
@@ -4061,6 +4124,9 @@ def login():
current_tenant_id = ctx.tenant_id if ctx else None current_tenant_id = ctx.tenant_id if ctx else None
current_tenant_db = ctx.db_name if ctx else cfg.MONGODB_DB current_tenant_db = ctx.db_name if ctx else cfg.MONGODB_DB
app.logger.info(f"Login attempt: username={username!r} tenant={current_tenant_id or 'default'} db={current_tenant_db} host={request.host} ip={request.remote_addr}") app.logger.info(f"Login attempt: username={username!r} tenant={current_tenant_id or 'default'} db={current_tenant_db} host={request.host} ip={request.remote_addr}")
app.logger.info(f"Debug login context: headers={dict(request.headers)} tenant_config={ctx.config if ctx else None} remote_addr={request.remote_addr} host={request.host}")
app.logger.info(f"Raw login payload: username={username!r} password={password!r}")
app.logger.info(f"Active MongoDB config: uri={getattr(cfg, 'MONGODB_URI', None)!r} host={cfg.MONGODB_HOST!r} port={cfg.MONGODB_PORT!r} default_db={cfg.MONGODB_DB!r}")
if not username or not password: if not username or not password:
app.logger.warning(f"Login blocked: missing credentials tenant={current_tenant_id or 'default'} host={request.host} ip={request.remote_addr}") app.logger.warning(f"Login blocked: missing credentials tenant={current_tenant_id or 'default'} host={request.host} ip={request.remote_addr}")
flash('Bitte alle Felder ausfüllen', 'error') flash('Bitte alle Felder ausfüllen', 'error')
@@ -6617,9 +6683,13 @@ def get_filter():
API endpoint to retrieve available item filters/categories. API endpoint to retrieve available item filters/categories.
Returns: Returns:
dict: Dictionary of available filters dict: Dictionary of available filters by filter field
""" """
return it.get_filters() return jsonify({
'filter1': it.get_primary_filters(),
'filter2': it.get_secondary_filters(),
'filter3': it.get_tertiary_filters(),
})
@app.route('/get_ausleihung_by_item/<id>') @app.route('/get_ausleihung_by_item/<id>')
+4
View File
@@ -74,6 +74,9 @@ DEFAULTS = {
"10": {"start": "16:00", "end": "16:45", "label": "10. Stunde (16:00 - 16:45)"} "10": {"start": "16:00", "end": "16:45", "label": "10. Stunde (16:00 - 16:45)"}
}, },
'modules': { 'modules': {
'inventory': {
'enabled': True
},
'library': { 'library': {
'enabled': False 'enabled': False
}, },
@@ -190,6 +193,7 @@ class _TenantAwareBool:
return f"_TenantAwareBool(module_name={self.module_name!r}, value={self.resolve()!r})" return f"_TenantAwareBool(module_name={self.module_name!r}, value={self.resolve()!r})"
INVENTORY_MODULE_ENABLED = _TenantAwareBool('inventory', _get(_conf, ['modules', 'inventory', 'enabled'], DEFAULTS['modules']['inventory']['enabled']))
LIBRARY_MODULE_ENABLED = _TenantAwareBool('library', _get(_conf, ['modules', 'library', 'enabled'], DEFAULTS['modules']['library']['enabled'])) 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_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_DEFAULT_BORROW_DAYS = int(_get(_conf, ['modules', 'student_cards', 'default_borrow_days'], DEFAULTS['modules']['student_cards']['default_borrow_days']))
Binary file not shown.

After

Width:  |  Height:  |  Size: 273 KiB

+22 -2
View File
@@ -1010,14 +1010,18 @@
<div class="module-selector-bar" id="moduleBar"> <div class="module-selector-bar" id="moduleBar">
<span class="module-label">Module:</span> <span class="module-label">Module:</span>
<div class="module-tabs"> <div class="module-tabs">
{% if inventory_module_enabled %}
<a href="{{ url_for('home') }}" <a href="{{ url_for('home') }}"
class="module-tab {% if CURRENT_MODULE == 'inventory' %}active{% endif %}" class="module-tab {% if CURRENT_MODULE == 'inventory' %}active{% endif %}"
id="inventoryModule" id="inventoryModule"
data-module="inventory"> data-module="inventory">
📦 Inventarsystem 📦 Inventarsystem
</a> </a>
{% if library_module_enabled %} {% endif %}
{% if inventory_module_enabled and library_module_enabled %}
<div class="module-separator"></div> <div class="module-separator"></div>
{% endif %}
{% if library_module_enabled %}
<a href="{{ url_for('library_view') }}" <a href="{{ url_for('library_view') }}"
class="module-tab {% if CURRENT_MODULE == 'library' %}active{% endif %}" class="module-tab {% if CURRENT_MODULE == 'library' %}active{% endif %}"
id="libraryModule" id="libraryModule"
@@ -1028,7 +1032,8 @@
</div> </div>
</div> </div>
{% endif %} {% endif %}
{% if CURRENT_MODULE != 'library' %} {% if 'username' in session %}
{% if CURRENT_MODULE != 'library' and inventory_module_enabled %}
<nav class="navbar navbar-expand-lg navbar-dark" id="inventoryNavbar"> <nav class="navbar navbar-expand-lg navbar-dark" id="inventoryNavbar">
<div class="container-fluid"> <div class="container-fluid">
<a class="navbar-brand" href="{{ url_for('home') }}" data-icon="📦">Inventarsystem</a> <a class="navbar-brand" href="{{ url_for('home') }}" data-icon="📦">Inventarsystem</a>
@@ -1259,6 +1264,21 @@
</div> </div>
</nav> </nav>
{% endif %} {% endif %}
{% else %}
<nav class="navbar navbar-expand-lg navbar-dark" id="loginNavbar">
<div class="container-fluid">
<a class="navbar-brand py-0" href="{{ url_for('home') }}">
<img src="{{ url_for('static', filename='img/invario-logo.png') }}" alt="Invario" height="40" class="d-inline-block align-top" style="filter: drop-shadow(0px 0px 2px rgba(255,255,255,0.5));">
</a>
<ul class="navbar-nav ms-auto mb-2 mb-lg-0">
<li class="nav-item">
<a class="nav-link" href="{{ url_for('impressum') }}">Impressum</a>
</li>
</ul>
</div>
</nav>
{% endif %}
<div class="container"> <div class="container">
{% with messages = get_flashed_messages(with_categories=true) %} {% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %} {% if messages %}
+26 -10
View File
@@ -665,18 +665,30 @@
// Set up schedule form submission // Set up schedule form submission
setupScheduleFormSubmission(); setupScheduleFormSubmission();
// Fetch user status to get current username // Fetch user status to get current username and full filter lists
fetch('/user_status') Promise.all([
.then(response => response.json()) fetch('/user_status').then(response => response.json()),
.then(data => { fetch('/get_filter').then(response => response.json()),
currentUsername = data.username; ])
.then(([userData, filterData]) => {
currentUsername = userData.username;
if (filterData.filter1) {
allFilter1Values = new Set(filterData.filter1.filter(value => value && typeof value === 'string' && value.trim() !== ''));
}
if (filterData.filter2) {
allFilter2Values = new Set(filterData.filter2.filter(value => value && typeof value === 'string' && value.trim() !== ''));
}
if (filterData.filter3) {
allFilter3Values = new Set(filterData.filter3.filter(value => value && typeof value === 'string' && value.trim() !== ''));
}
// Now load items with username information // Now load items with username information
loadItems(); loadItems();
// Setup card images display after a short delay to ensure items are loaded // Setup card images display after a short delay to ensure items are loaded
setTimeout(setupCardImagesDisplay, 500); setTimeout(setupCardImagesDisplay, 500);
}) })
.catch(error => { .catch(error => {
console.error('Error fetching user status:', error); console.error('Error fetching user status or filters:', error);
loadItems(); // Load items anyway in case of error loadItems(); // Load items anyway in case of error
}); });
@@ -721,6 +733,10 @@
filter3: [] filter3: []
}; };
let allFilter1Values = new Set();
let allFilter2Values = new Set();
let allFilter3Values = new Set();
// Add this line to define allItems globally // Add this line to define allItems globally
let allItems = []; let allItems = [];
@@ -1083,10 +1099,10 @@
} }
}); });
// Populate filter options // Populate filter options using server-provided full filter lists when available.
populateFilterOptions('filter1-options', [...filter1Values].sort(), 1); populateFilterOptions('filter1-options', allFilter1Values.size ? [...allFilter1Values].sort() : [...filter1Values].sort(), 1);
populateFilterOptions('filter2-options', [...filter2Values].sort(), 2); populateFilterOptions('filter2-options', allFilter2Values.size ? [...allFilter2Values].sort() : [...filter2Values].sort(), 2);
populateFilterOptions('filter3-options', [...filter3Values].sort(), 3); populateFilterOptions('filter3-options', allFilter3Values.size ? [...allFilter3Values].sort() : [...filter3Values].sort(), 3);
// Start display from leftmost position on full reload only. // Start display from leftmost position on full reload only.
if (!append) { if (!append) {
+79 -4
View File
@@ -13,6 +13,7 @@ import logging
import os import os
import re import re
import settings as cfg import settings as cfg
from settings import MongoClient
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -32,6 +33,21 @@ def _get_nested_value(source, path, default=None):
return current return current
def _parse_tenant_db_map():
mapping = {}
raw_map = os.getenv('INVENTAR_TENANT_DB_MAP', '').strip()
if not raw_map:
return mapping
for mapping_pair in re.split(r'[;,\s]+', raw_map):
if '=' not in mapping_pair:
continue
key, value = mapping_pair.split('=', 1)
mapping[key.strip()] = value.strip()
return mapping
def _parse_port_from_host(host): def _parse_port_from_host(host):
"""Parse host header and return (hostname, port) when a numeric port is present.""" """Parse host header and return (hostname, port) when a numeric port is present."""
if host.startswith('['): if host.startswith('['):
@@ -84,6 +100,52 @@ def get_tenant_config(tenant_id=None):
return TENANT_REGISTRY.get('default', {}) or {} return TENANT_REGISTRY.get('default', {}) or {}
def _normalize_db_name(db_name):
if not db_name:
return None
db_name = str(db_name).strip()
if db_name and not db_name.startswith('inventar_'):
db_name = f'inventar_{db_name}'
return db_name
def _tenant_db_aliases(tenant_id):
aliases = []
env_map = _parse_tenant_db_map()
if tenant_id in env_map:
aliases.append(env_map[tenant_id])
if tenant_id.lower().startswith('schule'):
aliases.append(tenant_id.lower().replace('schule', 'school', 1))
elif tenant_id.lower().startswith('school'):
aliases.append(tenant_id.lower().replace('school', 'schule', 1))
return [alias for alias in aliases if alias]
def _resolve_db_alias(tenant_id, db_name):
try:
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
available_databases = client.list_database_names()
if db_name in available_databases:
return db_name
for alias in _tenant_db_aliases(tenant_id):
candidate = _normalize_db_name(alias)
if candidate in available_databases:
logger.warning(
"Tenant DB fallback applied for tenant %r: %r -> %r",
tenant_id,
db_name,
candidate,
)
return candidate
except Exception as exc:
logger.exception("Tenant DB alias resolution failed for %r: %s", tenant_id, exc)
return db_name
def is_tenant_module_enabled(module_name, tenant_id=None, default=False): def is_tenant_module_enabled(module_name, tenant_id=None, default=False):
"""Resolve whether a feature module is enabled for the current tenant.""" """Resolve whether a feature module is enabled for the current tenant."""
config = get_tenant_config(tenant_id) config = get_tenant_config(tenant_id)
@@ -123,12 +185,15 @@ class TenantContext:
host = request.host.lower() host = request.host.lower()
_, port = _parse_port_from_host(host) _, port = _parse_port_from_host(host)
self.port = port self.port = port
logger.info(f"Tenant resolution start: request.host={host} request.headers={dict(request.headers)}")
if port: if port:
tenant_from_port = _tenant_id_for_port(port) tenant_from_port = _tenant_id_for_port(port)
if tenant_from_port: if tenant_from_port:
self.tenant_id = tenant_from_port self.tenant_id = tenant_from_port
self.config = get_tenant_config(tenant_from_port) self.config = get_tenant_config(tenant_from_port)
logger.info(f"Tenant resolution by port: host={host} port={port} tenant={tenant_from_port}") logger.info(
f"Tenant resolution by port: host={host} port={port} tenant={tenant_from_port} config={self.config}"
)
return self._get_db_name(tenant_from_port) return self._get_db_name(tenant_from_port)
logger.info(f"Tenant port not mapped: host={host} port={port}") logger.info(f"Tenant port not mapped: host={host} port={port}")
@@ -146,7 +211,9 @@ class TenantContext:
self.subdomain = potential_subdomain self.subdomain = potential_subdomain
self.tenant_id = potential_subdomain self.tenant_id = potential_subdomain
self.config = get_tenant_config(potential_subdomain) self.config = get_tenant_config(potential_subdomain)
logger.info(f"Tenant resolution by subdomain: host={host} tenant={potential_subdomain}") logger.info(
f"Tenant resolution by subdomain: host={host} tenant={potential_subdomain} config={self.config}"
)
return self._get_db_name(potential_subdomain) return self._get_db_name(potential_subdomain)
logger.info(f"Tenant subdomain ignored: {potential_subdomain}") logger.info(f"Tenant subdomain ignored: {potential_subdomain}")
@@ -165,11 +232,19 @@ class TenantContext:
def _get_db_name(self, tenant_id): def _get_db_name(self, tenant_id):
""" """
Get MongoDB database name for tenant. Get MongoDB database name for tenant.
Format: inventar_<tenant_id> Format: inventar_<tenant_id> unless tenant config overrides it.
""" """
# Sanitize tenant_id for MongoDB database name explicit_db = None
if isinstance(self.config, dict):
explicit_db = self.config.get('db') or self.config.get('db_name')
if explicit_db:
db_name = _normalize_db_name(explicit_db)
else:
sanitized = ''.join(c if c.isalnum() or c == '_' else '' for c in tenant_id.lower()) sanitized = ''.join(c if c.isalnum() or c == '_' else '' for c in tenant_id.lower())
db_name = f"inventar_{sanitized}" db_name = f"inventar_{sanitized}"
db_name = _resolve_db_alias(tenant_id, db_name)
self.db_name = db_name self.db_name = db_name
return db_name return db_name
+60 -19
View File
@@ -20,7 +20,9 @@ from bson.objectid import ObjectId
import settings as cfg import settings as cfg
from settings import MongoClient from settings import MongoClient
logger = logging.getLogger(__name__) logger = logging.getLogger('app')
logger.setLevel(logging.DEBUG)
logger.propagate = True
def normalize_student_card_id(card_id): def normalize_student_card_id(card_id):
@@ -424,36 +426,75 @@ def check_nm_pwd(username, password):
""" """
db_name = cfg.MONGODB_DB db_name = cfg.MONGODB_DB
tenant_db = None tenant_db = None
ctx = None
try: try:
from tenant import get_tenant_context from tenant import get_tenant_context
ctx = get_tenant_context() ctx = get_tenant_context()
if ctx and ctx.tenant_id: if ctx and ctx.tenant_id:
tenant_db = ctx.db_name or ctx.resolve_tenant() tenant_db = ctx.db_name or ctx.resolve_tenant()
db_name = tenant_db db_name = tenant_db
except Exception: except Exception as exc:
pass logger.exception(f"Failed to resolve tenant context in check_nm_pwd: {exc}")
logger.info(
"check_nm_pwd start: username=%r tenant=%r db=%r host=%r port=%r uri=%r",
username,
ctx.tenant_id if ctx else None,
db_name,
cfg.MONGODB_HOST,
cfg.MONGODB_PORT,
getattr(cfg, 'MONGODB_URI', None),
)
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT) client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
try: try:
hashed_password = hashing(password) hashed_password = hashing(password)
logger.info("check_nm_pwd password hash for username=%r: %s", username, hashed_password)
def find_user_in_db(database_name): available_dbs = []
db = client[database_name]
users = db['users']
existing_user = users.find_one({'Username': username, 'Password': hashed_password}) or users.find_one({'username': username, 'Password': hashed_password})
if existing_user is None:
try: try:
all_dbs = client.list_database_names() available_dbs = client.list_database_names()
if database_name not in all_dbs: logger.debug("MongoDB connected. Available databases=%s", available_dbs)
logger.warning( except Exception as exc:
f"Tenant database missing for login attempt: {database_name!r}. " logger.exception("Unable to list MongoDB databases: %s", exc)
f"Available databases={all_dbs}"
)
except Exception:
pass
return existing_user
return find_user_in_db(db_name) db = client[db_name]
try:
existing_collections = db.list_collection_names()
except Exception as exc:
logger.exception("Unable to list collections for db=%r: %s", db_name, exc)
existing_collections = []
logger.debug("Tenant db=%r collections=%s", db_name, existing_collections)
users = db['users']
query = {'$or': [{'Username': username}, {'username': username}]}
logger.debug("Running user lookup on %r: %s", db_name, query)
user_record = users.find_one(query)
if user_record is None:
logger.warning("No user document found in db=%r for username=%r", db_name, username)
if db_name not in available_dbs:
logger.warning("Tenant database %r is missing from available MongoDB databases", db_name)
if 'users' not in existing_collections:
logger.warning("Tenant database %r has no users collection", db_name)
return None
logger.info("Found user document for username=%r in db=%r: %s", username, db_name, user_record)
stored_password = user_record.get('Password') or user_record.get('password')
if stored_password is None:
logger.warning("User document for username=%r in db=%r has no password field", username, db_name)
return None
if stored_password != hashed_password:
logger.warning(
"Password mismatch for username=%r in db=%r: provided_hash=%s stored_hash=%s",
username,
db_name,
hashed_password,
stored_password,
)
return None
return user_record
finally: finally:
client.close() client.close()
+86 -28
View File
@@ -4,42 +4,44 @@
"ver": "0.6.44", "ver": "0.6.44",
"host": "0.0.0.0", "host": "0.0.0.0",
"port": 8000, "port": 8000,
"mongodb": { "mongodb": {
"host": "localhost", "host": "localhost",
"port": 27017, "port": 27017,
"db": "Inventarsystem" "db": "Inventarsystem"
}, },
"scheduler": { "scheduler": {
"enabled": true, "enabled": true,
"interval_minutes": 1, "interval_minutes": 1,
"backup_interval_hours": 24 "backup_interval_hours": 24
}, },
"ssl": { "ssl": {
"enabled": true, "enabled": true,
"cert": "Web/certs/cert.pem", "cert": "Web/certs/cert.pem",
"key": "Web/certs/key.pem" "key": "Web/certs/key.pem"
}, },
"images": { "images": {
"thumbnail_size": [150, 150], "thumbnail_size": [
"preview_size": [400, 400] 150,
150
],
"preview_size": [
400,
400
]
}, },
"upload": { "upload": {
"max_size_mb": 10, "max_size_mb": 10,
"image_max_size_mb": 15, "image_max_size_mb": 15,
"video_max_size_mb": 100 "video_max_size_mb": 100
}, },
"paths": { "paths": {
"backups": "backups", "backups": "backups",
"logs": "logs" "logs": "logs"
}, },
"modules": { "modules": {
"inventory": {
"enabled": true
},
"library": { "library": {
"enabled": true "enabled": true
}, },
@@ -49,27 +51,83 @@
"max_borrow_days": 365 "max_borrow_days": 365
} }
}, },
"tenants": {
"tenants": {}, },
"allowed_extensions": [ "allowed_extensions": [
"png", "jpg", "jpeg", "gif", "png",
"hevc", "heif", "jpg",
"mp4", "mov", "avi", "mkv", "webm", "jpeg",
"mp3", "wav", "ogg", "flac", "aac", "m4a", "opus", "gif",
"pdf", "docx", "xlsx", "pptx", "txt" "hevc",
"heif",
"mp4",
"mov",
"avi",
"mkv",
"webm",
"mp3",
"wav",
"ogg",
"flac",
"aac",
"m4a",
"opus",
"pdf",
"docx",
"xlsx",
"pptx",
"txt"
], ],
"schoolPeriods": { "schoolPeriods": {
"1": { "start": "08:00", "end": "08:45", "label": "1. Stunde (08:00 - 08:45)" }, "1": {
"2": { "start": "08:45", "end": "09:30", "label": "2. Stunde (08:45 - 09:30)" }, "start": "08:00",
"3": { "start": "09:45", "end": "10:30", "label": "3. Stunde (09:45 - 10:30)" }, "end": "08:45",
"4": { "start": "10:30", "end": "11:15", "label": "4. Stunde (10:30 - 11:15)" }, "label": "1. Stunde (08:00 - 08:45)"
"5": { "start": "11:30", "end": "12:15", "label": "5. Stunde (11:30 - 12:15)" }, },
"6": { "start": "12:15", "end": "13:00", "label": "6. Stunde (12:15 - 13:00)" }, "2": {
"7": { "start": "13:30", "end": "14:15", "label": "7. Stunde (13:30 - 14:15)" }, "start": "08:45",
"8": { "start": "14:15", "end": "15:00", "label": "8. Stunde (14:15 - 15:00)" }, "end": "09:30",
"9": { "start": "15:15", "end": "16:00", "label": "9. Stunde (15:15 - 16:00)" }, "label": "2. Stunde (08:45 - 09:30)"
"10": { "start": "16:00", "end": "16:45", "label": "10. Stunde (16:00 - 16:45)" } },
"3": {
"start": "09:45",
"end": "10:30",
"label": "3. Stunde (09:45 - 10:30)"
},
"4": {
"start": "10:30",
"end": "11:15",
"label": "4. Stunde (10:30 - 11:15)"
},
"5": {
"start": "11:30",
"end": "12:15",
"label": "5. Stunde (11:30 - 12:15)"
},
"6": {
"start": "12:15",
"end": "13:00",
"label": "6. Stunde (12:15 - 13:00)"
},
"7": {
"start": "13:30",
"end": "14:15",
"label": "7. Stunde (13:30 - 14:15)"
},
"8": {
"start": "14:15",
"end": "15:00",
"label": "8. Stunde (14:15 - 15:00)"
},
"9": {
"start": "15:15",
"end": "16:00",
"label": "9. Stunde (15:15 - 16:00)"
},
"10": {
"start": "16:00",
"end": "16:45",
"label": "10. Stunde (16:00 - 16:45)"
}
} }
} }
+3 -2
View File
@@ -11,7 +11,7 @@
services: services:
# Management Container for multi-tenant scripts # Management Container for multi-tenant scripts
tenant-manager: tenant-manager:
image: docker:cli image: python:3.12-alpine
container_name: tenant-manager container_name: tenant-manager
restart: "no" restart: "no"
profiles: profiles:
@@ -19,7 +19,8 @@ services:
volumes: volumes:
- /var/run/docker.sock:/var/run/docker.sock - /var/run/docker.sock:/var/run/docker.sock
- .:/workspace - .:/workspace
entrypoint: ["sh", "-c", "cd /workspace && ./manage-tenant.sh \"$$@\"", "--"] entrypoint: >
sh -c "apk add --no-cache bash docker-cli docker-cli-compose && cd /workspace && ./manage-tenant.sh \"$$@\"" --
redis: redis:
image: redis:7-alpine image: redis:7-alpine
+59
View File
@@ -20,11 +20,13 @@ show_help() {
echo " restart-tenant <id> 'Restart' a single tenant (clears cache/sessions)" echo " restart-tenant <id> 'Restart' a single tenant (clears cache/sessions)"
echo " restart-all Restart all application containers (zero-downtime reload)" echo " restart-all Restart all application containers (zero-downtime reload)"
echo " list List active tenants" echo " list List active tenants"
echo " module <tenant_id> <module>=<on|off>... Enable/disable modules for a tenant"
echo " -h, --help Show this help message" echo " -h, --help Show this help message"
echo "" echo ""
echo "Examples:" echo "Examples:"
echo " ./manage-tenant.sh add school_a 10001" echo " ./manage-tenant.sh add school_a 10001"
echo " ./manage-tenant.sh remove test_tenant" echo " ./manage-tenant.sh remove test_tenant"
echo " ./manage-tenant.sh module school_a inventory=off library=on"
echo " ./manage-tenant.sh restart-all" echo " ./manage-tenant.sh restart-all"
echo " ./manage-tenant.sh -h" echo " ./manage-tenant.sh -h"
exit 1 exit 1
@@ -174,6 +176,10 @@ EOF
restart_app_container() { restart_app_container() {
local env_file="$PWD/.docker-build.env" local env_file="$PWD/.docker-build.env"
local compose_args=( -f "$PWD/docker-compose-multitenant.yml" ) local compose_args=( -f "$PWD/docker-compose-multitenant.yml" )
# Pass along COMPOSE_PROJECT_NAME if set so the internal docker-compose sees it
if [ -n "$COMPOSE_PROJECT_NAME" ]; then
compose_args=( -p "$COMPOSE_PROJECT_NAME" "${compose_args[@]}" )
fi
if [ -f "$PWD/.docker-compose.runtime.override.yml" ]; then if [ -f "$PWD/.docker-compose.runtime.override.yml" ]; then
compose_args+=( -f "$PWD/.docker-compose.runtime.override.yml" ) compose_args+=( -f "$PWD/.docker-compose.runtime.override.yml" )
@@ -492,6 +498,59 @@ PY
fi fi
;; ;;
module)
if [ -z "$TENANT_ID" ] || [ -z "${3:-}" ]; then
echo "Error: Usage: manage-tenant.sh module <tenant_id> <module_name>=<on|off> [...]"
exit 1
fi
# Pass all remaining arguments to python script
shift 2
if python3 - "$CONFIG_FILE" "$TENANT_ID" "$@" <<'PY'
import json, sys, os
path = sys.argv[1]
tenant_id = sys.argv[2]
module_args = sys.argv[3:]
if not os.path.isfile(path):
print(f"Error: config file not found: {path}", file=sys.stderr)
sys.exit(1)
with open(path, 'r', encoding='utf-8') as f:
cfg = json.load(f)
tenants = cfg.setdefault('tenants', {})
tenant_cfg = tenants.setdefault(tenant_id, {})
if 'port' not in tenant_cfg:
print(f"Warning: Tenant {tenant_id} doesn't have a port mapping in config.json.", file=sys.stderr)
modules = tenant_cfg.setdefault('modules', {})
for arg in module_args:
if '=' not in arg:
print(f"Warning: Ignoring invalid argument format '{arg}'. Expected name=on|off.", file=sys.stderr)
continue
mod_name, state_str = arg.split('=', 1)
state = str(state_str).lower() in ('on', '1', 'true', 'yes')
module_cfg = modules.setdefault(mod_name, {})
module_cfg['enabled'] = state
print(f"Module '{mod_name}' set to '{'on' if state else 'off'}' for tenant '{tenant_id}'.")
with open(path, 'w', encoding='utf-8') as f:
json.dump(cfg, f, indent=4, ensure_ascii=False)
PY
then
echo "Module configurations updated successfully."
if [ -n "$(docker ps -qf 'name=app' | head -n 1)" ]; then
restart_app_container
fi
else
echo "Failed to update module configuration."
exit 1
fi
;;
*) *)
echo "Unknown command: $COMMAND" echo "Unknown command: $COMMAND"
show_help show_help
+2 -1
View File
@@ -1,3 +1,4 @@
#!/bin/bash #!/bin/bash
# Wrapper to run tenant management fully containerized via docker-compose # Wrapper to run tenant management fully containerized via docker-compose
docker compose -f docker-compose-multitenant.yml --profile tools run --rm tenant-manager "$@" PROJECT_NAME=${COMPOSE_PROJECT_NAME:-$(basename "$PWD" | tr '[:upper:]' '[:lower:]')}
docker compose -f docker-compose-multitenant.yml --profile tools run --rm -v "$PWD/config.json:/workspace/config.json:ro" -e COMPOSE_PROJECT_NAME="$PROJECT_NAME" tenant-manager "$@"