diff --git a/Dockerfile b/Dockerfile index 3a2b7bc..c09e7e9 100644 --- a/Dockerfile +++ b/Dockerfile @@ -9,7 +9,8 @@ WORKDIR /opt/meshcentral RUN apt-get update \ && apt-get install -y --no-install-recommends nodejs npm ca-certificates postgresql-client \ && rm -rf /var/lib/apt/lists/* \ - && npm install --omit=dev meshcentral + && npm install --omit=dev meshcentral \ + && chown -R root:root /opt/meshcentral WORKDIR /app COPY requirements.txt . @@ -21,10 +22,14 @@ RUN pip install --no-cache-dir -r requirements.txt \ && rm /tmp/export_dependency_licenses.py COPY app ./app -COPY VERSION APPINFO.json LICENSE.txt THIRD_PARTY_NOTICES.md ./ -RUN mkdir -p /app/app/static/uploads /app/config /app/data/logs/sync /app/data/logs/diagnostics /data/backups +COPY VERSION LICENSE.txt THIRD_PARTY_NOTICES.md ./ +COPY APPINFO.json /app/default-config/APPINFO.json +COPY docker-entrypoint.sh /usr/local/bin/assetmanager-entrypoint +RUN chmod +x /usr/local/bin/assetmanager-entrypoint \ + && mkdir -p /app/app/static/uploads /app/config /app/data/logs/sync /app/data/logs/diagnostics /data/backups HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=3 \ CMD ["python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/api/software-callback/health', timeout=3).read()"] +ENTRYPOINT ["/usr/local/bin/assetmanager-entrypoint"] CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/PRUEFEN-0.5.5.5.sh b/PRUEFEN-0.5.5.5.sh deleted file mode 100755 index fe0212d..0000000 --- a/PRUEFEN-0.5.5.5.sh +++ /dev/null @@ -1,12 +0,0 @@ -#!/bin/sh -set -eu -ROOT="${1:-/srv/docker/assetmanager}" -cd "$ROOT" -echo "VERSION: $(cat VERSION)" -grep -n 'APP_VERSION = "0.5.5.5"' app/version.py -grep -n 'powershell -ExecutionPolicy Bypass -File' app/software_control.py -if sed -n '/def _launch_uploaded_script/,/return _run_meshctrl/p' app/software_control.py | grep -q "action=.*--powershell"; then - echo "FEHLER: _launch_uploaded_script enthält --powershell" >&2 - exit 1 -fi -echo "Dateien auf dem Host sind korrekt." diff --git a/README.md b/README.md index 0578ea0..7e58159 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# AssetManager 0.5.5.42 +# AssetManager 0.5.5.52 AssetManager is a self-hosted web application for managing IT equipment and other organizational assets. It provides asset inventory, software inventory, remote job execution, reporting, privacy/retention documentation, and optional integration with MeshCentral. @@ -27,6 +27,9 @@ The project is licensed under the **Apache License 2.0** and may be used, modifi ## Installation +For a detailed first-start, image deployment, authentication, callback, update, and troubleshooting guide, see [`docs/INSTALLATION.md`](docs/INSTALLATION.md). + + Create the local environment file: ```bash @@ -50,6 +53,20 @@ docker compose up -d --build By default, AssetManager is exposed on port `8088`. +### First start and authentication + +On a fresh image-based installation, `config.json` and `APPINFO.json` are created automatically in `data/config/` if they do not exist. Existing files are never overwritten by the container entrypoint. + +The default configuration starts with authentication disabled so the initial configuration can be completed. A prominent warning banner is displayed in the web interface while this mode is active. Before normal use: + +1. Edit `data/config/config.json`. +2. Set `authentication.mode` to `local`. +3. Restart the application container with `docker compose restart app`. +4. Sign in with the protected local administrator configured through `LOCAL_ADMIN_USERNAME` and `LOCAL_ADMIN_PASSWORD` in `.env`. +5. Configure LDAP/Active Directory afterwards if required. The protected local administrator remains available as an emergency account. + +Do not expose a fresh installation to untrusted networks while authentication is disabled. + ## Persistent data Runtime data is stored in bind mounts below `data/`: diff --git a/VERSION b/VERSION index 0df7470..23903a9 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.5.5.42 +0.5.5.52 diff --git a/app/config.py b/app/config.py index f07019c..71810d0 100644 --- a/app/config.py +++ b/app/config.py @@ -59,8 +59,10 @@ DEFAULT_CONFIG: dict[str, Any] = { "timeout_seconds": 120, "presence_enabled": True, "presence_interval_seconds": 30, - "match_order": ["node_id", "serial_number"], + "match_order": ["node_id", "mac_address", "serial_number"], "serial_match_manufacturer": False, + "invalid_serial_values": ["unknown", "n/a", "none", "not specified", "to be filled by o.e.m."], + "manufacturer_invalid_serial_rules": [{"manufacturer": "Synology", "values": ["123456789", "Unknown"]}], "create_missing_assets": True, "mark_missing_devices": True, "field_rules": {}, diff --git a/app/i18n.py b/app/i18n.py index b8c5334..63397e0 100644 --- a/app/i18n.py +++ b/app/i18n.py @@ -7,6 +7,8 @@ BASE_TRANSLATIONS = { "app.charts": ("Charts", "Diagramme"), "app.categories": ("Categories", "Kategorien"), "app.status_values": ("Status values", "Statuswerte"), "app.settings": ("Settings", "Einstellungen"), "app.users": ("Users", "Benutzer"), "app.profile": ("Profile", "Profil"), "app.login": ("Sign in", "Anmelden"), "app.logout": ("Sign out", "Ausloggen"), "app.guest": ("Guest", "Gast"), +"auth.disabled_banner_title": ("Authentication is disabled", "Authentifizierung ist deaktiviert"), +"auth.disabled_banner_text": ("This installation is currently accessible without signing in. For the first setup, edit data/config/config.json, set authentication.mode to local, restart the app container, and then sign in with the protected local administrator configured in .env.", "Diese Installation ist derzeit ohne Anmeldung erreichbar. Für die Ersteinrichtung bearbeite data/config/config.json, setze authentication.mode auf local, starte den App-Container neu und melde dich anschließend mit dem in .env konfigurierten geschützten lokalen Administrator an."), "common.save": ("Save", "Speichern"), "common.cancel": ("Cancel", "Abbrechen"), "common.delete": ("Delete", "Löschen"), "common.edit": ("Edit", "Bearbeiten"), "common.search": ("Search", "Suchen"), "common.export": ("Export", "Exportieren"), "common.import": ("Import", "Importieren"), "common.back": ("Back", "Zurück"), "common.yes": ("Yes", "Ja"), "common.no": ("No", "Nein"), @@ -74,9 +76,16 @@ BASE_TRANSLATIONS = { "mesh.verify_tls": ("Verify TLS", "TLS prüfen"), "mesh.asset_matching": ("Asset matching", "Asset-Zuordnung"), "mesh.asset_matching_help": ("Choose which identifiers are used to match MeshCentral devices to existing assets. Matching is evaluated in the displayed order.", "Wähle, mit welchen Kennungen MeshCentral-Geräte bestehenden Assets zugeordnet werden. Die Zuordnung wird in der angezeigten Reihenfolge geprüft."), "mesh.match_node_id": ("Node ID", "Node-ID"), +"mesh.match_mac": ("MAC address", "MAC-Adresse"), "mesh.match_serial": ("Serial number", "Seriennummer"), +"mesh.match_mac_help": ("MAC matching is used only for an exact, unique MAC address. Node ID remains the primary device identity.", "Der MAC-Abgleich wird nur bei einer exakten, eindeutigen MAC-Adresse verwendet. Die Node-ID bleibt die primäre Geräteidentität."), "mesh.match_serial_manufacturer": ("For serial-number matches, also compare normalized manufacturer", "Beim Seriennummern-Abgleich zusätzlich den normalisierten Hersteller vergleichen"), "mesh.match_serial_manufacturer_help": ("When enabled, a serial-number match is accepted only if the normalized manufacturer also matches. Case, punctuation, accents and common legal suffixes such as GmbH, Inc. or Corporation are ignored.", "Wenn aktiviert, wird eine Übereinstimmung über die Seriennummer nur akzeptiert, wenn auch der normalisierte Hersteller übereinstimmt. Groß-/Kleinschreibung, Satzzeichen, Akzente und übliche Rechtsformzusätze wie GmbH, Inc. oder Corporation werden ignoriert."), +"mesh.invalid_serial_values": ("Invalid serial-number placeholders", "Ungültige Seriennummern-Platzhalter"), +"mesh.invalid_serial_values_help": ("One value per line. These values are never used for serial-number matching and never overwrite an existing asset serial number.", "Ein Wert pro Zeile. Diese Werte werden niemals für den Seriennummern-Abgleich verwendet und überschreiben keine vorhandene Asset-Seriennummer."), +"mesh.manufacturer_invalid_serial_rules": ("Manufacturer-specific serial placeholders", "Herstellerspezifische Seriennummern-Platzhalter"), +"mesh.manufacturer_invalid_serial_rules_help": ("One rule per line in the format Manufacturer | Serial number. Example: Synology | 123456789. Manufacturer matching is normalized and ignores case, punctuation and common legal suffixes.", "Eine Regel pro Zeile im Format Hersteller | Seriennummer. Beispiel: Synology | 123456789. Der Herstellervergleich wird normalisiert und ignoriert Groß-/Kleinschreibung, Satzzeichen und übliche Rechtsformzusätze."), +"mesh.node_id_identity_help": ("If a reported serial number is recognized as a placeholder, AssetManager ignores it for matching and synchronization. The MeshCentral node ID remains the stable unique device identity. The original reported value is retained in the stored MeshCentral source data when source-data storage is enabled.", "Wird eine gemeldete Seriennummer als Platzhalter erkannt, ignoriert AssetManager sie beim Abgleich und bei der Synchronisierung. Die MeshCentral-Node-ID bleibt die stabile eindeutige Geräteidentität. Der ursprünglich gemeldete Wert bleibt bei aktivierter Quelldatenspeicherung in den gespeicherten MeshCentral-Quelldaten erhalten."), "mesh.dry_run": ("Dry run", "Dry-Run"), "mesh.fallback_category_id": ("Fallback category ID", "Fallback-Kategorie-ID"), "mesh.device_type_mapping": ("Device type mapping", "Gerätetyp-Mapping"), "mesh.type": ("Type", "Typ"), "mesh.recent_runs": ("Recent runs", "Letzte Läufe"), "mesh.start_time": ("Start", "Start"), @@ -87,6 +96,8 @@ BASE_TRANSLATIONS = { "mesh.finished_visible": ("Synchronization finished. The log remains visible.", "Synchronisierung beendet. Das Protokoll bleibt sichtbar."), "settings.mesh_mapping": ("MeshCentral category mapping", "MeshCentral-Kategoriezuordnung"), "settings.mesh_mapping_help": ("Mappings are stored using stable category IDs. Renaming a category does not interrupt synchronization. Existing assets keep their current category; mappings apply only to newly imported devices.", "Die Zuordnung wird über stabile Kategorie-IDs gespeichert. Umbenennen einer Kategorie unterbricht die Synchronisierung daher nicht mehr. Bestehende Assets behalten ihre bisherige Kategorie; die Zuordnung gilt nur für neu importierte Geräte."), +"settings.save_mesh_mapping": ("Save category mapping", "Kategoriezuordnung speichern"), +"settings.mesh_mapping_saved": ("MeshCentral category mapping saved", "MeshCentral-Kategoriezuordnung gespeichert"), "settings.fallback_category": ("Fallback category", "Fallback-Kategorie"), "settings.mobile_device": ("Mobile device", "Mobilgerät"), "settings.device_type": ("Device type", "Gerätetyp"), "settings.other_device": ("other device", "sonstiges Gerät"), "settings.mesh_type": ("MeshCentral type", "MeshCentral-Typ"), "settings.use_fallback": ("Use fallback", "Fallback verwenden"), @@ -146,6 +157,9 @@ BASE_TRANSLATIONS = { "lists.filters_active": ("{count} filters active", "{count} Filter aktiv"), "lists.filter_status": ("Filtered: {visible} of {total} records", "Gefiltert: {visible} von {total} Datensätzen"), "lists.clear_filters": ("Clear filters", "Filter zurücksetzen"), +"lists.row_number": ("Row number", "Zeilennummer"), +"lists.row_number_resize": ("Drag to change the row-number column width", "Ziehen, um die Breite der Nummernspalte zu ändern"), +"lists.column_resize": ("Drag to change the column width", "Ziehen, um die Spaltenbreite zu ändern"), "appearance.colors_title": ("System colors", "Systemfarben"), "appearance.colors_help": ("Configure the central colors used throughout the application.", "Konfigurieren Sie die zentral im gesamten System verwendeten Farben."), "appearance.primary_color": ("Primary color", "Primärfarbe"), @@ -549,6 +563,12 @@ BASE_TRANSLATIONS.update({ "duplicates.ignore_case": ("Ignore letter case", "Groß-/Kleinschreibung ignorieren"), "duplicates.clear_filter": ("Clear duplicate filter", "Dublettenfilter aufheben"), "duplicates.show": ("Show duplicates", "Duplikate anzeigen"), + "duplicates.active_filter": ("Duplicates: {field}", "Duplikate: {field}"), + "duplicates.result_summary": ("{records} records in {groups} duplicate groups found.", "{records} Datensätze in {groups} Dublettengruppen gefunden."), + "assets.active_selection": ("Active selection", "Aktive Auswahl"), + "assets.category_filter": ("Category: {category}", "Kategorie: {category}"), + "assets.clear_category_filter": ("Remove category filter", "Kategoriefilter entfernen"), + "duplicates.remove_filter": ("Remove duplicate filter", "Dublettenfilter entfernen"), "duplicates.order_help": ("The top record remains. Empty fields are then filled in the selected order.", "Der oberste Datensatz bleibt bestehen. Danach werden in der Reihenfolge nur noch leere Felder ergänzt."), "duplicates.order_notice": ("The category and name of the top asset remain authoritative. The other assets are deleted only after confirmation.", "Kategorie und Bezeichnung des obersten Assets bleiben maßgeblich. Die übrigen Assets werden erst nach der Bestätigung gelöscht."), "duplicates.preview": ("View result", "Ergebnis ansehen"), diff --git a/app/main.py b/app/main.py index d32b269..aee7dc8 100644 --- a/app/main.py +++ b/app/main.py @@ -9,6 +9,7 @@ import shutil import io import json import logging +from logging.handlers import WatchedFileHandler import traceback import hashlib import hmac @@ -192,7 +193,7 @@ def _software_debug_tail(max_lines: int = 400) -> str: logger = logging.getLogger("assetmanager") logger.setLevel(logging.INFO) if not logger.handlers: - file_handler = logging.FileHandler(APP_LOG_DIR / "errors.log", encoding="utf-8") + file_handler = WatchedFileHandler(APP_LOG_DIR / "errors.log", encoding="utf-8") file_handler.setFormatter(logging.Formatter("%(asctime)s | %(levelname)s | %(message)s")) logger.addHandler(file_handler) @@ -200,7 +201,7 @@ ldap_logger = logging.getLogger("assetmanager.ldap") ldap_logger.setLevel(logging.DEBUG) ldap_logger.propagate = False if not ldap_logger.handlers: - ldap_file_handler = logging.FileHandler(APP_LOG_DIR / "ldap.log", encoding="utf-8") + ldap_file_handler = WatchedFileHandler(APP_LOG_DIR / "ldap.log", encoding="utf-8") ldap_file_handler.setFormatter(logging.Formatter("%(asctime)s | %(levelname)s | %(message)s")) ldap_logger.addHandler(ldap_file_handler) ldap_console_handler = logging.StreamHandler() @@ -6023,6 +6024,39 @@ def _decode_software_callback_body(raw_body: bytes) -> tuple[str, str, list[str] return decoded_body, detected_encoding, decode_errors +def _sanitize_callback_value(value: Any) -> Any: + """Remove PostgreSQL-incompatible NUL characters from callback values. + + Windows registry and uninstall metadata can occasionally contain embedded + NUL characters. PostgreSQL text and JSON values reject those characters, + so callback payloads are sanitized before they are written to the database. + Other characters and the original data structure are preserved. + """ + if isinstance(value, str): + return value.replace("\x00", "") + if isinstance(value, list): + return [_sanitize_callback_value(item) for item in value] + if isinstance(value, tuple): + return tuple(_sanitize_callback_value(item) for item in value) + if isinstance(value, dict): + return { + _sanitize_callback_value(key) if isinstance(key, str) else key: _sanitize_callback_value(item) + for key, item in value.items() + } + return value + + +def _count_nul_characters(value: Any) -> int: + """Count embedded NUL characters recursively for diagnostics only.""" + if isinstance(value, str): + return value.count("\x00") + if isinstance(value, (list, tuple)): + return sum(_count_nul_characters(item) for item in value) + if isinstance(value, dict): + return sum(_count_nul_characters(key) + _count_nul_characters(item) for key, item in value.items()) + return 0 + + def _process_software_job_callback( job_id: int, token: str, @@ -6101,6 +6135,14 @@ def _process_software_job_callback( _software_debug_log(f"CALLBACK JSON TYPE ERROR | job={job_id} | root={type(payload).__name__}") raise HTTPException(400, "JSON-Objekt erwartet") + nul_character_count = _count_nul_characters(payload) + if nul_character_count: + payload = _sanitize_callback_value(payload) + _software_debug_log( + f"CALLBACK sanitized PostgreSQL-incompatible NUL characters | " + f"job={job_id} | removed={nul_character_count}" + ) + _software_debug_log( f"CALLBACK JSON parsed | job={job_id} | keys={','.join(sorted(str(key) for key in payload.keys()))}" ) @@ -7910,15 +7952,6 @@ async def settings_save( success_color: str = Form("#198754"), warning_color: str = Form("#d97706"), danger_color: str = Form("#b42318"), - mesh_fallback_category_id: int = Form(1), - mesh_category_1: int | None = Form(None), - mesh_category_2: int | None = Form(None), - mesh_category_3: int | None = Form(None), - mesh_category_4: int | None = Form(None), - mesh_category_5: int | None = Form(None), - mesh_category_6: int | None = Form(None), - mesh_category_7: int | None = Form(None), - mesh_category_8: int | None = Form(None), auth_mode: str = Form("none"), ldap_server: str = Form(""), ldap_port: int = Form(389), @@ -8012,19 +8045,7 @@ async def settings_save( "debug_logging": ldap_debug_logging == "on", }) authentication["ldap"] = ldap - meshcentral = dict(current.get("meshcentral", {})) - meshcentral["fallback_category_id"] = int(mesh_fallback_category_id or 1) - meshcentral["category_mapping"] = { - "1": mesh_category_1, - "2": mesh_category_2, - "3": mesh_category_3, - "4": mesh_category_4, - "5": mesh_category_5, - "6": mesh_category_6, - "7": mesh_category_7, - "8": mesh_category_8, - } - save_config({"general": general, "authentication": authentication, "meshcentral": meshcentral}) + save_config({"general": general, "authentication": authentication}) return RedirectResponse("/settings?toast_success=Einstellungen gespeichert", status_code=303) @@ -8037,11 +8058,13 @@ def meshcentral_sync_page(request: Request, db: Session = Depends(get_db)): missing = db.query(Asset).filter(Asset.mesh_sync_status == "missing").count() definitions = db.query(FieldDefinition).filter(FieldDefinition.is_active.is_(True)).order_by(FieldDefinition.sort_order, FieldDefinition.label).all() mappings = db.query(MeshFieldMapping).order_by(MeshFieldMapping.priority, MeshFieldMapping.id).all() + categories = db.query(Category).order_by(Category.name.asc()).all() return templates.TemplateResponse("meshcentral_sync.html", { "request": request, "runs": runs, "config": public_config(), "linked": linked, "conflicts": conflicts, "missing": missing, "definitions": definitions, "mappings": mappings, + "categories": categories, "transform_options": [ "raw", "string", "integer", "decimal", "boolean", "json", "normalize_serial", "normalize_mac", "active_ipv4_address", "active_ipv4_mac", @@ -8198,8 +8221,34 @@ async def meshcentral_settings_save(request: Request, db: Session = Depends(get_ 'store_source_json': form.get('store_source_json') == 'on', 'serial_match_manufacturer': form.get('serial_match_manufacturer') == 'on', }) - submitted_match_order = [value for value in form.getlist('match_order') if value in {'node_id', 'serial_number'}] - mesh['match_order'] = submitted_match_order or ['node_id', 'serial_number'] + + invalid_serial_values = [] + for line in str(form.get('invalid_serial_values') or '').splitlines(): + value = line.strip() + if value and value.casefold() not in {item.casefold() for item in invalid_serial_values}: + invalid_serial_values.append(value) + mesh['invalid_serial_values'] = invalid_serial_values + + manufacturer_rules: dict[str, list[str]] = {} + for line in str(form.get('manufacturer_invalid_serial_rules') or '').splitlines(): + line = line.strip() + if not line or line.startswith('#'): + continue + if '|' not in line: + continue + manufacturer, serial = (part.strip() for part in line.split('|', 1)) + if not manufacturer or not serial: + continue + values = manufacturer_rules.setdefault(manufacturer, []) + if serial.casefold() not in {item.casefold() for item in values}: + values.append(serial) + mesh['manufacturer_invalid_serial_rules'] = [ + {'manufacturer': manufacturer, 'values': values} + for manufacturer, values in manufacturer_rules.items() + ] + + submitted_match_order = [value for value in form.getlist('match_order') if value in {'node_id', 'mac_address', 'serial_number'}] + mesh['match_order'] = submitted_match_order or ['node_id', 'mac_address', 'serial_number'] # Do not retain plaintext passwords from older versions. mesh.pop('password', None) mesh.pop('field_mappings', None) @@ -8208,6 +8257,44 @@ async def meshcentral_settings_save(request: Request, db: Session = Depends(get_ return RedirectResponse('/sync/meshcentral?toast_success=MeshCentral-Einstellungen gespeichert', status_code=303) +@app.post('/sync/meshcentral/categories/save') +async def meshcentral_category_mapping_save(request: Request, db: Session = Depends(get_db)): + _require_admin(request) + form = await request.form() + categories = db.query(Category).all() + valid_category_ids = {int(category.id) for category in categories} + if not valid_category_ids: + raise HTTPException(400, 'No asset categories are available.') + + try: + fallback_category_id = int(form.get('mesh_fallback_category_id') or 0) + except (TypeError, ValueError): + fallback_category_id = 0 + if fallback_category_id not in valid_category_ids: + raise HTTPException(400, 'Invalid fallback category.') + + category_mapping = {} + for type_id in range(1, 9): + raw_value = str(form.get(f'mesh_category_{type_id}') or '').strip() + if not raw_value: + category_mapping[str(type_id)] = None + continue + try: + category_id = int(raw_value) + except ValueError: + raise HTTPException(400, f'Invalid category mapping for MeshCentral type {type_id}.') + if category_id not in valid_category_ids: + raise HTTPException(400, f'Unknown category for MeshCentral type {type_id}.') + category_mapping[str(type_id)] = category_id + + current = load_config() + mesh = dict(current.get('meshcentral', {})) + mesh['fallback_category_id'] = fallback_category_id + mesh['category_mapping'] = category_mapping + save_config({'meshcentral': mesh}) + return RedirectResponse('/sync/meshcentral?toast_success=' + quote(_translate_request(request, 'settings.mesh_mapping_saved', 'MeshCentral category mapping saved')), status_code=303) + + @app.post('/sync/meshcentral/mappings/save') async def meshcentral_mappings_save(request: Request, db: Session = Depends(get_db)): _require_admin(request) diff --git a/app/main_old.py b/app/main_old.py deleted file mode 100644 index 78aa096..0000000 --- a/app/main_old.py +++ /dev/null @@ -1,1351 +0,0 @@ -import os -import shutil -import uuid -import threading -import io -import logging -import traceback -import hashlib -import hmac -import secrets -from urllib.parse import quote -from datetime import datetime, date -from pathlib import Path -from fastapi import Depends, FastAPI, File, Form, HTTPException, Request, UploadFile -from fastapi.responses import RedirectResponse, JSONResponse, StreamingResponse, Response -from starlette.exceptions import HTTPException as StarletteHTTPException -from starlette.middleware.sessions import SessionMiddleware -from fastapi.staticfiles import StaticFiles -from fastapi.templating import Jinja2Templates -from sqlalchemy.orm import Session, joinedload -from sqlalchemy.exc import IntegrityError - -from .database import Base, engine, get_db, SessionLocal -from .fields import ASSET_FIELDS, COMPUTER_FIELDS, DEFAULT_FIELDS -from .models import Asset, AssetHistory, Category, CategoryField, StatusOption, SyncRun, User -from .config import load_config, public_config, save_config -from .meshcentral import synchronize -from .migrations import apply_lightweight_migrations -from openpyxl import Workbook, load_workbook -from openpyxl.styles import Font, PatternFill, Alignment - -BASE_DIR = Path(__file__).resolve().parent -UPLOAD_DIR = BASE_DIR / "static" / "uploads" -UPLOAD_DIR.mkdir(parents=True, exist_ok=True) -LOG_DIR = Path(os.getenv("SYNC_LOG_DIR", "/app/data/logs/sync")) -LOG_DIR.mkdir(parents=True, exist_ok=True) -APP_LOG_DIR = Path(os.getenv("APP_LOG_DIR", "/app/data/logs")) -APP_LOG_DIR.mkdir(parents=True, exist_ok=True) - -logger = logging.getLogger("assetmanager") -logger.setLevel(logging.INFO) -if not logger.handlers: - file_handler = logging.FileHandler(APP_LOG_DIR / "errors.log", encoding="utf-8") - file_handler.setFormatter(logging.Formatter("%(asctime)s | %(levelname)s | %(message)s")) - logger.addHandler(file_handler) - -ldap_logger = logging.getLogger("assetmanager.ldap") -ldap_logger.setLevel(logging.DEBUG) -ldap_logger.propagate = False -if not ldap_logger.handlers: - ldap_file_handler = logging.FileHandler(APP_LOG_DIR / "ldap.log", encoding="utf-8") - ldap_file_handler.setFormatter(logging.Formatter("%(asctime)s | %(levelname)s | %(message)s")) - ldap_logger.addHandler(ldap_file_handler) - ldap_console_handler = logging.StreamHandler() - ldap_console_handler.setFormatter(logging.Formatter("LDAP | %(levelname)s | %(message)s")) - ldap_logger.addHandler(ldap_console_handler) - -app = FastAPI(title=os.getenv("APP_TITLE", "AssetManager")) -_session_cfg = load_config().get("authentication", {}) -_session_env = _session_cfg.get("session_secret_env", "SESSION_SECRET") -_session_secret = os.getenv(_session_env) or os.getenv("SESSION_SECRET") or "assetmanager-change-this-session-secret" -app.add_middleware(SessionMiddleware, secret_key=_session_secret, same_site="lax", https_only=False) -app.mount("/static", StaticFiles(directory=BASE_DIR / "static"), name="static") -templates = Jinja2Templates(directory=BASE_DIR / "templates") -templates.env.globals["application_config"] = load_config -SYNC_JOBS: dict[str, dict] = {} -SYNC_JOBS_LOCK = threading.Lock() - - -def _hash_password(password: str) -> str: - salt = secrets.token_bytes(16) - iterations = 310_000 - digest = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt, iterations) - return f"pbkdf2_sha256${iterations}${salt.hex()}${digest.hex()}" - - -def _verify_password(password: str, encoded: str | None) -> bool: - if not encoded: - return False - try: - algorithm, iterations_text, salt_hex, digest_hex = encoded.split("$", 3) - if algorithm != "pbkdf2_sha256": - return False - candidate = hashlib.pbkdf2_hmac( - "sha256", password.encode("utf-8"), bytes.fromhex(salt_hex), int(iterations_text) - ) - return hmac.compare_digest(candidate.hex(), digest_hex) - except (ValueError, TypeError): - return False - - -def _session_user(request: Request) -> dict | None: - value = request.session.get("user") - return value if isinstance(value, dict) else None - - -def _changed_by(request: Request) -> str | None: - user = _session_user(request) - return (user.get("display_name") or user.get("username")) if user else None - - -def _login_user(request: Request, user: User) -> None: - request.session["user"] = { - "id": user.id, - "username": user.username, - "display_name": user.display_name or user.username, - "email": user.email or "", - "auth_source": user.auth_source, - "is_admin": bool(user.is_admin), - "allowed_mesh_groups": user.allowed_mesh_groups or [], - } - - - -def _is_admin(request: Request) -> bool: - user = _session_user(request) - return bool(user and user.get("is_admin")) - - -def _require_admin(request: Request) -> None: - if not _is_admin(request): - raise HTTPException(403, "Diese Funktion ist nur für Administratoren verfügbar.") - - -def _allowed_groups(request: Request) -> list[str] | None: - user = _session_user(request) - if not user or user.get("is_admin"): - return None - return [str(x) for x in (user.get("allowed_mesh_groups") or []) if str(x).strip()] - - -def _apply_asset_access(query, request: Request): - groups = _allowed_groups(request) - return query if groups is None else query.filter(Asset.mesh_group.in_(groups)) - - -def _get_visible_asset(db: Session, request: Request, asset_id: int) -> Asset: - query = _apply_asset_access(db.query(Asset), request) - asset = query.filter(Asset.id == asset_id).first() - if not asset: - raise HTTPException(404, "Asset nicht gefunden oder keine Berechtigung") - return asset - - -def _action_status(db: Session, action: str) -> StatusOption | None: - column = StatusOption.use_for_issue if action == "issue" else StatusOption.use_for_return - return db.query(StatusOption).filter(StatusOption.active.is_(True), column.is_(True)).first() - - -def _ldap_authenticate(username: str, password: str, auth_config: dict) -> dict | None: - from ldap3 import ALL, Connection, Server, SUBTREE - from ldap3.core.exceptions import LDAPException - from ldap3.utils.conv import escape_filter_chars - - ldap = auth_config.get("ldap", {}) - server_name = str(ldap.get("server", "")).strip() - base_dn = str(ldap.get("user_base_dn", "")).strip() - use_ssl = bool(ldap.get("use_ssl", False)) - start_tls = bool(ldap.get("start_tls", False)) - port = int(ldap.get("port") or (636 if use_ssl else 389)) - bind_dn = str(ldap.get("bind_dn", "")).strip() - env_name = str(ldap.get("bind_password_env", "LDAP_BIND_PASSWORD")).strip() - bind_password = os.getenv(env_name, "") if env_name else "" - if not bind_password: - bind_password = str(ldap.get("bind_password", "")) - - ldap_logger.info( - "Anmeldeversuch Benutzer=%r Server=%s Port=%s SSL=%s StartTLS=%s BaseDN=%r BindDN=%r Passwortvariable=%r gesetzt=%s", - username, server_name, port, use_ssl, start_tls, base_dn, bind_dn, env_name, bool(bind_password), - ) - - if not server_name: - ldap_logger.error("LDAP-Server ist nicht konfiguriert") - return None - if not base_dn: - ldap_logger.error("Benutzer-Basis-DN ist nicht konfiguriert") - return None - if not password: - ldap_logger.warning("Leeres Benutzerpasswort für Benutzer=%r", username) - return None - if bind_dn and not bind_password: - ldap_logger.error("Bind-DN ist gesetzt, aber das Bind-Passwort fehlt. Erwartete Umgebungsvariable=%r", env_name) - return None - - server = Server(server_name, port=port, use_ssl=use_ssl, get_info=ALL, connect_timeout=10) - search_connection = None - user_connection = None - try: - search_connection = Connection( - server, - user=bind_dn or None, - password=bind_password or None, - auto_bind=False, - receive_timeout=15, - raise_exceptions=False, - ) - if not search_connection.open(): - ldap_logger.error("Verbindung zum LDAP-Server fehlgeschlagen: result=%r last_error=%r", search_connection.result, search_connection.last_error) - return None - ldap_logger.debug("TCP/LDAP-Verbindung geöffnet") - - if start_tls and not use_ssl: - if not search_connection.start_tls(): - ldap_logger.error("StartTLS fehlgeschlagen: result=%r last_error=%r", search_connection.result, search_connection.last_error) - return None - ldap_logger.debug("StartTLS erfolgreich") - - if not search_connection.bind(): - ldap_logger.error("Dienstkonto-Bind fehlgeschlagen: result=%r last_error=%r", search_connection.result, search_connection.last_error) - return None - ldap_logger.info("Dienstkonto-Bind erfolgreich") - - raw_filter = str(ldap.get("user_filter", "(sAMAccountName={username})")) - escaped_username = escape_filter_chars(username) - user_filter = raw_filter.replace("{username}", escaped_username).replace("{{username}}", escaped_username) - display_attr = str(ldap.get("display_name_attribute", "displayName")) - email_attr = str(ldap.get("email_attribute", "mail")) - attributes = list(dict.fromkeys([display_attr, email_attr, "distinguishedName"])) - ldap_logger.info("Benutzersuche BaseDN=%r Filter=%r Attribute=%r", base_dn, user_filter, attributes) - - search_ok = search_connection.search( - base_dn, - user_filter, - search_scope=SUBTREE, - attributes=attributes, - ) - ldap_logger.info( - "Benutzersuche beendet: success=%s Treffer=%s result=%r last_error=%r", - search_ok, len(search_connection.entries), search_connection.result, search_connection.last_error, - ) - if len(search_connection.entries) != 1: - if len(search_connection.entries) == 0: - ldap_logger.warning("Kein LDAP-Benutzer für Benutzer=%r gefunden", username) - else: - ldap_logger.warning("Mehrere LDAP-Benutzer für Benutzer=%r gefunden: %s", username, len(search_connection.entries)) - return None - - entry = search_connection.entries[0] - user_dn = entry.entry_dn - display_name = str(getattr(entry, display_attr, "") or username) - email = str(getattr(entry, email_attr, "") or "") - ldap_logger.info("Benutzer gefunden: DN=%r Anzeigename=%r E-Mail=%r", user_dn, display_name, email) - - user_connection = Connection( - server, - user=user_dn, - password=password, - auto_bind=False, - receive_timeout=15, - raise_exceptions=False, - ) - if not user_connection.open(): - ldap_logger.error("Verbindung für Benutzer-Bind fehlgeschlagen: result=%r last_error=%r", user_connection.result, user_connection.last_error) - return None - if start_tls and not use_ssl: - if not user_connection.start_tls(): - ldap_logger.error("StartTLS beim Benutzer-Bind fehlgeschlagen: result=%r last_error=%r", user_connection.result, user_connection.last_error) - return None - if not user_connection.bind(): - ldap_logger.warning("Benutzer-Bind fehlgeschlagen für DN=%r: result=%r last_error=%r", user_dn, user_connection.result, user_connection.last_error) - return None - - ldap_logger.info("LDAP-Anmeldung erfolgreich für Benutzer=%r DN=%r", username, user_dn) - return {"username": username, "display_name": display_name, "email": email} - except LDAPException as exc: - ldap_logger.exception("LDAP-Ausnahme für Benutzer=%r: %s", username, exc) - return None - except Exception as exc: - ldap_logger.exception("Unerwarteter LDAP-Fehler für Benutzer=%r: %s", username, exc) - return None - finally: - for connection in (user_connection, search_connection): - if connection is not None: - try: - connection.unbind() - except Exception: - pass - - -@app.middleware("http") -async def authentication_gate(request: Request, call_next): - mode = str(load_config().get("authentication", {}).get("mode", "none")) - public_paths = {"/login", "/logout"} - if mode in {"local", "ldap"} and not request.session.get("user") and request.url.path not in public_paths and not request.url.path.startswith("/static/"): - if request.method == "GET": - return RedirectResponse("/login?toast_warning=Bitte zuerst anmelden", status_code=303) - return JSONResponse({"detail": "Anmeldung erforderlich"}, status_code=401) - return await call_next(request) - - -def _is_browser_request(request: Request) -> bool: - accept = request.headers.get("accept", "") - return "text/html" in accept or request.method != "GET" - - -def _toast_redirect(request: Request, message: str, level: str = "error") -> RedirectResponse: - referer = request.headers.get("referer") - target = referer if referer and referer.startswith(str(request.base_url).rstrip("/")) else "/" - separator = "&" if "?" in target else "?" - return RedirectResponse(f"{target}{separator}toast_{level}={quote(message)}", status_code=303) - - -@app.exception_handler(StarletteHTTPException) -async def http_exception_handler(request: Request, exc: StarletteHTTPException): - message = str(exc.detail) if exc.detail else f"HTTP-Fehler {exc.status_code}" - logger.error("HTTP %s | %s %s | %s", exc.status_code, request.method, request.url.path, message) - if _is_browser_request(request): - return _toast_redirect(request, message) - return JSONResponse({"detail": message}, status_code=exc.status_code) - - -@app.exception_handler(Exception) -async def unhandled_exception_handler(request: Request, exc: Exception): - logger.error( - "Unerwarteter Fehler | %s %s | %s\n%s", - request.method, - request.url.path, - exc, - traceback.format_exc(), - ) - if _is_browser_request(request): - return _toast_redirect(request, "Ein unerwarteter Fehler ist aufgetreten. Details wurden protokolliert.") - return JSONResponse({"detail": "Interner Serverfehler"}, status_code=500) - -def _job_log(job_id: str, level: str, message: str) -> None: - entry = { - "time": datetime.now().strftime("%H:%M:%S"), - "level": level, - "message": str(message), - } - with SYNC_JOBS_LOCK: - job = SYNC_JOBS.get(job_id) - if job is not None: - job["logs"].append(entry) - job["logs"] = job["logs"][-10000:] - try: - with (LOG_DIR / f"{job_id}.jsonl").open("a", encoding="utf-8") as handle: - import json - handle.write(json.dumps(entry, ensure_ascii=False) + "\n") - except OSError: - pass - -def _run_sync_job(job_id: str) -> None: - db = SessionLocal() - try: - result = synchronize(db, lambda level, message: _job_log(job_id, level, message)) - with SYNC_JOBS_LOCK: - job = SYNC_JOBS[job_id] - job["status"] = result.run.status - job["run_id"] = result.run.id - job["finished"] = True - except Exception as exc: - _job_log(job_id, "error", f"Unerwarteter Fehler: {exc}") - with SYNC_JOBS_LOCK: - job = SYNC_JOBS[job_id] - job["status"] = "error" - job["finished"] = True - finally: - db.close() - - - -@app.on_event("startup") -def startup(): - Base.metadata.create_all(bind=engine) - apply_lightweight_migrations() - from .database import SessionLocal - db = SessionLocal() - try: - default_statuses = ["Aktiv", "Defekt", "im Lager", "Ausgegeben", "Verschrottet"] - if db.query(StatusOption).count() == 0: - for order, status_name in enumerate(default_statuses): - db.add(StatusOption(name=status_name, sort_order=order, active=True)) - db.commit() - if not db.query(StatusOption).filter(StatusOption.use_for_issue.is_(True)).first(): - item = db.query(StatusOption).filter(StatusOption.name == "Ausgegeben").first() - if item: item.use_for_issue = True - if not db.query(StatusOption).filter(StatusOption.use_for_return.is_(True)).first(): - item = db.query(StatusOption).filter(StatusOption.name == "im Lager").first() - if item: item.use_for_return = True - db.commit() - if db.query(Category).count() == 0: - computer = Category(name="Computer", description="Computer und Notebooks aus MeshCentral oder manueller Erfassung") - db.add(computer) - db.flush() - for order, field_name in enumerate(COMPUTER_FIELDS): - db.add(CategoryField( - category_id=computer.id, - field_name=field_name, - label=ASSET_FIELDS[field_name], - active=True, - required=field_name == "name", - show_in_list=field_name in {"asset_tag", "name", "hostname", "operating_system", "os_patch_level", "mac_address", "status"}, - sort_order=order, - )) - db.commit() - else: - computer = db.query(Category).options(joinedload(Category.visible_fields)).filter(Category.name == "Computer").first() - if computer: - existing = {field.field_name for field in computer.visible_fields} - next_order = max((field.sort_order for field in computer.visible_fields), default=-1) + 1 - important_list_fields = {"hostname", "manufacturer", "model", "serial_number", "operating_system", "ip_address", "mac_address", "storage_details", "gpu_name", "tpm_version", "antivirus", "mesh_group"} - changed = False - for field_name in COMPUTER_FIELDS: - if field_name not in existing: - db.add(CategoryField( - category_id=computer.id, field_name=field_name, label=ASSET_FIELDS[field_name], - active=True, required=False, show_in_list=field_name in important_list_fields, - sort_order=next_order, - )) - next_order += 1 - changed = True - if changed: - db.commit() - finally: - db.close() - - - - -def delete_asset_image(image_path: str | None) -> None: - """Löscht nur individuell hochgeladene Asset-Bilder aus dem Upload-Ordner.""" - if not image_path or not image_path.startswith("/static/uploads/"): - return - filename = Path(image_path).name - target = UPLOAD_DIR / filename - try: - target.unlink(missing_ok=True) - except OSError: - pass - - -def save_upload(upload: UploadFile | None) -> str | None: - if not upload or not upload.filename: - return None - suffix = Path(upload.filename).suffix.lower() - if suffix not in {".png", ".jpg", ".jpeg", ".webp", ".gif"}: - raise HTTPException(400, "Nur PNG, JPG, WEBP oder GIF sind erlaubt.") - filename = f"{uuid.uuid4().hex}{suffix}" - target = UPLOAD_DIR / filename - with target.open("wb") as buffer: - shutil.copyfileobj(upload.file, buffer) - return f"/static/uploads/{filename}" - - -def _display_value(value) -> str: - if value is None: - return "" - if isinstance(value, (dict, list)): - import json - return json.dumps(value, ensure_ascii=False, sort_keys=True) - return str(value) - - -def _record_asset_history(db: Session, asset: Asset, changes: dict, source: str = "manual", changed_by: str | None = None) -> None: - if not changes: - return - db.add(AssetHistory(asset_id=asset.id, source=source, changed_by=changed_by, changes=changes)) - - -def _xlsx_response(filename: str, headers: list[str], rows: list[list]) -> Response: - """Erzeugt eine echte XLSX-Datei und liefert vollständige Bytes statt eines Streams. - - Das verhindert beschädigte Downloads bei einzelnen Reverse-Proxies und Browsern. - """ - workbook = Workbook() - sheet = workbook.active - sheet.title = "Export" - sheet.freeze_panes = "A2" - sheet.auto_filter.ref = f"A1:{sheet.cell(row=1, column=max(1, len(headers))).column_letter}1" - sheet.append(headers) - header_fill = PatternFill("solid", fgColor="D9EAF7") - for cell in sheet[1]: - cell.font = Font(bold=True) - cell.fill = header_fill - cell.alignment = Alignment(vertical="top") - for row in rows: - sheet.append([_display_value(value) for value in row]) - for column in sheet.columns: - max_length = min(max((len(str(cell.value or "")) for cell in column), default=10) + 2, 60) - sheet.column_dimensions[column[0].column_letter].width = max_length - output = io.BytesIO() - workbook.save(output) - payload = output.getvalue() - ascii_name = "".join(ch if ch.isascii() and (ch.isalnum() or ch in "._-") else "_" for ch in filename) - disposition = f"attachment; filename={ascii_name}; filename*=UTF-8''{quote(filename)}" - return Response( - content=payload, - media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", - headers={ - "Content-Disposition": disposition, - "Content-Length": str(len(payload)), - "X-Content-Type-Options": "nosniff", - "Cache-Control": "no-store", - }, - ) - - -def _import_field_map(category: Category) -> dict[str, str]: - result: dict[str, str] = {"bezeichnung": "name", "name": "name", "kategorie": "category"} - for field in category.visible_fields: - if field.active and hasattr(Asset, field.field_name): - result[field.label.strip().casefold()] = field.field_name - result[field.field_name.strip().casefold()] = field.field_name - return result - - -def _excel_cell_value(value) -> str | None: - if value is None: - return None - if isinstance(value, datetime): - return value.strftime("%Y-%m-%d %H:%M:%S") - if isinstance(value, date): - return value.isoformat() - text = str(value).strip() - return text or None - - -def _active_status_options(db: Session) -> list[StatusOption]: - return db.query(StatusOption).filter(StatusOption.active.is_(True)).order_by(StatusOption.sort_order, StatusOption.name).all() - - -def _asset_action_changes(asset: Asset, action: str, status_name: str, assigned_to: str | None = None, assigned_on: str | None = None) -> dict[str, dict[str, str]]: - changes: dict[str, dict[str, str]] = {} - if _display_value(asset.status) != status_name: - changes[ASSET_FIELDS["status"]] = {"old": _display_value(asset.status), "new": status_name} - asset.status = status_name - if action == "issue": - new_name = (assigned_to or "").strip() or None - new_date = (assigned_on or date.today().isoformat()).strip() - if _display_value(asset.assigned_to) != _display_value(new_name): - changes[ASSET_FIELDS["assigned_to"]] = {"old": _display_value(asset.assigned_to), "new": _display_value(new_name)} - asset.assigned_to = new_name - if _display_value(asset.assigned_on) != new_date: - changes[ASSET_FIELDS["assigned_on"]] = {"old": _display_value(asset.assigned_on), "new": new_date} - asset.assigned_on = new_date - else: - if asset.assigned_to: - changes[ASSET_FIELDS["assigned_to"]] = {"old": _display_value(asset.assigned_to), "new": ""} - asset.assigned_to = None - if asset.assigned_on: - changes[ASSET_FIELDS["assigned_on"]] = {"old": _display_value(asset.assigned_on), "new": ""} - asset.assigned_on = None - return changes - - -def _list_fields_for_categories(categories: list[Category]) -> list[CategoryField]: - """Bildet eine eindeutige, sortierte Vereinigungsmenge der konfigurierten Listenspalten.""" - result: list[CategoryField] = [] - seen: set[str] = set() - for category in categories: - for field in sorted(category.visible_fields, key=lambda item: item.sort_order): - if not field.active or not field.show_in_list or field.field_name in seen: - continue - result.append(field) - seen.add(field.field_name) - return result - - -@app.get("/") -def dashboard(request: Request, db: Session = Depends(get_db)): - categories = db.query(Category).options(joinedload(Category.visible_fields)).all() - assets = ( - _apply_asset_access(db.query(Asset), request) - .options(joinedload(Asset.category).joinedload(Category.visible_fields)) - .order_by(Asset.updated_at.desc()) - .limit(10) - .all() - ) - recent_categories = [] - seen_category_ids = set() - for asset in assets: - if asset.category_id not in seen_category_ids: - recent_categories.append(asset.category) - seen_category_ids.add(asset.category_id) - fields = _list_fields_for_categories(recent_categories) - return templates.TemplateResponse( - "dashboard.html", - {"request": request, "categories": categories, "assets": assets, "fields": fields, "is_admin": _is_admin(request), "issue_status_name": (_action_status(db, "issue").name if _action_status(db, "issue") else None)}, - ) - - -@app.get("/assets") -def assets_list(request: Request, category_id: int | None = None, db: Session = Depends(get_db)): - categories = db.query(Category).options(joinedload(Category.visible_fields)).all() - query = _apply_asset_access(db.query(Asset), request).options(joinedload(Asset.category).joinedload(Category.visible_fields)) - selected = None - if category_id: - selected = next((category for category in categories if category.id == category_id), None) - if not selected: - raise HTTPException(404, "Kategorie nicht gefunden") - query = query.filter(Asset.category_id == category_id) - assets = query.order_by(Asset.name).all() - fields = ( - [field for field in sorted(selected.visible_fields, key=lambda item: item.sort_order) if field.active and field.show_in_list] - if selected - else _list_fields_for_categories(categories) - ) - return templates.TemplateResponse( - "assets.html", - {"request": request, "assets": assets, "categories": categories, "selected": selected, "fields": fields, "is_admin": _is_admin(request), "issue_status_name": (_action_status(db, "issue").name if _action_status(db, "issue") else None)}, - ) - - -@app.get("/assets/new") -def asset_new(request: Request, category_id: int | None = None, db: Session = Depends(get_db)): - _require_admin(request) - categories = db.query(Category).options(joinedload(Category.visible_fields)).all() - selected = next((c for c in categories if c.id == category_id), categories[0] if categories else None) - return templates.TemplateResponse("asset_form.html", {"request": request, "asset": None, "categories": categories, "selected": selected, "fields": ASSET_FIELDS, "status_options": _active_status_options(db), "duplicate_mode": False}) - - -@app.get("/assets/{asset_id}/duplicate") -def asset_duplicate(asset_id: int, request: Request, db: Session = Depends(get_db)): - _require_admin(request) - original = db.query(Asset).options(joinedload(Asset.category).joinedload(Category.visible_fields)).filter(Asset.id == asset_id).first() - if not original: - raise HTTPException(404, "Asset nicht gefunden") - categories = db.query(Category).options(joinedload(Category.visible_fields)).all() - class DuplicateValues: - pass - duplicate = DuplicateValues() - for field_name in ASSET_FIELDS: - setattr(duplicate, field_name, getattr(original, field_name, None)) - duplicate.asset_tag = None - duplicate.mesh_node_id = None - duplicate.name = f"{original.name} – Kopie" - return templates.TemplateResponse("asset_form.html", { - "request": request, "asset": duplicate, "categories": categories, "selected": original.category, - "fields": ASSET_FIELDS, "status_options": _active_status_options(db), "duplicate_mode": True, - }) - - -@app.post("/assets/new") -async def asset_create(request: Request, category_id: int = Form(...), image: UploadFile | None = File(None), db: Session = Depends(get_db)): - _require_admin(request) - form = await request.form() - category = db.query(Category).options(joinedload(Category.visible_fields)).filter(Category.id == category_id).first() - if not category: - raise HTTPException(404, "Kategorie nicht gefunden") - data = {f.field_name: form.get(f.field_name) or None for f in category.visible_fields if f.active and hasattr(Asset, f.field_name)} - if not data.get("name"): - raise HTTPException(400, "Bezeichnung fehlt") - asset = Asset(category_id=category_id, image_path=save_upload(image), **data) - db.add(asset) - try: - db.flush() - created_changes = {field.label: {"old": "", "new": _display_value(getattr(asset, field.field_name, None))} for field in category.visible_fields if field.active and getattr(asset, field.field_name, None) not in (None, "")} - _record_asset_history(db, asset, created_changes, source="manual-create", changed_by=_changed_by(request)) - db.commit() - except IntegrityError as exc: - db.rollback() - raise HTTPException(400, "Asset konnte nicht gespeichert werden. Inventarnummer, Seriennummer oder MeshCentral Node-ID ist möglicherweise bereits vergeben.") from exc - return RedirectResponse(f"/assets/{asset.id}", status_code=303) - - -@app.get("/assets/import") -def assets_import_form(request: Request, category_id: int | None = None, db: Session = Depends(get_db)): - categories = db.query(Category).options(joinedload(Category.visible_fields)).order_by(Category.name).all() - selected = next((category for category in categories if category.id == category_id), categories[0] if categories else None) - return templates.TemplateResponse( - "assets_import.html", - {"request": request, "categories": categories, "selected": selected, "result": None}, - ) - - -@app.get("/assets/import/template.xlsx") -def assets_import_template(request: Request, category_id: int, db: Session = Depends(get_db)): - _require_admin(request) - category = db.query(Category).options(joinedload(Category.visible_fields)).filter(Category.id == category_id).first() - if not category: - raise HTTPException(404, "Kategorie nicht gefunden") - fields = [field for field in sorted(category.visible_fields, key=lambda item: item.sort_order) if field.active and hasattr(Asset, field.field_name)] - if not any(field.field_name == "name" for field in fields): - headers = ["Bezeichnung"] + [field.label for field in fields] - else: - headers = [field.label for field in fields] - return _xlsx_response(f"asset-import-{category.name}.xlsx", headers, []) - - -@app.post("/assets/import") -async def assets_import( - request: Request, - category_id: int = Form(...), - file: UploadFile = File(...), - duplicate_action: str = Form("skip"), - db: Session = Depends(get_db), -): - _require_admin(request) - category = db.query(Category).options(joinedload(Category.visible_fields)).filter(Category.id == category_id).first() - categories = db.query(Category).options(joinedload(Category.visible_fields)).order_by(Category.name).all() - if not category: - raise HTTPException(404, "Kategorie nicht gefunden") - if not file.filename or Path(file.filename).suffix.lower() != ".xlsx": - raise HTTPException(400, "Bitte eine XLSX-Datei auswählen.") - try: - raw = await file.read() - workbook = load_workbook(io.BytesIO(raw), read_only=True, data_only=True) - sheet = workbook.active - except Exception as exc: - logger.exception("Excel-Import konnte nicht geöffnet werden") - raise HTTPException(400, "Die Excel-Datei ist ungültig oder beschädigt.") from exc - - rows_iter = sheet.iter_rows(values_only=True) - header_row = next(rows_iter, None) - if not header_row: - raise HTTPException(400, "Die Excel-Datei enthält keine Kopfzeile.") - field_map = _import_field_map(category) - columns: list[str | None] = [] - unknown_headers: list[str] = [] - for header in header_row: - label = _excel_cell_value(header) or "" - field_name = field_map.get(label.casefold()) - columns.append(field_name) - if label and not field_name: - unknown_headers.append(label) - if "name" not in columns: - raise HTTPException(400, "Die Spalte ‚Bezeichnung‘ bzw. ‚Name‘ fehlt.") - - created = 0 - updated = 0 - skipped = 0 - errors: list[str] = [] - changed_by = _changed_by(request) - allowed_fields = {field.field_name for field in category.visible_fields if field.active and hasattr(Asset, field.field_name)} | {"name"} - for excel_row_number, values in enumerate(rows_iter, start=2): - data: dict[str, str | None] = {} - for index, value in enumerate(values): - if index >= len(columns): - break - field_name = columns[index] - if field_name and field_name in allowed_fields: - data[field_name] = _excel_cell_value(value) - if not any(value not in (None, "") for value in data.values()): - continue - if not data.get("name"): - errors.append(f"Zeile {excel_row_number}: Bezeichnung fehlt") - continue - - existing = None - if data.get("asset_tag"): - existing = db.query(Asset).filter(Asset.asset_tag == data["asset_tag"]).first() - if existing is None and data.get("serial_number"): - existing = db.query(Asset).filter(Asset.serial_number == data["serial_number"]).first() - if existing: - if duplicate_action != "update": - skipped += 1 - continue - changes: dict[str, dict[str, str]] = {} - existing.category_id = category.id - for field_name, value in data.items(): - old_value = getattr(existing, field_name, None) - if _display_value(old_value) != _display_value(value): - changes[ASSET_FIELDS.get(field_name, field_name)] = {"old": _display_value(old_value), "new": _display_value(value)} - setattr(existing, field_name, value) - _record_asset_history(db, existing, changes, source="excel-import-update", changed_by=changed_by) - updated += 1 - else: - try: - with db.begin_nested(): - asset = Asset(category_id=category.id, **data) - db.add(asset) - db.flush() - created_changes = {ASSET_FIELDS.get(name, name): {"old": "", "new": _display_value(value)} for name, value in data.items() if value not in (None, "")} - _record_asset_history(db, asset, created_changes, source="excel-import-create", changed_by=changed_by) - created += 1 - except IntegrityError: - errors.append(f"Zeile {excel_row_number}: Inventarnummer, Seriennummer oder MeshCentral Node-ID ist bereits vergeben") - continue - try: - db.commit() - except IntegrityError as exc: - db.rollback() - raise HTTPException(400, "Der Import konnte wegen doppelter eindeutiger Werte nicht gespeichert werden.") from exc - result = {"created": created, "updated": updated, "skipped": skipped, "errors": errors, "unknown_headers": unknown_headers} - return templates.TemplateResponse( - "assets_import.html", - {"request": request, "categories": categories, "selected": category, "result": result}, - ) - - -@app.get("/assets/{asset_id}") -def asset_detail(asset_id: int, request: Request, db: Session = Depends(get_db)): - asset = _apply_asset_access(db.query(Asset), request).options(joinedload(Asset.category).joinedload(Category.visible_fields)).filter(Asset.id == asset_id).first() - if not asset: - raise HTTPException(404, "Asset nicht gefunden") - active_fields = [f for f in asset.category.visible_fields if f.active] - history_count = db.query(AssetHistory).filter(AssetHistory.asset_id == asset.id).count() - return templates.TemplateResponse("asset_detail.html", {"request": request, "asset": asset, "active_fields": active_fields, "history_count": history_count, "is_admin": _is_admin(request), "issue_status_name": (_action_status(db, "issue").name if _action_status(db, "issue") else None), "today": date.today().isoformat()}) - - -@app.get("/assets/{asset_id}/edit") -def asset_edit(asset_id: int, request: Request, category_id: int | None = None, db: Session = Depends(get_db)): - _require_admin(request) - asset = db.query(Asset).options(joinedload(Asset.category).joinedload(Category.visible_fields)).filter(Asset.id == asset_id).first() - categories = db.query(Category).options(joinedload(Category.visible_fields)).all() - if not asset: - raise HTTPException(404, "Asset nicht gefunden") - selected = next((category for category in categories if category.id == category_id), None) if category_id else asset.category - if selected is None: - selected = asset.category - return templates.TemplateResponse("asset_form.html", {"request": request, "asset": asset, "categories": categories, "selected": selected, "fields": ASSET_FIELDS, "status_options": _active_status_options(db), "duplicate_mode": False}) - - -@app.post("/assets/{asset_id}/edit") -async def asset_update(asset_id: int, request: Request, category_id: int = Form(...), image: UploadFile | None = File(None), db: Session = Depends(get_db)): - asset = db.query(Asset).filter(Asset.id == asset_id).first() - category = db.query(Category).options(joinedload(Category.visible_fields)).filter(Category.id == category_id).first() - if not asset or not category: - raise HTTPException(404, "Asset oder Kategorie nicht gefunden") - form = await request.form() - changes: dict[str, dict[str, str]] = {} - old_category_id = asset.category_id - if old_category_id != category_id: - old_category = db.query(Category).filter(Category.id == old_category_id).first() - changes["Kategorie"] = {"old": old_category.name if old_category else str(old_category_id), "new": category.name} - asset.category_id = category_id - for field in category.visible_fields: - if field.active and hasattr(asset, field.field_name): - old_value = getattr(asset, field.field_name) - new_value = form.get(field.field_name) or None - if _display_value(old_value) != _display_value(new_value): - changes[field.label] = {"old": _display_value(old_value), "new": _display_value(new_value)} - setattr(asset, field.field_name, new_value) - new_image = save_upload(image) - if new_image: - if asset.image_path != new_image: - changes["Bild"] = {"old": asset.image_path or "", "new": new_image} - asset.image_path = new_image - try: - _record_asset_history(db, asset, changes, source="manual-update", changed_by=_changed_by(request)) - db.commit() - except IntegrityError as exc: - db.rollback() - raise HTTPException(400, "Asset konnte nicht gespeichert werden. Inventarnummer, Seriennummer oder MeshCentral Node-ID ist möglicherweise bereits vergeben.") from exc - return RedirectResponse(f"/assets/{asset.id}", status_code=303) - - -@app.get("/assets/{asset_id}/history") -def asset_history(asset_id: int, request: Request, db: Session = Depends(get_db)): - asset = _get_visible_asset(db, request, asset_id) - entries = db.query(AssetHistory).filter(AssetHistory.asset_id == asset_id).order_by(AssetHistory.changed_at.desc()).all() - return templates.TemplateResponse("asset_history.html", {"request": request, "asset": asset, "entries": entries}) - - -@app.get("/assets/{asset_id}/history/export.xlsx") -def asset_history_export(asset_id: int, request: Request, db: Session = Depends(get_db)): - asset = _get_visible_asset(db, request, asset_id) - if not asset: - raise HTTPException(404, "Asset nicht gefunden") - entries = db.query(AssetHistory).filter(AssetHistory.asset_id == asset_id).order_by(AssetHistory.changed_at.desc()).all() - rows = [] - for entry in entries: - for label, change in (entry.changes or {}).items(): - rows.append([entry.changed_at, entry.source, entry.changed_by or "", label, change.get("old", ""), change.get("new", "")]) - return _xlsx_response(f"asset-{asset.id}-historie.xlsx", ["Zeitpunkt", "Quelle", "Benutzer", "Feld", "Alter Wert", "Neuer Wert"], rows) - - -@app.get("/assets/export.xlsx") -def assets_export(request: Request, category_id: int | None = None, db: Session = Depends(get_db)): - categories = db.query(Category).options(joinedload(Category.visible_fields)).all() - selected = next((category for category in categories if category.id == category_id), None) if category_id else None - query = _apply_asset_access(db.query(Asset), request) - if selected: - query = query.filter(Asset.category_id == selected.id) - assets = query.order_by(Asset.name).all() - fields = ([field for field in sorted(selected.visible_fields, key=lambda item: item.sort_order) if field.active and field.show_in_list] if selected else _list_fields_for_categories(categories)) - headers = (["Kategorie"] if not selected else []) + [field.label for field in fields] - if not any(field.field_name == "name" for field in fields): - headers.insert(0, "Bezeichnung") - rows = [] - for asset in assets: - row = ([asset.category.name] if not selected else []) + [getattr(asset, field.field_name, "") for field in fields] - if not any(field.field_name == "name" for field in fields): - row.insert(0, asset.name) - rows.append(row) - suffix = selected.name if selected else "alle" - return _xlsx_response(f"assets-{suffix}.xlsx", headers, rows) - - -@app.get("/export/assets.xlsx") -def assets_export_static(request: Request, category_id: int | None = None, db: Session = Depends(get_db)): - return assets_export(request=request, category_id=category_id, db=db) - - -@app.post("/assets/{asset_id}/issue") -def asset_issue(asset_id: int, request: Request, return_to: str = Form("detail"), assigned_to: str = Form(""), assigned_on: str = Form(""), db: Session = Depends(get_db)): - _require_admin(request) - asset = _get_visible_asset(db, request, asset_id) - status = _action_status(db, "issue") - if not status: - raise HTTPException(400, "Kein Status für die Aktion Ausgeben konfiguriert") - changes = _asset_action_changes(asset, "issue", status.name, assigned_to, assigned_on) - _record_asset_history(db, asset, changes, source="manual-issue", changed_by=_changed_by(request)) - db.commit() - return RedirectResponse("/" if return_to == "dashboard" else ("/assets" if return_to == "list" else f"/assets/{asset.id}"), status_code=303) - - -@app.post("/assets/{asset_id}/return") -def asset_return(asset_id: int, request: Request, return_to: str = Form("detail"), db: Session = Depends(get_db)): - _require_admin(request) - asset = _get_visible_asset(db, request, asset_id) - status = _action_status(db, "return") - if not status: - raise HTTPException(400, "Kein Status für die Aktion Zurücknehmen konfiguriert") - changes = _asset_action_changes(asset, "return", status.name) - _record_asset_history(db, asset, changes, source="manual-return", changed_by=_changed_by(request)) - db.commit() - return RedirectResponse("/" if return_to == "dashboard" else ("/assets" if return_to == "list" else f"/assets/{asset.id}"), status_code=303) - - -@app.post("/assets/{asset_id}/delete") -def asset_delete(asset_id: int, request: Request, db: Session = Depends(get_db)): - _require_admin(request) - asset = db.query(Asset).filter(Asset.id == asset_id).first() - if not asset: - raise HTTPException(404, "Asset nicht gefunden") - - image_path = asset.image_path - db.delete(asset) - db.commit() - delete_asset_image(image_path) - return RedirectResponse("/assets", status_code=303) - - -@app.post("/assets/delete-all") -def assets_delete_all(request: Request, category_id: int | None = Form(None), db: Session = Depends(get_db)): - _require_admin(request) - query = db.query(Asset) - redirect_url = "/assets" - if category_id is not None: - query = query.filter(Asset.category_id == category_id) - redirect_url = f"/assets?category_id={category_id}" - - assets = query.all() - image_paths = [asset.image_path for asset in assets if asset.image_path] - for asset in assets: - db.delete(asset) - db.commit() - - for image_path in image_paths: - delete_asset_image(image_path) - - return RedirectResponse(redirect_url, status_code=303) - - -@app.get("/status-options") -def status_options_page(request: Request, db: Session = Depends(get_db)): - _require_admin(request) - options = db.query(StatusOption).order_by(StatusOption.sort_order, StatusOption.name).all() - return templates.TemplateResponse("status_options.html", {"request": request, "options": options}) - - -@app.post("/status-options") -async def status_options_save(request: Request, db: Session = Depends(get_db)): - _require_admin(request) - form = await request.form() - existing = db.query(StatusOption).all() - for option in existing: - name = (form.get(f"name_{option.id}") or "").strip() - if not name: - continue - option.name = name - option.active = form.get(f"active_{option.id}") == "on" - try: - option.sort_order = int(form.get(f"sort_{option.id}") or option.sort_order) - except ValueError: - pass - issue_id = int(form.get("issue_status_id") or 0) - return_id = int(form.get("return_status_id") or 0) - for option in existing: - option.use_for_issue = option.id == issue_id - option.use_for_return = option.id == return_id - new_name = (form.get("new_name") or "").strip() - if new_name: - max_order = max((item.sort_order for item in existing), default=-1) - db.add(StatusOption(name=new_name, sort_order=max_order + 1, active=True)) - try: - db.commit() - except IntegrityError as exc: - db.rollback() - raise HTTPException(400, "Statuswerte müssen eindeutig sein.") from exc - return RedirectResponse("/status-options", status_code=303) - - -@app.post("/status-options/{option_id}/delete") -def status_option_delete(option_id: int, request: Request, db: Session = Depends(get_db)): - _require_admin(request) - option = db.query(StatusOption).filter(StatusOption.id == option_id).first() - if not option: - raise HTTPException(404, "Statuswert nicht gefunden") - db.delete(option) - db.commit() - return RedirectResponse("/status-options", status_code=303) - - -@app.get("/categories") -def categories_list(request: Request, db: Session = Depends(get_db)): - _require_admin(request) - categories = db.query(Category).options(joinedload(Category.visible_fields)).order_by(Category.name).all() - return templates.TemplateResponse("categories.html", {"request": request, "categories": categories}) - - -@app.get("/categories/new") -def category_new(request: Request): - _require_admin(request) - return templates.TemplateResponse("category_form.html", {"request": request, "category": None, "field_defs": ASSET_FIELDS, "selected_fields": DEFAULT_FIELDS}) - - -@app.post("/categories/new") -async def category_create(request: Request, name: str = Form(...), description: str = Form(""), image: UploadFile | None = File(None), db: Session = Depends(get_db)): - _require_admin(request) - form = await request.form() - category = Category(name=name.strip(), description=description or None, image_path=save_upload(image)) - db.add(category) - db.flush() - order = 0 - for field_name, default_label in ASSET_FIELDS.items(): - active = form.get(f"active_{field_name}") == "on" - if active: - db.add(CategoryField(category_id=category.id, field_name=field_name, label=form.get(f"label_{field_name}") or default_label, active=True, required=form.get(f"required_{field_name}") == "on", show_in_list=form.get(f"list_{field_name}") == "on", sort_order=order)) - order += 1 - if form.get("active_name") != "on": - db.add(CategoryField(category_id=category.id, field_name="name", label="Bezeichnung", active=True, required=True, show_in_list=True, sort_order=0)) - try: - db.commit() - except IntegrityError as exc: - db.rollback() - raise HTTPException(400, "Kategorie konnte nicht gespeichert werden. Der Kategoriename ist möglicherweise bereits vorhanden.") from exc - return RedirectResponse("/categories", status_code=303) - - -@app.get("/categories/{category_id}/edit") -def category_edit(category_id: int, request: Request, db: Session = Depends(get_db)): - _require_admin(request) - category = db.query(Category).options(joinedload(Category.visible_fields)).filter(Category.id == category_id).first() - if not category: - raise HTTPException(404, "Kategorie nicht gefunden") - selected_fields = {f.field_name: f for f in category.visible_fields} - return templates.TemplateResponse("category_form.html", {"request": request, "category": category, "field_defs": ASSET_FIELDS, "selected_fields": selected_fields}) - - -@app.post("/categories/{category_id}/edit") -async def category_update(category_id: int, request: Request, name: str = Form(...), description: str = Form(""), image: UploadFile | None = File(None), db: Session = Depends(get_db)): - _require_admin(request) - category = db.query(Category).options(joinedload(Category.visible_fields)).filter(Category.id == category_id).first() - if not category: - raise HTTPException(404, "Kategorie nicht gefunden") - form = await request.form() - category.name = name.strip() - category.description = description or None - new_image = save_upload(image) - if new_image: - category.image_path = new_image - # Vorhandene Definitionen explizit löschen und flushen. Das verhindert - # UniqueConstraint-Verletzungen bei anschließend identischen Feldnamen. - db.query(CategoryField).filter(CategoryField.category_id == category.id).delete(synchronize_session=False) - db.flush() - selected_names = [field_name for field_name in ASSET_FIELDS if form.get(f"active_{field_name}") == "on"] - if "name" not in selected_names: - selected_names.insert(0, "name") - for order, field_name in enumerate(selected_names): - default_label = ASSET_FIELDS[field_name] - db.add(CategoryField( - category_id=category.id, field_name=field_name, - label=form.get(f"label_{field_name}") or default_label, active=True, - required=True if field_name == "name" else form.get(f"required_{field_name}") == "on", - show_in_list=True if field_name == "name" else form.get(f"list_{field_name}") == "on", - sort_order=order, - )) - try: - db.commit() - except IntegrityError as exc: - db.rollback() - raise HTTPException(400, "Kategorie konnte nicht gespeichert werden. Der Kategoriename ist möglicherweise bereits vorhanden.") from exc - return RedirectResponse("/categories", status_code=303) - - - - -@app.get("/login") -def login_page(request: Request): - if _session_user(request): - return RedirectResponse("/profile", status_code=303) - mode = load_config().get("authentication", {}).get("mode", "none") - return templates.TemplateResponse("login.html", {"request": request, "mode": mode}) - - -@app.post("/login") -def login_submit(request: Request, username: str = Form(...), password: str = Form(...), db: Session = Depends(get_db)): - username = username.strip() - auth_config = load_config().get("authentication", {}) - mode = str(auth_config.get("mode", "none")).lower() - user = db.query(User).filter(User.username == username).first() - valid = False - if mode == "local": - valid = bool(user and user.is_active and user.auth_source == "local" and _verify_password(password, user.password_hash)) - elif mode == "ldap": - ldap_user = _ldap_authenticate(username, password, auth_config) - if ldap_user: - if not user: - user = User(username=username, auth_source="ldap", is_active=True) - db.add(user) - if user.is_active: - user.auth_source = "ldap" - user.display_name = ldap_user.get("display_name") or username - user.email = ldap_user.get("email") or None - valid = True - elif mode == "none": - return RedirectResponse("/?toast_warning=Die Anmeldung ist derzeit deaktiviert", status_code=303) - if not valid or not user: - logger.warning("Fehlgeschlagene Anmeldung für Benutzer %s im Modus %s", username, mode) - return RedirectResponse("/login?toast_error=Benutzername oder Passwort ist ungültig", status_code=303) - user.last_login = datetime.utcnow() - db.commit() - db.refresh(user) - _login_user(request, user) - return RedirectResponse("/?toast_success=Anmeldung erfolgreich", status_code=303) - - -@app.get("/logout") -def logout(request: Request): - request.session.clear() - return RedirectResponse("/?toast_success=Sie wurden abgemeldet", status_code=303) - - -@app.get("/profile") -def profile(request: Request, db: Session = Depends(get_db)): - session_user = _session_user(request) - if not session_user: - return RedirectResponse("/login?toast_warning=Bitte zuerst anmelden", status_code=303) - user = db.query(User).filter(User.id == session_user.get("id")).first() - if not user: - request.session.clear() - return RedirectResponse("/login?toast_error=Benutzerkonto wurde nicht gefunden", status_code=303) - return templates.TemplateResponse("profile.html", {"request": request, "user": user}) - - -@app.post("/profile/password") -def profile_password(request: Request, current_password: str = Form(...), new_password: str = Form(...), db: Session = Depends(get_db)): - session_user = _session_user(request) - if not session_user: - return RedirectResponse("/login", status_code=303) - user = db.query(User).filter(User.id == session_user.get("id")).first() - if not user or user.auth_source != "local": - return RedirectResponse("/profile?toast_warning=Passwortänderungen sind nur für lokale Konten möglich", status_code=303) - if not _verify_password(current_password, user.password_hash): - return RedirectResponse("/profile?toast_error=Das aktuelle Passwort ist falsch", status_code=303) - if len(new_password) < 8: - return RedirectResponse("/profile?toast_error=Das neue Passwort muss mindestens 8 Zeichen lang sein", status_code=303) - user.password_hash = _hash_password(new_password) - db.commit() - return RedirectResponse("/profile?toast_success=Passwort geändert", status_code=303) - - -@app.get("/users") -def users_page(request: Request, db: Session = Depends(get_db)): - _require_admin(request) - users = db.query(User).order_by(User.username).all() - mesh_groups = [row[0] for row in db.query(Asset.mesh_group).filter(Asset.mesh_group.isnot(None), Asset.mesh_group != "").distinct().order_by(Asset.mesh_group).all()] - return templates.TemplateResponse("users.html", {"request": request, "users": users, "auth": public_config().get("authentication", {}), "mesh_groups": mesh_groups}) - - -@app.post("/users/new") -def user_create(request: Request, username: str = Form(...), display_name: str = Form(""), email: str = Form(""), password: str = Form(...), is_admin: str | None = Form(None), db: Session = Depends(get_db)): - _require_admin(request) - username = username.strip() - if not username: - raise HTTPException(400, "Benutzername fehlt") - if len(password) < 8: - raise HTTPException(400, "Das Passwort muss mindestens 8 Zeichen lang sein") - user = User(username=username, display_name=display_name.strip() or username, email=email.strip() or None, password_hash=_hash_password(password), auth_source="local", is_active=True, is_admin=is_admin == "on") - db.add(user) - try: - db.commit() - except IntegrityError as exc: - db.rollback() - raise HTTPException(400, "Der Benutzername ist bereits vorhanden") from exc - return RedirectResponse("/users?toast_success=Benutzer angelegt", status_code=303) - - -@app.post("/users/{user_id}/update") -def user_update(user_id: int, request: Request, display_name: str = Form(""), email: str = Form(""), is_active: str | None = Form(None), is_admin: str | None = Form(None), new_password: str = Form(""), mesh_groups: list[str] = Form([]), db: Session = Depends(get_db)): - _require_admin(request) - user = db.query(User).filter(User.id == user_id).first() - if not user: - raise HTTPException(404, "Benutzer nicht gefunden") - user.display_name = display_name.strip() or user.username - user.email = email.strip() or None - user.is_active = is_active == "on" - user.is_admin = is_admin == "on" - user.allowed_mesh_groups = sorted(set(mesh_groups)) - if new_password: - if user.auth_source != "local": - raise HTTPException(400, "LDAP-Passwörter können hier nicht geändert werden") - if len(new_password) < 8: - raise HTTPException(400, "Das neue Passwort muss mindestens 8 Zeichen lang sein") - user.password_hash = _hash_password(new_password) - db.commit() - return RedirectResponse("/users?toast_success=Benutzer gespeichert", status_code=303) - - - - -def _ldap_search_users(query_text: str, auth_config: dict) -> list[dict]: - from ldap3 import ALL, Connection, Server, SUBTREE - from ldap3.utils.conv import escape_filter_chars - ldap = auth_config.get("ldap", {}) - server_name = str(ldap.get("server", "")).strip() - use_ssl = bool(ldap.get("use_ssl", False)); start_tls = bool(ldap.get("start_tls", False)) - port = int(ldap.get("port") or (636 if use_ssl else 389)) - bind_dn = str(ldap.get("bind_dn", "")).strip() - env_name = str(ldap.get("bind_password_env", "LDAP_BIND_PASSWORD")).strip() - bind_password = os.getenv(env_name, "") or str(ldap.get("bind_password", "")) - base_dn = str(ldap.get("user_base_dn", "")).strip() - q = escape_filter_chars(query_text.strip()) - search_filter = f"(&(objectCategory=person)(objectClass=user)(|(sAMAccountName=*{q}*)(displayName=*{q}*)(mail=*{q}*)))" - server = Server(server_name, port=port, use_ssl=use_ssl, get_info=ALL, connect_timeout=10) - conn = Connection(server, user=bind_dn or None, password=bind_password or None, auto_bind=False, receive_timeout=15) - if not conn.open(): raise HTTPException(400, "LDAP-Verbindung fehlgeschlagen") - if start_tls and not use_ssl and not conn.start_tls(): raise HTTPException(400, "LDAP StartTLS fehlgeschlagen") - if not conn.bind(): raise HTTPException(400, f"LDAP Bind fehlgeschlagen: {conn.last_error}") - conn.search(base_dn, search_filter, search_scope=SUBTREE, attributes=["sAMAccountName", "displayName", "mail", "distinguishedName"], size_limit=100) - result=[] - for e in conn.entries: - result.append({"username": str(getattr(e,"sAMAccountName","") or ""), "display_name": str(getattr(e,"displayName","") or ""), "email": str(getattr(e,"mail","") or ""), "dn": e.entry_dn}) - conn.unbind(); return result - - -@app.get("/settings/ldap-search") -def ldap_search_form(request: Request): - _require_admin(request) - return templates.TemplateResponse("ldap_search.html", {"request": request, "query": "", "results": []}) - - -@app.post("/settings/ldap-search") -def ldap_search_page(request: Request, ldap_query: str = Form("")): - _require_admin(request) - cfg = public_config() - results = _ldap_search_users(ldap_query, cfg.get("authentication", {})) if ldap_query.strip() else [] - return templates.TemplateResponse("ldap_search.html", {"request": request, "query": ldap_query, "results": results}) - -@app.get("/settings") -def settings_page(request: Request): - _require_admin(request) - return templates.TemplateResponse("settings.html", {"request": request, "settings": public_config()}) - - -@app.post("/settings") -async def settings_save( - request: Request, - title: str = Form("AssetManager"), - company_name: str = Form(""), - auth_mode: str = Form("none"), - ldap_server: str = Form(""), - ldap_port: int = Form(389), - ldap_use_ssl: str | None = Form(None), - ldap_start_tls: str | None = Form(None), - ldap_bind_dn: str = Form(""), - ldap_bind_password_env: str = Form("LDAP_BIND_PASSWORD"), - ldap_user_base_dn: str = Form(""), - ldap_user_filter: str = Form("(sAMAccountName={username})"), - ldap_display_name_attribute: str = Form("displayName"), - ldap_email_attribute: str = Form("mail"), - remove_logo: str | None = Form(None), - logo: UploadFile | None = File(None), -): - _require_admin(request) - - current = load_config() - general = dict(current.get("general", {})) - general["title"] = title.strip() or "AssetManager" - general["company_name"] = company_name.strip() - if remove_logo == "on": - old_logo = general.get("logo") - if old_logo: - delete_asset_image(old_logo) - general["logo"] = "" - if logo and logo.filename: - old_logo = general.get("logo") - new_logo = save_upload(logo) - if old_logo and old_logo != new_logo: - delete_asset_image(old_logo) - general["logo"] = new_logo or "" - if auth_mode not in {"none", "local", "ldap"}: - auth_mode = "none" - authentication = dict(current.get("authentication", {})) - authentication["mode"] = auth_mode - ldap = dict(authentication.get("ldap", {})) - ldap.update({ - "server": ldap_server.strip(), - "port": ldap_port, - "use_ssl": ldap_use_ssl == "on", - "start_tls": ldap_start_tls == "on", - "bind_dn": ldap_bind_dn.strip(), - "bind_password_env": ldap_bind_password_env.strip() or "LDAP_BIND_PASSWORD", - "user_base_dn": ldap_user_base_dn.strip(), - "user_filter": ldap_user_filter.strip() or "(sAMAccountName={username})", - "display_name_attribute": ldap_display_name_attribute.strip() or "displayName", - "email_attribute": ldap_email_attribute.strip() or "mail", - }) - authentication["ldap"] = ldap - save_config({"general": general, "authentication": authentication}) - return RedirectResponse("/settings?toast_success=Einstellungen gespeichert", status_code=303) - - -@app.get("/sync/meshcentral") -def meshcentral_sync_page(request: Request, db: Session = Depends(get_db)): - _require_admin(request) - runs = db.query(SyncRun).order_by(SyncRun.started_at.desc()).limit(20).all() - linked = db.query(Asset).filter(Asset.mesh_node_id.isnot(None)).count() - conflicts = db.query(Asset).filter(Asset.mesh_sync_status == "conflict").count() - missing = db.query(Asset).filter(Asset.mesh_sync_status == "missing").count() - return templates.TemplateResponse("meshcentral_sync.html", { - "request": request, "runs": runs, "config": public_config(), - "linked": linked, "conflicts": conflicts, "missing": missing - }) - - -@app.post("/sync/meshcentral/start") -def meshcentral_sync_start(request: Request): - _require_admin(request) - with SYNC_JOBS_LOCK: - if any(not job.get("finished") for job in SYNC_JOBS.values()): - return JSONResponse({"error": "Es läuft bereits eine Synchronisierung."}, status_code=409) - job_id = uuid.uuid4().hex - SYNC_JOBS[job_id] = {"status": "running", "finished": False, "run_id": None, "logs": [], "created_at": datetime.now().isoformat()} - _job_log(job_id, "info", "Synchronisierungsauftrag angelegt.") - threading.Thread(target=_run_sync_job, args=(job_id,), daemon=True).start() - return {"job_id": job_id} - - -@app.get("/sync/meshcentral/jobs/{job_id}") -def meshcentral_sync_job(job_id: str, offset: int = 0): - with SYNC_JOBS_LOCK: - job = SYNC_JOBS.get(job_id) - if not job: - raise HTTPException(404, "Synchronisierungsauftrag nicht gefunden") - logs = list(job["logs"][max(0, offset):]) - return { - "status": job["status"], - "finished": job["finished"], - "run_id": job["run_id"], - "logs": logs, - "next_offset": max(0, offset) + len(logs), - } - - -@app.post("/sync/meshcentral") -def meshcentral_sync_run(): - return RedirectResponse("/sync/meshcentral", status_code=303) diff --git a/app/meshcentral.py b/app/meshcentral.py index 5399a03..19e5262 100644 --- a/app/meshcentral.py +++ b/app/meshcentral.py @@ -466,29 +466,126 @@ def _path_values(data: Any, path: str) -> list[Any]: def _network_entries(value: Any) -> list[dict[str, Any]]: + """Flatten MeshCentral network entries while retaining the interface name. + + ``net.netif2`` is keyed by interface name. Keeping that name allows us to + prefer real/primary interfaces over loopback, container and tunnel entries. + """ entries: list[dict[str, Any]] = [] if isinstance(value, dict): - for group in value.values(): - if isinstance(group, list): - entries.extend(item for item in group if isinstance(item, dict)) + for adapter_name, group in value.items(): + if not isinstance(group, list): + continue + for item in group: + if not isinstance(item, dict): + continue + entry = dict(item) + entry.setdefault('_adapter_name', str(adapter_name or '')) + entries.append(entry) elif isinstance(value, list): - entries.extend(item for item in value if isinstance(item, dict)) + entries.extend(dict(item) for item in value if isinstance(item, dict)) return entries +def _network_interface_rank(name: Any) -> int: + """Return a stable preference rank for common physical/virtual NIC names.""" + value = str(name or '').strip().casefold() + if not value: + return 80 + + # Explicitly de-prioritize interfaces that are usually local/container/tunnel + # plumbing and should not become the AssetManager management address. + low_priority = ( + 'loopback', 'docker', 'veth', 'virbr', 'tun', 'tap', 'tailscale', + 'wg', 'zt', 'br-', + ) + if value == 'lo' or value.startswith(low_priority): + return 200 + + # Common Linux, Windows and hypervisor interface names. Comparison is + # intentionally case-insensitive. + if value.startswith(('ethernet', 'eth')): + return 10 + if value.startswith(('eno', 'ens', 'enp', 'enx')): + return 15 + if value == 'vmbr0': + return 20 + if value.startswith('vmbr'): + return 22 + if value.startswith(('bond', 'team')): + return 25 + if value.startswith(('wlan', 'wlp', 'wifi', 'wi-fi', 'wireless')): + return 30 + if value.startswith(('vethernet', 'hyper-v', 'vmnet')): + return 35 + if value.startswith('br'): + return 40 + return 60 + + +def _network_entry_active(entry: dict[str, Any]) -> bool: + status = str(entry.get('status') or '').strip().casefold() + return status not in {'down', 'inactive', 'disabled', 'disconnected', 'notpresent'} + + +def _valid_ipv4(value: Any) -> bool: + try: + import ipaddress + address = ipaddress.ip_address(str(value or '').strip()) + except ValueError: + return False + return address.version == 4 and not address.is_loopback and not address.is_unspecified + + +def _valid_network_mac(value: Any) -> str | None: + mac = _normalize_mac(value) + if not mac: + return None + raw = re.sub(r'[^0-9A-Fa-f]', '', mac) + # Reject malformed, all-zero and broadcast placeholders. Locally administered + # addresses remain valid because they are common for virtual interfaces. + if len(raw) != 12 or set(raw.casefold()) == {'0'} or set(raw.casefold()) == {'f'}: + return None + return ':'.join(raw[index:index + 2] for index in range(0, 12, 2)).upper() + + +def _preferred_network_entries(value: Any) -> list[dict[str, Any]]: + """Order usable network records by interface suitability and source order.""" + candidates: list[tuple[int, int, dict[str, Any]]] = [] + for index, entry in enumerate(_network_entries(value)): + if entry.get('type') == 'loopback' or not _network_entry_active(entry): + continue + rank = _network_interface_rank(entry.get('_adapter_name')) + candidates.append((rank, index, entry)) + candidates.sort(key=lambda item: (item[0], item[1])) + return [entry for _, _, entry in candidates] + + def _convert_mapping_value(values: list[Any], transform: str, separator: str) -> Any: if not values: return None source = values[0] if transform == 'active_ipv4_address': - for entry in _network_entries(source): - if entry.get('family') == 'IPv4' and entry.get('status') == 'up' and entry.get('type') != 'loopback': - return entry.get('address') + entries = _preferred_network_entries(source) + # Prefer a usable IPv4 address on well-known management interfaces. + for entry in entries: + if entry.get('family') == 'IPv4' and _valid_ipv4(entry.get('address')): + return str(entry.get('address')).strip() return None if transform == 'active_ipv4_mac': - for entry in _network_entries(source): - if entry.get('family') == 'IPv4' and entry.get('status') == 'up' and entry.get('type') != 'loopback': - return _normalize_mac(entry.get('mac')) + entries = _preferred_network_entries(source) + # Prefer the MAC from an interface that also has usable IPv4 data. This + # keeps IP and MAC aligned in the common case and avoids all-zero values. + for entry in entries: + if entry.get('family') == 'IPv4' and _valid_ipv4(entry.get('address')): + mac = _valid_network_mac(entry.get('mac')) + if mac: + return mac + # Some MeshCentral inventories expose MAC data in a separate record. + for entry in entries: + mac = _valid_network_mac(entry.get('mac')) + if mac: + return mac return None if transform == 'normalize_serial': return _normalize_serial(source) @@ -683,17 +780,72 @@ def _normalize_manufacturer(value: Any) -> str: return "".join(tokens) +def _normalized_serial_value(value: Any) -> str: + if value in (None, ""): + return "" + return str(value).replace("\x00", "").strip().casefold() + + +def _invalid_serial_reason(mapped: dict[str, Any], cfg: dict[str, Any]) -> str | None: + """Return a human-readable reason when a reported serial number is a configured placeholder.""" + serial = _normalized_serial_value(mapped.get("serial_number")) + if not serial: + return None + + global_values = { + _normalized_serial_value(value) + for value in (cfg.get("invalid_serial_values") or []) + if _normalized_serial_value(value) + } + if serial in global_values: + return "global configured placeholder" + + manufacturer = _normalize_manufacturer(mapped.get("manufacturer")) + if manufacturer: + for rule in cfg.get("manufacturer_invalid_serial_rules") or []: + if not isinstance(rule, dict): + continue + if _normalize_manufacturer(rule.get("manufacturer")) != manufacturer: + continue + values = { + _normalized_serial_value(value) + for value in (rule.get("values") or []) + if _normalized_serial_value(value) + } + if serial in values: + return f"configured placeholder for {rule.get('manufacturer') or mapped.get('manufacturer')}" + return None + + +def _serial_identity_key(mapped: dict[str, Any], *, include_manufacturer: bool) -> tuple[str, str] | None: + serial = _normalized_serial_value(mapped.get("serial_number")) + if not serial: + return None + manufacturer = _normalize_manufacturer(mapped.get("manufacturer")) if include_manufacturer else "" + return serial, manufacturer + + +def _normalized_mac_identity(value: Any) -> str: + mac = _valid_network_mac(value) + return mac.casefold() if mac else "" + + def _find_asset( db: Session, mapped: dict[str, Any], order: list[str], *, serial_match_manufacturer: bool = False, + serial_is_invalid: bool = False, + serial_is_ambiguous: bool = False, ) -> tuple[Asset | None, str | None, str | None]: + incoming_mac = _normalized_mac_identity(mapped.get("mac_address")) for method in order: if method == "node_id" and mapped.get("mesh_node_id"): matches = db.query(Asset).filter(Asset.mesh_node_id == mapped["mesh_node_id"]).all() - elif method == "serial_number" and mapped.get("serial_number"): + elif method == "mac_address" and incoming_mac: + matches = db.query(Asset).filter(func.lower(Asset.mac_address) == str(mapped.get("mac_address")).lower()).all() + elif method == "serial_number" and mapped.get("serial_number") and not serial_is_invalid and not serial_is_ambiguous: matches = db.query(Asset).filter(func.lower(Asset.serial_number) == mapped["serial_number"].lower()).all() if serial_match_manufacturer: incoming_manufacturer = _normalize_manufacturer(mapped.get("manufacturer")) @@ -703,6 +855,16 @@ def _find_asset( asset for asset in matches if _normalize_manufacturer(asset.manufacturer) == incoming_manufacturer ] + # A shared DMI/mainboard serial is common for containers and VMs. + # Never accept such a serial match when both sides have a valid but + # different MAC address. This avoids reassigning another guest's + # MeshCentral node ID. + if incoming_mac: + matches = [ + asset for asset in matches + if not _normalized_mac_identity(asset.mac_address) + or _normalized_mac_identity(asset.mac_address) == incoming_mac + ] else: continue if len(matches) == 1: @@ -928,12 +1090,34 @@ def synchronize(db: Session, log: Callable[[str, str], None] | None = None, *, d log("error", f"Systemfeld '{target}' besitzt keine Asset-Datenbankspalte und wird übersprungen.") run.error_count += 1 log("info", "Dry-Run gestartet; es werden keine Datenbankänderungen gespeichert." if dry_run else "Synchronisierung gestartet.") - log("info", "Asset-Zuordnung: " + " → ".join(cfg.get("match_order", ["node_id", "serial_number"]))) + log("info", "Asset-Zuordnung: " + " → ".join(cfg.get("match_order", ["node_id", "mac_address", "serial_number"]))) if cfg.get("serial_match_manufacturer", False): log("info", "Seriennummern-Abgleich erfordert zusätzlich einen normalisierten Herstellervergleich.") + invalid_global_count = len(cfg.get("invalid_serial_values") or []) + invalid_manufacturer_count = len(cfg.get("manufacturer_invalid_serial_rules") or []) + if invalid_global_count or invalid_manufacturer_count: + log("info", f"Dummy-Seriennummernfilter aktiv: {invalid_global_count} globale Werte, {invalid_manufacturer_count} Herstellerregel(n). Node-ID bleibt die stabile Geräteidentität.") devices = fetch_devices(log) run.devices_found = len(devices) log("info", f"Verarbeite {len(devices)} Gerät(e).") + + # Determine serial numbers that are already ambiguous within the current + # MeshCentral inventory. This is essential for containers/VMs where the + # host DMI/mainboard serial may be exposed identically to several guests. + serial_identity_counts: dict[tuple[str, str], int] = {} + for raw_for_identity in devices: + mapped_for_identity = map_device(raw_for_identity, field_mappings) + if _invalid_serial_reason(mapped_for_identity, cfg): + continue + identity_key = _serial_identity_key( + mapped_for_identity, + include_manufacturer=bool(cfg.get("serial_match_manufacturer", False)), + ) + if identity_key: + serial_identity_counts[identity_key] = serial_identity_counts.get(identity_key, 0) + 1 + ambiguous_serial_keys = {key for key, count in serial_identity_counts.items() if count > 1} + if ambiguous_serial_keys: + log("warning", f"{len(ambiguous_serial_keys)} Seriennummern-Identität(en) sind im aktuellen MeshCentral-Bestand mehrfach vorhanden und werden nicht zum Matching verwendet.") fallback_id = int(cfg.get("fallback_category_id") or 1) fallback_category = db.query(Category).filter(Category.id == fallback_id).first() if fallback_category is None and fallback_id != 1: @@ -954,11 +1138,24 @@ def synchronize(db: Session, log: Callable[[str, str], None] | None = None, *, d log("error", "Gerät ohne Node-ID wurde übersprungen.") continue seen_node_ids.add(node_id) + invalid_serial_reason = _invalid_serial_reason(mapped, cfg) + serial_identity_key = _serial_identity_key( + mapped, + include_manufacturer=bool(cfg.get("serial_match_manufacturer", False)), + ) + serial_is_ambiguous = bool(serial_identity_key and serial_identity_key in ambiguous_serial_keys) + if serial_is_ambiguous: + log("warning", f"{mapped.get('name', node_id)}: Seriennummer '{mapped.get('serial_number')}' ist im aktuellen MeshCentral-Bestand nicht eindeutig und wird nicht zum Matching verwendet. Node-ID/MAC haben Vorrang.") + if invalid_serial_reason: + reported_serial = mapped.get("serial_number") + log("warning", f"{mapped.get('name', node_id)}: gemeldete Seriennummer '{reported_serial}' als Dummy erkannt ({invalid_serial_reason}); Seriennummer wird weder zum Matching noch zum Überschreiben verwendet. Node-ID: {node_id}.") asset, match_method, match_error = _find_asset( db, mapped, - cfg.get("match_order", ["node_id", "serial_number"]), + cfg.get("match_order", ["node_id", "mac_address", "serial_number"]), serial_match_manufacturer=bool(cfg.get("serial_match_manufacturer", False)), + serial_is_invalid=bool(invalid_serial_reason), + serial_is_ambiguous=serial_is_ambiguous, ) if match_error: run.conflict_count += 1 @@ -997,6 +1194,8 @@ def synchronize(db: Session, log: Callable[[str, str], None] | None = None, *, d for target, incoming in mapped.items(): if target == "mesh_device_type" or incoming in (None, "", [], {}): continue + if target == "serial_number" and invalid_serial_reason: + continue definition = field_definitions.get(target) if definition is None: diff --git a/app/static/css/app.css b/app/static/css/app.css index bc4f8c1..1535c5e 100644 --- a/app/static/css/app.css +++ b/app/static/css/app.css @@ -2851,3 +2851,113 @@ table.server-filter-loading tbody{opacity:.55;transition:opacity .12s ease} .privacy-settings-form[data-privacy-readonly="true"] input:disabled, .privacy-settings-form[data-privacy-readonly="true"] textarea:disabled, .privacy-settings-form[data-privacy-readonly="true"] select:disabled { opacity: 1; color: inherit; -webkit-text-fill-color: currentColor; cursor: default; } + +/* First-start security notice shown while authentication is disabled. */ +.authentication-disabled-banner{display:flex;align-items:flex-start;gap:.75rem;padding:.7rem 1.25rem;background:#fff3cd;border-bottom:1px solid #d9b650;color:#4a3a00;font-size:.92rem;line-height:1.35} +.authentication-disabled-banner strong{white-space:nowrap} +html[data-theme="dark"] .authentication-disabled-banner{background:#3b310d;border-bottom-color:#7f6a1d;color:#fff0ad} +@media(max-width:800px){.authentication-disabled-banner{flex-direction:column;gap:.25rem}.authentication-disabled-banner strong{white-space:normal}} + +/* Active category / duplicate filters on the asset list. */ +.asset-context-filter-details{display:flex;align-items:center;gap:8px;flex-wrap:wrap} +.asset-filter-chip{display:inline-flex;align-items:center;gap:6px;padding:3px 7px;border:1px solid currentColor;border-radius:999px;background:rgba(255,255,255,.45)} +.asset-filter-chip-remove{display:inline-grid;place-items:center;width:20px;height:20px;padding:0;border:0;border-radius:50%;background:transparent;color:inherit;font:inherit;font-size:17px;line-height:1;cursor:pointer} +.asset-filter-chip-remove:hover,.asset-filter-chip-remove:focus-visible{background:rgba(0,0,0,.10);outline:none} +html[data-theme="dark"] .asset-filter-chip{background:rgba(0,0,0,.16)} +html[data-theme="dark"] .asset-filter-chip-remove:hover,html[data-theme="dark"] .asset-filter-chip-remove:focus-visible{background:rgba(255,255,255,.12)} + +/* v0.5.5.41: generated visual row number column for data tables. */ +.data-table .table-row-number-header, +.data-table .table-row-number-cell{ + width:1%; + min-width:3.25rem; + white-space:nowrap; + text-align:right; + font-variant-numeric:tabular-nums; +} +.data-table .table-row-number-header{ + cursor:default; +} +.data-table .table-row-number-cell{ + color:var(--am-muted,#64748b); + user-select:none; +} + + +/* Visual row-number column: readable by default and user-resizable per table. */ +.data-table .table-row-number-header, +.data-table .table-row-number-filter, +.data-table .table-row-number-cell { + width: var(--table-row-number-width, 64px); + min-width: var(--table-row-number-width, 64px); + max-width: var(--table-row-number-width, 64px); + text-align: right; + white-space: nowrap; +} +.data-table .table-row-number-header { + position: relative; + padding-right: 14px; + overflow: visible; +} +.data-table .table-row-number-cell { + font-variant-numeric: tabular-nums; + overflow: hidden; + text-overflow: clip; +} +.data-table .table-row-number-resizer { + position: absolute; + top: 0; + right: -4px; + bottom: 0; + width: 9px; + cursor: col-resize; + z-index: 3; + touch-action: none; +} +.data-table .table-row-number-resizer::after { + content: ""; + position: absolute; + top: 20%; + bottom: 20%; + left: 4px; + border-left: 1px solid transparent; +} +.data-table .table-row-number-resizer:hover::after, +.data-table .table-row-number-resizer:focus-visible::after { + border-left-color: currentColor; +} +html.table-column-resizing, +html.table-column-resizing * { + cursor: col-resize !important; + user-select: none !important; +} + + +/* User-resizable widths for every data-table column. */ +.data-table th.table-resizable-column-header { + position: relative; + padding-right: 14px; + overflow: visible; +} +.data-table .table-column-resizer { + position: absolute; + top: 0; + right: -4px; + bottom: 0; + width: 9px; + cursor: col-resize; + z-index: 4; + touch-action: none; +} +.data-table .table-column-resizer::after { + content: ""; + position: absolute; + top: 20%; + bottom: 20%; + left: 4px; + border-left: 1px solid transparent; +} +.data-table .table-column-resizer:hover::after, +.data-table .table-column-resizer:focus-visible::after { + border-left-color: currentColor; +} diff --git a/app/static/js/asset-duplicates.js b/app/static/js/asset-duplicates.js index 76efe14..f3848d6 100644 --- a/app/static/js/asset-duplicates.js +++ b/app/static/js/asset-duplicates.js @@ -9,6 +9,11 @@ const ignoreEmpty = document.getElementById('duplicate-ignore-empty'); const caseInsensitive = document.getElementById('duplicate-case-insensitive'); const summary = document.getElementById('duplicate-search-summary'); + const contextStatus = document.getElementById('asset-context-filter-status'); + const duplicateChip = document.getElementById('asset-duplicate-filter-chip'); + const duplicateLabel = document.getElementById('asset-duplicate-filter-label'); + const duplicateRemove = document.getElementById('asset-duplicate-filter-remove'); + const categoryRemove = document.getElementById('asset-category-filter-remove'); if (!table || !openButton || !dialog || table.dataset.duplicateSearchReady === '1') return; table.dataset.duplicateSearchReady = '1'; @@ -26,6 +31,17 @@ label: th.dataset.label || th.textContent.trim() })); + function refreshContextStatus() { + const categoryChip = document.getElementById('asset-category-filter-chip'); + const duplicateActive = table.dataset.duplicateFilterActive === '1'; + if (duplicateChip) duplicateChip.hidden = !duplicateActive; + if (contextStatus) { + const hasActive = Boolean(categoryChip) || duplicateActive; + contextStatus.hidden = !hasActive; + contextStatus.classList.toggle('is-active', hasActive); + } + } + function fillSelect() { const previous = select.value; select.innerHTML = ''; @@ -42,8 +58,13 @@ function clearDuplicateFilter() { dataRows().forEach(row => row.classList.remove('duplicate-hidden', 'duplicate-match')); - summary.hidden = true; - window.dispatchEvent(new Event('asset-table-filter-changed')); + delete table.dataset.duplicateFilterActive; + delete table.dataset.duplicateFilterTotal; + delete table.dataset.duplicateFilterField; + if (summary) summary.hidden = true; + if (duplicateLabel) duplicateLabel.textContent = ''; + refreshContextStatus(); + window.dispatchEvent(new CustomEvent('asset-table-filter-changed', {detail: {table}})); } function normalize(value) { @@ -93,9 +114,24 @@ } }); - summary.textContent = `${duplicateRows.size} Datensätze in ${duplicateGroups} Dublettengruppen gefunden.`; - summary.hidden = false; - window.dispatchEvent(new Event('asset-table-filter-changed')); + table.dataset.duplicateFilterActive = '1'; + table.dataset.duplicateFilterTotal = String(duplicateRows.size); + table.dataset.duplicateFilterField = field; + + const selectedOption = select.options[select.selectedIndex]; + const fieldLabel = selectedOption?.textContent?.trim() || field; + const duplicateTemplate = contextStatus?.dataset.duplicateTemplate || 'Duplikate: {field}'; + if (duplicateLabel) duplicateLabel.textContent = duplicateTemplate.replace('{field}', fieldLabel); + refreshContextStatus(); + + const summaryTemplate = dialog.dataset.resultSummaryTemplate || '{records} Datensätze in {groups} Dublettengruppen gefunden.'; + if (summary) { + summary.textContent = summaryTemplate + .replace('{records}', String(duplicateRows.size)) + .replace('{groups}', String(duplicateGroups)); + summary.hidden = false; + } + window.dispatchEvent(new CustomEvent('asset-table-filter-changed', {detail: {table}})); dialog.close(); } @@ -108,6 +144,13 @@ }); applyButton.addEventListener('click', applyDuplicateFilter); clearButton.addEventListener('click', clearDuplicateFilter); + duplicateRemove?.addEventListener('click', clearDuplicateFilter); + categoryRemove?.addEventListener('click', () => { + const url = new URL(window.location.href); + url.searchParams.delete('category_id'); + window.location.assign(url.toString()); + }); + refreshContextStatus(); } if (document.readyState === 'loading') { diff --git a/app/static/js/browser-state.js b/app/static/js/browser-state.js index 6d33197..87d8b38 100644 --- a/app/static/js/browser-state.js +++ b/app/static/js/browser-state.js @@ -35,15 +35,35 @@ return control.name || control.dataset.field || cell?.dataset.field || `filter-${index}`; } + function restoreSort(table, index, force = false) { + if (!force && table.dataset.browserSortRestored === '1') return; + if (typeof table.assetApplySort !== 'function') return; + + const key = `${tableKey(table, index)}:sort`; + let state = null; + try { state = JSON.parse(api.get(key) || 'null'); } catch (_) {} + if (state?.field && (state.direction === 'asc' || state.direction === 'desc')) { + table.assetApplySort(state.field, state.direction, {silent: true}); + } else { + table.assetUpdateRowNumbers?.(); + } + table.dataset.browserSortRestored = '1'; + } + function restoreTable(table, index) { if (table.dataset.browserStateReady === '1') return; if (table.dataset.serverFilters === '1') { table.dataset.browserStateReady = '1'; + restoreSort(table, index); return; } const restoreStarted = performance.now(); const controls = filterControls(table); - if (!controls.length) return; + if (!controls.length) { + table.dataset.browserStateReady = '1'; + restoreSort(table, index); + return; + } table.dataset.browserStateReady = '1'; const key = `${tableKey(table, index)}:filters`; @@ -71,6 +91,7 @@ if (typeof table.assetApplyFilters === 'function') { table.assetApplyFilters(); } + restoreSort(table, index); table.dataset.browserStateRestored = '1'; window.dispatchEvent(new CustomEvent('asset-table-state-ready', { detail: { table } })); const duration = performance.now() - restoreStarted; @@ -109,5 +130,25 @@ api.remove(key); }); + window.addEventListener('asset-table-sort-changed', event => { + const table = event.detail?.table; + if (!(table instanceof HTMLTableElement)) return; + const tables = [...document.querySelectorAll('table')]; + const index = tables.indexOf(table); + const key = `${tableKey(table, index >= 0 ? index : 0)}:sort`; + api.set(key, JSON.stringify({ + field: event.detail?.field || '', + direction: event.detail?.direction || 'asc' + })); + }); + + window.addEventListener('asset-table-content-reloaded', event => { + const table = event.detail?.table; + if (!(table instanceof HTMLTableElement)) return; + const tables = [...document.querySelectorAll('table')]; + const index = tables.indexOf(table); + restoreSort(table, index >= 0 ? index : 0, true); + }); + perf?.end('browser-state:gesamtes Modul'); })(); diff --git a/app/static/js/list-tools.js b/app/static/js/list-tools.js index c8e8869..888fecd 100644 --- a/app/static/js/list-tools.js +++ b/app/static/js/list-tools.js @@ -11,7 +11,7 @@ } function isVisible(row) { - if (row.hidden || row.classList.contains('hidden')) return false; + if (row.hidden || row.classList.contains('hidden') || row.classList.contains('duplicate-hidden')) return false; return true; } @@ -40,23 +40,27 @@ const updateStarted = performance.now(); const rows = [...table.querySelectorAll('tbody tr')].filter(isDataRow); const visible = rows.filter(isVisible).length; + const duplicateTotal = Number.parseInt(table.dataset.duplicateFilterTotal || '', 10); + const total = table.dataset.duplicateFilterActive === '1' && Number.isFinite(duplicateTotal) + ? duplicateTotal + : rows.length; const target = countTarget(table, index); if (target) { const template = document.body.dataset.recordCountTemplate || 'Angezeigt: {visible} von {total} Datensätzen'; target.textContent = template .replace('{visible}', String(visible)) - .replace('{total}', String(rows.length)); + .replace('{total}', String(total)); target.title = document.body.dataset.recordCountTitle || ''; } window.dispatchEvent(new CustomEvent('asset-list-count-updated', { - detail: { table, visible, total: rows.length } + detail: { table, visible, total } })); const duration = performance.now() - updateStarted; const logger = duration >= 50 ? console.warn : console.debug; logger('[AssetManager][Performance]', 'list-tools:update', { dauer_ms: Math.round(duration * 10) / 10, sichtbar: visible, - gesamt: rows.length + gesamt: total }); } diff --git a/app/static/js/table-tools.js b/app/static/js/table-tools.js index 73015bb..9467a38 100644 --- a/app/static/js/table-tools.js +++ b/app/static/js/table-tools.js @@ -163,6 +163,221 @@ const headerRow = thead.rows[0]; + // Add a purely visual row-number column before every real table column. + // It is recalculated after filtering, sorting and partial server reloads and + // therefore never represents a database identifier. + if (!headerRow.querySelector('th[data-row-number-column="1"]')) { + const numberHeader = document.createElement('th'); + numberHeader.className = 'table-row-number-header'; + numberHeader.dataset.rowNumberColumn = '1'; + numberHeader.dataset.noSort = '1'; + numberHeader.dataset.noFilter = '1'; + numberHeader.textContent = '#'; + numberHeader.title = document.body.dataset.rowNumberLabel || 'Row number'; + headerRow.insertBefore(numberHeader, headerRow.firstChild); + } + + const numberHeader = headerRow.querySelector('th[data-row-number-column="1"]'); + const rowNumberStoragePart = table.dataset.storageKey || table.id || window.location.pathname; + const rowNumberWidthKey = `assetmanager:table:${rowNumberStoragePart}:row-number-width`; + const defaultRowNumberWidth = 64; + const minimumRowNumberWidth = 48; + const maximumRowNumberWidth = 180; + + function readStoredRowNumberWidth() { + try { + const stored = Number.parseInt(window.localStorage.getItem(rowNumberWidthKey) || '', 10); + if (Number.isFinite(stored)) { + return Math.min(maximumRowNumberWidth, Math.max(minimumRowNumberWidth, stored)); + } + } catch (_) {} + return defaultRowNumberWidth; + } + + function applyRowNumberWidth(width) { + const normalized = Math.min(maximumRowNumberWidth, Math.max(minimumRowNumberWidth, Math.round(width))); + table.style.setProperty('--table-row-number-width', `${normalized}px`); + table.dataset.rowNumberWidth = String(normalized); + return normalized; + } + + function installRowNumberResizer() { + if (!numberHeader || numberHeader.querySelector('.table-row-number-resizer')) return; + const handle = document.createElement('span'); + handle.className = 'table-row-number-resizer'; + handle.setAttribute('role', 'separator'); + handle.setAttribute('aria-orientation', 'vertical'); + handle.tabIndex = 0; + handle.title = document.body.dataset.rowNumberResizeLabel || 'Drag to change column width'; + numberHeader.appendChild(handle); + + const saveWidth = width => { + const normalized = applyRowNumberWidth(width); + try { window.localStorage.setItem(rowNumberWidthKey, String(normalized)); } catch (_) {} + }; + + handle.addEventListener('pointerdown', event => { + event.preventDefault(); + event.stopPropagation(); + const startX = event.clientX; + const startWidth = Number.parseInt(table.dataset.rowNumberWidth || '', 10) || defaultRowNumberWidth; + handle.setPointerCapture?.(event.pointerId); + document.documentElement.classList.add('table-column-resizing'); + + const move = moveEvent => applyRowNumberWidth(startWidth + moveEvent.clientX - startX); + const finish = finishEvent => { + saveWidth(Number.parseInt(table.dataset.rowNumberWidth || '', 10) || startWidth); + handle.releasePointerCapture?.(finishEvent.pointerId); + document.documentElement.classList.remove('table-column-resizing'); + handle.removeEventListener('pointermove', move); + handle.removeEventListener('pointerup', finish); + handle.removeEventListener('pointercancel', finish); + }; + handle.addEventListener('pointermove', move); + handle.addEventListener('pointerup', finish); + handle.addEventListener('pointercancel', finish); + }); + + handle.addEventListener('keydown', event => { + if (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight') return; + event.preventDefault(); + const current = Number.parseInt(table.dataset.rowNumberWidth || '', 10) || defaultRowNumberWidth; + saveWidth(current + (event.key === 'ArrowRight' ? 8 : -8)); + }); + } + + applyRowNumberWidth(readStoredRowNumberWidth()); + installRowNumberResizer(); + + + // Every visible data column can be resized independently. Widths are + // stored per table and per field/index so they survive navigation, + // reloads and container updates. The visual row-number column keeps its + // dedicated implementation above. + const columnWidthStoragePrefix = `assetmanager:table:${rowNumberStoragePart}:column-width:`; + const minimumColumnWidth = 56; + const maximumColumnWidth = 1200; + + function columnStorageId(header, columnIndex) { + const field = header?.dataset?.field; + if (field) return field; + return `index-${columnIndex}`; + } + + function normalizeColumnWidth(width) { + return Math.min(maximumColumnWidth, Math.max(minimumColumnWidth, Math.round(width))); + } + + function applyColumnWidth(columnIndex, width) { + const normalized = normalizeColumnWidth(width); + const rows = [ + ...thead.rows, + ...tbody.rows + ]; + rows.forEach(row => { + const cell = row.cells?.[columnIndex]; + if (!cell || cell.dataset.rowNumberColumn === '1') return; + cell.style.width = `${normalized}px`; + cell.style.minWidth = `${normalized}px`; + cell.style.maxWidth = `${normalized}px`; + }); + return normalized; + } + + function readStoredColumnWidth(header, columnIndex) { + try { + const key = `${columnWidthStoragePrefix}${columnStorageId(header, columnIndex)}`; + const stored = Number.parseInt(window.localStorage.getItem(key) || '', 10); + if (Number.isFinite(stored)) return normalizeColumnWidth(stored); + } catch (_) {} + return null; + } + + function saveColumnWidth(header, columnIndex, width) { + const normalized = applyColumnWidth(columnIndex, width); + try { + const key = `${columnWidthStoragePrefix}${columnStorageId(header, columnIndex)}`; + window.localStorage.setItem(key, String(normalized)); + } catch (_) {} + return normalized; + } + + function applyStoredColumnWidths() { + [...headerRow.cells].forEach((header, columnIndex) => { + if (header.dataset.rowNumberColumn === '1') return; + const stored = readStoredColumnWidth(header, columnIndex); + if (stored !== null) applyColumnWidth(columnIndex, stored); + }); + } + + function installColumnResizers() { + [...headerRow.cells].forEach((header, columnIndex) => { + if (header.dataset.rowNumberColumn === '1') return; + if (header.dataset.noResize === '1') return; + if (header.querySelector(':scope > .table-column-resizer')) return; + + header.classList.add('table-resizable-column-header'); + const handle = document.createElement('span'); + handle.className = 'table-column-resizer'; + handle.setAttribute('role', 'separator'); + handle.setAttribute('aria-orientation', 'vertical'); + handle.tabIndex = 0; + handle.title = document.body.dataset.columnResizeLabel || 'Drag to change the column width'; + header.appendChild(handle); + + handle.addEventListener('click', event => { + event.preventDefault(); + event.stopPropagation(); + }); + + handle.addEventListener('pointerdown', event => { + event.preventDefault(); + event.stopPropagation(); + const startX = event.clientX; + const measured = header.getBoundingClientRect().width; + const startWidth = measured > 0 ? measured : minimumColumnWidth; + handle.setPointerCapture?.(event.pointerId); + document.documentElement.classList.add('table-column-resizing'); + + const move = moveEvent => { + applyColumnWidth(columnIndex, startWidth + moveEvent.clientX - startX); + }; + const finish = finishEvent => { + const current = header.getBoundingClientRect().width || startWidth; + saveColumnWidth(header, columnIndex, current); + handle.releasePointerCapture?.(finishEvent.pointerId); + document.documentElement.classList.remove('table-column-resizing'); + handle.removeEventListener('pointermove', move); + handle.removeEventListener('pointerup', finish); + handle.removeEventListener('pointercancel', finish); + }; + handle.addEventListener('pointermove', move); + handle.addEventListener('pointerup', finish); + handle.addEventListener('pointercancel', finish); + }); + + handle.addEventListener('keydown', event => { + if (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight') return; + event.preventDefault(); + event.stopPropagation(); + const current = header.getBoundingClientRect().width || minimumColumnWidth; + saveColumnWidth(header, columnIndex, current + (event.key === 'ArrowRight' ? 12 : -12)); + }); + }); + } + + function ensureRowNumberCells() { + [...tbody.rows].forEach(row => { + if (row.querySelector('td[data-row-number-column="1"]')) return; + const cell = document.createElement('td'); + cell.className = 'table-row-number-cell'; + cell.dataset.rowNumberColumn = '1'; + cell.setAttribute('aria-hidden', 'true'); + row.insertBefore(cell, row.firstChild); + }); + } + ensureRowNumberCells(); + const isAssetTable = table.id === 'asset-table'; const usesServerFilters = table.dataset.serverFilters === '1'; let serverFilterTimer = null; @@ -173,15 +388,19 @@ // real data-field keys on its movable columns; selection and action // columns must remain without a field key. if (!isAssetTable) { - [...headerRow.cells].forEach((header, index) => { + let syntheticIndex = 0; + [...headerRow.cells].forEach(header => { + if (header.dataset.rowNumberColumn === '1') return; if (!header.dataset.field) { - header.dataset.field = `__col_${index}`; + header.dataset.field = `__col_${syntheticIndex}`; header.dataset.syntheticField = '1'; } + syntheticIndex += 1; }); [...tbody.rows].forEach(row => { [...row.cells].forEach((cell, index) => { + if (cell.dataset.rowNumberColumn === '1') return; const header = headerRow.cells[index]; if (header && !cell.dataset.field) { cell.dataset.field = header.dataset.field; @@ -291,10 +510,12 @@ } function rebuildMissingCellKeys() { + ensureRowNumberCells(); if (isAssetTable) return; [...tbody.rows].forEach(row => { [...row.cells].forEach((cell, index) => { + if (cell.dataset.rowNumberColumn === '1') return; const header = headerRow.cells[index]; if (header && !cell.dataset.field) { cell.dataset.field = header.dataset.field; @@ -405,6 +626,20 @@ else serverFilterTimer = window.setTimeout(navigate, 700); } + function updateRowNumbers() { + let visibleIndex = 0; + [...tbody.rows].forEach(row => { + const cell = row.querySelector('td[data-row-number-column="1"]'); + if (!cell) return; + if (row.hidden) { + cell.textContent = ''; + return; + } + visibleIndex += 1; + cell.textContent = String(visibleIndex); + }); + } + function applyFilters() { rebuildMissingCellKeys(); @@ -419,6 +654,7 @@ const serverTotal = Number.parseInt(table.dataset.serverFilterTotal || '', 10); const total = Number.isFinite(serverTotal) ? serverTotal : tbody.rows.length; updateFilterPresentation(active, tbody.rows.length, total); + updateRowNumbers(); window.dispatchEvent(new CustomEvent('asset-table-filter-changed', { detail: { table, @@ -447,6 +683,7 @@ }); updateFilterPresentation(active, visibleRows, tbody.rows.length); + updateRowNumbers(); window.dispatchEvent(new CustomEvent('asset-table-filter-changed', { detail: { @@ -458,6 +695,43 @@ })); } + function applySort(field, direction, options = {}) { + const header = [...headerRow.cells].find(item => item.dataset.field === field); + if (!header || header.dataset.noSort) return false; + + const columnIndex = header.cellIndex; + [...headerRow.cells].forEach(item => { + delete item.dataset.sortDirection; + item.classList.remove('sort-asc', 'sort-desc'); + }); + + const normalizedDirection = direction === 'desc' ? 'desc' : 'asc'; + header.dataset.sortDirection = normalizedDirection; + header.classList.add(normalizedDirection === 'asc' ? 'sort-asc' : 'sort-desc'); + + const rows = [...tbody.rows]; + rows.sort((left, right) => { + const definition = { field, columnIndex }; + const result = compareValues( + textValue(cellForDefinition(left, definition)), + textValue(cellForDefinition(right, definition)) + ); + return normalizedDirection === 'asc' ? result : -result; + }); + + const fragment = document.createDocumentFragment(); + rows.forEach(row => fragment.appendChild(row)); + tbody.appendChild(fragment); + applyFilters(); + + if (!options.silent) { + window.dispatchEvent(new CustomEvent('asset-table-sort-changed', { + detail: { table, field, direction: normalizedDirection } + })); + } + return true; + } + [...headerRow.cells].forEach((header, columnIndex) => { const field = header.dataset.field || ''; const serverName = header.dataset.serverFilterName || ''; @@ -468,37 +742,18 @@ header.addEventListener('click', event => { if (event.target.closest('input,button,a,select,textarea,label')) return; - const direction = header.dataset.sortDirection === 'asc' ? 'desc' : 'asc'; - - [...headerRow.cells].forEach(item => { - delete item.dataset.sortDirection; - item.classList.remove('sort-asc', 'sort-desc'); - }); - - header.dataset.sortDirection = direction; - header.classList.add(direction === 'asc' ? 'sort-asc' : 'sort-desc'); - - const rows = [...tbody.rows]; - rows.sort((left, right) => { - const definition = { field, columnIndex }; - const result = compareValues( - textValue(cellForDefinition(left, definition)), - textValue(cellForDefinition(right, definition)) - ); - return direction === 'asc' ? result : -result; - }); - - const fragment = document.createDocumentFragment(); - rows.forEach(row => fragment.appendChild(row)); - tbody.appendChild(fragment); - applyFilters(); + applySort(field, direction); }); } const filterCell = document.createElement('th'); if (field) filterCell.dataset.field = field; filterCell.dataset.columnIndex = String(columnIndex); + if (header.dataset.rowNumberColumn === '1') { + filterCell.dataset.rowNumberColumn = '1'; + filterCell.className = 'table-row-number-filter'; + } if (!header.dataset.noFilter && (!usesServerFilters || serverName)) { const input = document.createElement('input'); @@ -541,6 +796,8 @@ }); thead.appendChild(filterRow); + installColumnResizers(); + applyStoredColumnWidths(); // Editable cells are always read live, so changed values immediately // participate in filtering without a stale cache. @@ -553,7 +810,10 @@ }); table.assetApplyFilters = applyFilters; + table.assetApplySort = (field, direction, options = {}) => applySort(field, direction, options); + table.assetUpdateRowNumbers = updateRowNumbers; table.assetRebuildFilterCache = rebuildMissingCellKeys; + table.assetApplyStoredColumnWidths = applyStoredColumnWidths; table.dataset.tableToolsReady = '1'; ensureFilterStatus(); @@ -586,7 +846,14 @@ const table = event.detail?.table; if (table?.assetRebuildFilterCache) { table.assetRebuildFilterCache(); - table.assetApplyFilters?.(); + const sortedHeader = table.tHead?.rows?.[0]?.querySelector('[data-sort-direction]'); + if (sortedHeader?.dataset.field && table.assetApplySort) { + table.assetApplySort(sortedHeader.dataset.field, sortedHeader.dataset.sortDirection, {silent: true}); + } else { + table.assetApplyFilters?.(); + } + table.assetUpdateRowNumbers?.(); + table.assetApplyStoredColumnWidths?.(); } }); })(); diff --git a/app/templates/assets.html b/app/templates/assets.html index c25e443..c01fc12 100644 --- a/app/templates/assets.html +++ b/app/templates/assets.html @@ -36,6 +36,24 @@
{{ t('common.all', 'Alle') }}{% for c in categories %}{{ c.name }}{% endfor %}
+
+ + {{ t('assets.active_selection', 'Aktive Auswahl') }} + + {% if selected %} + + {{ t('assets.category_filter', 'Kategorie: {category}').format(category=selected.name) }} + + + {% endif %} + + +
{% if job_filter %}
{{ t('assets.job_filter.active', 'Aktiver Jobfilter') }}: @@ -193,7 +211,7 @@ {% endif %} - +

{{ t('duplicates.search_title') }}

diff --git a/app/templates/base.html b/app/templates/base.html index 1486e72..20f7626 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -14,7 +14,7 @@ --am-warning:{{ colors.get('warning_color', '#d97706') }}; --am-danger:{{ colors.get('danger_color', '#b42318') }}; } -{{ title or general.get('title', 'AssetManager') }}{% set favicon_path = general.get('favicon') or general.get('logo') %}{% if favicon_path %}{% endif %} +{{ title or general.get('title', 'AssetManager') }}{% set favicon_path = general.get('favicon') or general.get('logo') %}{% if favicon_path %}{% endif %}
{% if general.get('logo') %}Logo{% endif %}{{ general.get('title', 'AssetManager') }}{% if general.get('company_name') %}– {{ general.get('company_name') }}{% endif %} @@ -33,6 +33,12 @@
🌐 {{ current_language|upper }} ▾
{% for language in available_languages() %}{% endfor %}
👤 {{ session_user.display_name if session_user else t('app.guest') }} ▾
{% if session_user %}{{ t('app.profile') }}{{ t('app.logout') }}{% else %}{% if auth.get('mode', 'none') == 'none' %}Authentication disabled{% endif %}{{ t('app.login') }}{% endif %}
+{% if auth.get('mode', 'none') == 'none' %} +
+ ⚠ {{ t('auth.disabled_banner_title') }} + {{ t('auth.disabled_banner_text') }} +
+{% endif %}
@@ -99,7 +105,7 @@ {% endif %}
{% block content %}{% endblock %}
-