import os import asyncio import shutil import uuid import threading import tempfile import subprocess import shutil import io import json import logging import traceback import hashlib import hmac import secrets import subprocess import base64 import re import unicodedata import socket import ssl import time import urllib.error import urllib.request from typing import Any from concurrent.futures import ThreadPoolExecutor from urllib.parse import quote import urllib.parse from datetime import datetime, date, timedelta from pathlib import Path from fastapi import Body, Depends, FastAPI, File, Form, HTTPException, Request, UploadFile from fastapi.responses import RedirectResponse, JSONResponse, StreamingResponse, Response, FileResponse, HTMLResponse from starlette.exceptions import HTTPException as StarletteHTTPException from starlette.middleware.sessions import SessionMiddleware from starlette.requests import ClientDisconnect from fastapi.staticfiles import StaticFiles from fastapi.templating import Jinja2Templates from sqlalchemy import and_, case, func, or_, false, distinct, cast, String from sqlalchemy.orm import Session, aliased, joinedload, selectinload, load_only, contains_eager from sqlalchemy.exc import IntegrityError from jinja2 import pass_context from .database import Base, engine, get_db, SessionLocal from .fields import ASSET_FIELDS, COMPUTER_FIELDS, DEFAULT_FIELDS, FIELD_DATA_TYPES, SYSTEM_READONLY_FIELDS from .models import Asset, AssetHistory, Category, CategoryField, StatusOption, SyncRun, User, ChartDefinition, Language, Translation, FieldDefinition, AssetFieldValue, MeshFieldMapping, SoftwarePackage, SoftwarePackageParameter, SoftwareJob, InstalledSoftware, JobEvent, JobDefinition, JobDefinitionRevision, JobDefinitionParameter, JobDefinitionAssetParameter, AssetJobState from .config import load_config, public_config, save_config from .meshcentral import synchronize, fetch_device_summaries_for_linking from .migrations import apply_lightweight_migrations from .i18n import seed_i18n, translate, dictionary as translation_dictionary, languages as i18n_languages, clear_translation_cache from .software_control import token_hash, detect_platform, execute_job, build_registry_user_script from .job_state import backfill_asset_job_states, filter_state_key, sync_asset_job_state from .presence import mesh_presence_loop from .version import APP_VERSION from .backup import (BACKUP_DIR, BACKUP_INTERVAL_HOURS, BACKUP_RETENTION_DAYS, backup_path, create_backup, delete_backup, list_backups, restore_backup, store_uploaded_backup, automatic_backup_loop, system_storage_information) 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) SOFTWARE_CALLBACK_DEBUG_LOG = APP_LOG_DIR / "software-callback-debug.log" _SOFTWARE_LOG_LOCK = threading.Lock() def _optional_form_int(value: Any, *, field_name: str, minimum: int | None = None, maximum: int | None = None) -> int | None: """Convert an optional HTML form value to int. Browsers submit an empty numeric input as an empty string. FastAPI/Pydantic cannot coerce that value directly to ``int | None``, so optional numeric form fields are normalized here before validation and persistence. """ if value is None: return None text = str(value).strip() if not text: return None try: parsed = int(text) except (TypeError, ValueError) as exc: raise HTTPException(400, f"Invalid integer value for {field_name}.") from exc if minimum is not None: parsed = max(minimum, parsed) if maximum is not None: parsed = min(maximum, parsed) return parsed def _record_job_event(db: Session, job: SoftwareJob, event_type: str, status: str | None = None, message: str | None = None, created_by: str | None = None) -> JobEvent: event = JobEvent( job_id=job.id, attempt=int(job.attempt_count or 0), event_type=event_type, status=status if status is not None else job.status, message=(message or "")[:4000] or None, created_by=created_by, ) db.add(event) return event _SOFTWARE_EXCLUSION_PLATFORMS = {"all", "windows", "linux", "macos", "unknown"} def _normalize_software_exclusion_rules(value: Any) -> list[dict[str, str]]: """Return safe, deduplicated substring rules from configuration or forms.""" if not isinstance(value, list): return [] normalized: list[dict[str, str]] = [] seen: set[tuple[str, str]] = set() for raw_rule in value[:500]: if not isinstance(raw_rule, dict): continue platform = str(raw_rule.get("platform") or "all").strip().lower() if platform not in _SOFTWARE_EXCLUSION_PLATFORMS: platform = "all" name_contains = re.sub( r"\s+", " ", str(raw_rule.get("name_contains") or raw_rule.get("name") or "").strip(), )[:300] if not name_contains: continue identity = (platform, name_contains.casefold()) if identity in seen: continue seen.add(identity) normalized.append({"platform": platform, "name_contains": name_contains}) return normalized def _software_name_is_excluded( name: str, platform: str, rules: list[dict[str, str]], ) -> bool: """Match a software name against case-insensitive platform substring rules.""" name_key = str(name or "").casefold() platform_key = str(platform or "unknown").strip().lower() or "unknown" for rule in rules: rule_platform = rule.get("platform", "all") if rule_platform not in {"all", platform_key}: continue needle = str(rule.get("name_contains") or "").casefold() if needle and needle in name_key: return True return False def _software_settings() -> dict[str, Any]: config = load_config() software = dict(config.get("software", {})) # Compatibility with the callback field that existed in the general section. if not software.get("callback_base_url"): software["callback_base_url"] = str(config.get("general", {}).get("callback_base_url", "") or "") software["inventory_exclusion_rules"] = _normalize_software_exclusion_rules( software.get("inventory_exclusion_rules", []) ) return software def _software_debug_log(message: str, *, force: bool = False) -> None: settings = _software_settings() if not force and not bool(settings.get("debug_logging", True)): return line = f"{datetime.utcnow().isoformat(timespec='milliseconds')}Z | {message.rstrip()}\n" try: with _SOFTWARE_LOG_LOCK: SOFTWARE_CALLBACK_DEBUG_LOG.parent.mkdir(parents=True, exist_ok=True) with SOFTWARE_CALLBACK_DEBUG_LOG.open("a", encoding="utf-8") as handle: handle.write(line) max_lines = max(100, min(int(settings.get("debug_log_max_lines", 1000) or 1000), 10000)) lines = SOFTWARE_CALLBACK_DEBUG_LOG.read_text(encoding="utf-8", errors="replace").splitlines() if len(lines) > max_lines: SOFTWARE_CALLBACK_DEBUG_LOG.write_text("\n".join(lines[-max_lines:]) + "\n", encoding="utf-8") except Exception: logger.exception("Software callback debug log could not be written") def _software_debug_tail(max_lines: int = 400) -> str: try: lines = SOFTWARE_CALLBACK_DEBUG_LOG.read_text(encoding="utf-8", errors="replace").splitlines() return "\n".join(lines[-max(20, min(max_lines, 2000)):]) except OSError: return "" 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.mount("/static", StaticFiles(directory=BASE_DIR / "static"), name="static") templates = Jinja2Templates(directory=BASE_DIR / "templates") templates.env.globals["application_config"] = load_config def application_version() -> str: """Read VERSION when available and otherwise use the embedded build version.""" candidates = [ BASE_DIR.parent / "VERSION", BASE_DIR / "VERSION", ] for candidate in candidates: try: value = candidate.read_text(encoding="utf-8").strip() if value: return value except OSError: continue return APP_VERSION templates.env.globals["application_version"] = application_version def application_info() -> dict[str, str]: """Read persistent application metadata. Primary file: /app/config/APPINFO.json (or APPINFO_PATH). A legacy APPINFO.json is migrated once when possible. """ defaults = { "name": "AssetManager", "author": "", "organization": "", "contact": "", "website": "", "repository": "", "license": "", "copyright": "", "description": "", } persistent = Path(os.getenv("APPINFO_PATH", "/app/config/APPINFO.json")) legacy_candidates = [ Path("/app/data/config/APPINFO.json"), # path used by v0.3.14.2 BASE_DIR.parent / "APPINFO.json", # /app/APPINFO.json BASE_DIR / "APPINFO.json", Path("/APPINFO.json"), # legacy file in container root ] if not persistent.exists(): for legacy in legacy_candidates: try: if legacy.exists(): persistent.parent.mkdir(parents=True, exist_ok=True) shutil.copy2(legacy, persistent) break except OSError: continue candidates = [persistent, *legacy_candidates] for candidate in candidates: try: payload = json.loads(candidate.read_text(encoding="utf-8")) if isinstance(payload, dict): return {**defaults, **{str(k): str(v or "") for k, v in payload.items()}} except (OSError, ValueError, TypeError): continue return defaults templates.env.globals["application_info"] = application_info def manufacturer_logo_path() -> str: """Return the first fixed manufacturer logo found in the static image folder. The file is intentionally not configurable through the GUI. Administrators can replace it only in the application files using the fixed base name ``manufacturer-logo`` and one of the supported image extensions. """ image_dir = BASE_DIR / "static" / "img" for extension in (".svg", ".png", ".jpg", ".jpeg", ".webp"): candidate = image_dir / f"manufacturer-logo{extension}" try: if candidate.is_file(): return f"/static/img/{candidate.name}" except OSError: continue return "" templates.env.globals["manufacturer_logo_path"] = manufacturer_logo_path @pass_context def _template_translate(context, key: str, default: str | None = None, **values): """Translate template text without opening a database session for every cell. The previous implementation created and closed a SQLAlchemy session for every ``t(...)`` call. A job table with hundreds of rows therefore caused thousands of avoidable pool checkouts. Translation dictionaries are already cached by the i18n module; keep the two dictionaries on the request and format locally. """ request = context.get("request") language_code = "en" if request: user = request.session.get("user") or {} language_code = user.get("language_code") or request.session.get("language_code") or load_config().get("general", {}).get("default_language", "en") language_code = str(language_code or "en").lower() cached = getattr(request.state, "template_translation_dictionaries", None) if not cached or cached[0] != language_code: db = SessionLocal() try: own = translation_dictionary(db, language_code) english = translation_dictionary(db, "en") finally: db.close() cached = (language_code, own, english) request.state.template_translation_dictionaries = cached _, own, english = cached text_value = own.get(key) or english.get(key) or default or key try: return text_value.format(**values) except (KeyError, ValueError): return text_value db = SessionLocal() try: return translate(db, language_code, key, default, **values) finally: db.close() def _template_languages(): db = SessionLocal() try: return [{"code": item.code, "name": item.name, "native_name": item.native_name} for item in i18n_languages(db)] finally: db.close() templates.env.globals["t"] = _template_translate templates.env.globals["available_languages"] = _template_languages def _utc_iso(value): """Serialize project timestamps as explicit UTC for browser conversion.""" if value is None: return "" if isinstance(value, datetime): if value.tzinfo is None: return value.isoformat(timespec="seconds") + "Z" return value.astimezone(timezone.utc).isoformat(timespec="seconds").replace("+00:00", "Z") return str(value) templates.env.globals["utc_iso"] = _utc_iso def _translate_request(request: Request, key: str, default: str | None = None, **values) -> str: user = _session_user(request) or {} language_code = user.get("language_code") or request.session.get("language_code") or load_config().get("general", {}).get("default_language", "en") db = SessionLocal() try: return translate(db, language_code, key, default, **values) finally: db.close() @app.get("/custom.css", include_in_schema=False) def custom_css(): css = str(load_config().get("general", {}).get("custom_css", "") or "") return Response(content=css, media_type="text/css", headers={"Cache-Control": "no-store, max-age=0"}) SYNC_JOBS: dict[str, dict] = {} SYNC_JOBS_LOCK = threading.Lock() BACKUP_STOP_EVENT = threading.Event() BACKUP_THREAD: threading.Thread | None = None PRESENCE_STOP_EVENT = threading.Event() PRESENCE_THREAD: threading.Thread | None = None SOFTWARE_TIMEOUT_STOP_EVENT = threading.Event() SOFTWARE_TIMEOUT_THREAD: threading.Thread | None = None SOFTWARE_JOB_TERMINAL_STATES = {"success", "failed", "partial", "timeout", "cancelled"} SOFTWARE_JOB_AUTOMATIC_RETRY_STATES = {"failed", "partial", "timeout", "sent"} def _configured_callback_worker_count() -> int: try: value = int(_software_settings().get("callback_worker_count", 4) or 4) except (TypeError, ValueError): value = 4 return max(1, min(value, 8)) SOFTWARE_CALLBACK_WORKER_COUNT = _configured_callback_worker_count() SOFTWARE_CALLBACK_EXECUTOR = ThreadPoolExecutor( max_workers=SOFTWARE_CALLBACK_WORKER_COUNT, thread_name_prefix="assetmanager-callback", ) def _expire_stale_software_jobs(db: Session, job_id: int | None = None) -> int: """Mark callback jobs as timed out independently of page views.""" query = db.query(SoftwareJob).filter( SoftwareJob.callback_completed_at.is_(None), SoftwareJob.callback_expires_at < datetime.utcnow(), SoftwareJob.status.notin_(SOFTWARE_JOB_TERMINAL_STATES), ) if job_id is not None: query = query.filter(SoftwareJob.id == job_id) rows = query.all() if not rows: return 0 language_code = str(load_config().get("general", {}).get("default_language", "de") or "de") timeout_message = translate(db, language_code, "software.status.timeout", "Timed out") now = datetime.utcnow() for job in rows: job.status = "timeout" job.message = timeout_message job.finished_at = now _record_job_event(db, job, "timeout", "timeout", timeout_message) sync_asset_job_state(db, job, status_at=now) db.commit() return len(rows) def _automatic_retry_runtime_settings() -> tuple[bool, int, int, int]: settings = _software_settings() enabled = bool(settings.get("automatic_retry_enabled", True)) try: max_age_hours = int(settings.get("automatic_retry_max_age_hours", 12) or 12) except (TypeError, ValueError): max_age_hours = 12 try: retry_interval_seconds = int(settings.get("automatic_retry_interval_seconds", 60) or 60) except (TypeError, ValueError): retry_interval_seconds = 60 try: max_retries = int(settings.get("automatic_retry_max_retries", 3) or 3) except (TypeError, ValueError): max_retries = 3 return ( enabled, max(1, min(max_age_hours, 168)), max(10, min(retry_interval_seconds, 86400)), max(1, min(max_retries, 10)), ) def _job_callback_base_for_background(job: SoftwareJob) -> str: parameters = job.parameters if isinstance(job.parameters, dict) else {} stored = str(parameters.get("_callback_base_url") or "").strip().rstrip("/") if stored: return stored configured = str(_software_settings().get("callback_base_url", "") or "").strip().rstrip("/") if configured: return configured # Compatibility for jobs created before the callback base was stored in # the job snapshot. The resolved script contains the complete callback URL. match = re.search( r"(https?://[^\s'\"<>]+?)/api/software-jobs/\d+/callback(?:\?[^\s'\"<>]*)?", str(job.resolved_script or ""), flags=re.IGNORECASE, ) return match.group(1).rstrip("/") if match else "" def _queue_automatic_job_retries(db: Session) -> list[tuple[int, str, str]]: enabled, max_age_hours, retry_interval_seconds, max_retries = _automatic_retry_runtime_settings() if not enabled: return [] now = datetime.utcnow() oldest_created_at = now - timedelta(hours=max_age_hours) retry_ready_at = now - timedelta(seconds=retry_interval_seconds) candidates = ( db.query(SoftwareJob) .options(joinedload(SoftwareJob.asset), joinedload(SoftwareJob.package)) .filter( SoftwareJob.status.in_(SOFTWARE_JOB_AUTOMATIC_RETRY_STATES), SoftwareJob.created_at >= oldest_created_at, func.coalesce(SoftwareJob.finished_at, SoftwareJob.sent_at, SoftwareJob.created_at) <= retry_ready_at, ) .order_by(func.coalesce(SoftwareJob.finished_at, SoftwareJob.sent_at, SoftwareJob.created_at), SoftwareJob.id) .limit(100) .all() ) if not candidates: return [] candidate_ids = [job.id for job in candidates] retry_counts = dict( db.query(JobEvent.job_id, func.count(JobEvent.id)) .filter( JobEvent.job_id.in_(candidate_ids), JobEvent.event_type == "automatic_retry_requested", ) .group_by(JobEvent.job_id) .all() ) queued: list[tuple[int, str, str]] = [] for job in candidates: if int(retry_counts.get(job.id, 0) or 0) >= max_retries: continue if not job.asset or not job.asset.mesh_node_id: continue callback_base = _job_callback_base_for_background(job) if not callback_base: logger.warning( "Automatic retry skipped for software job %s because no callback base URL could be determined", job.id, ) continue token = _prepare_existing_job_restart(db, None, job, automatic=True) queued.append((job.id, token, callback_base)) if queued: db.commit() return queued def _software_job_timeout_loop(stop_event: threading.Event) -> None: while not stop_event.is_set(): queued: list[tuple[int, str, str]] = [] db = SessionLocal() try: expired = _expire_stale_software_jobs(db) if expired: logger.info("%s software jobs automatically marked as timed out", expired) queued = _queue_automatic_job_retries(db) except Exception: db.rollback() logger.exception("Software job timeout or retry check failed") finally: db.close() for job_id, token, callback_base in queued: threading.Thread( target=execute_job, args=(job_id, token, callback_base), daemon=True, name=f"assetmanager-auto-retry-{job_id}", ).start() if queued: logger.info("%s software jobs automatically queued again", len(queued)) _, _, retry_interval_seconds, _ = _automatic_retry_runtime_settings() stop_event.wait(min(30, max(5, retry_interval_seconds))) @app.get('/job-definitions', response_class=HTMLResponse) def job_definitions_page(request: Request, db: Session = Depends(get_db)): _require_admin(request) search_text = str(request.query_params.get('q') or '').strip() active_only = str(request.query_params.get('active_only', '1')).lower() not in {'0', 'false', 'off', 'no'} query = db.query(JobDefinition) if active_only: query = query.filter(JobDefinition.enabled.is_(True)) if search_text: pattern = f"%{search_text}%" query = query.filter(or_(JobDefinition.name.ilike(pattern), JobDefinition.description.ilike(pattern))) rows = query.order_by(JobDefinition.enabled.desc(), JobDefinition.name.asc()).all() referenced_definition_ids = set() for job in db.query(SoftwareJob).filter(SoftwareJob.job_type == 'job_definition').all(): try: referenced_definition_ids.add(int((job.parameters or {}).get('definition_id'))) except (TypeError, ValueError, AttributeError): continue definition_rows = [ { 'item': row, 'can_delete': (not row.is_system and row.id not in referenced_definition_ids), 'delete_reason': ( 'system' if row.is_system else 'referenced' if row.id in referenced_definition_ids else None ), } for row in rows ] return templates.TemplateResponse( 'job_definitions.html', { 'request': request, 'definition_rows': definition_rows, 'active_only': active_only, 'search_text': search_text, }, ) @app.get('/job-definitions/new', response_class=HTMLResponse) def job_definition_new(request: Request): _require_admin(request) return templates.TemplateResponse('job_definition_form.html', {'request':request,'definition':None,'test_result':None,'script_preview':None,'preview_values':None}) @app.get('/job-definitions/{definition_id}/edit', response_class=HTMLResponse) def job_definition_edit(definition_id:int, request:Request, db:Session=Depends(get_db)): _require_admin(request); row=db.get(JobDefinition,definition_id) if not row: raise HTTPException(404) return templates.TemplateResponse('job_definition_form.html', {'request':request,'definition':row,'test_result':None,'script_preview':None,'preview_values':None}) def _parse_registry_entries_json(raw: str) -> list[dict]: try: items = json.loads(raw or "[]") except (TypeError, ValueError): raise HTTPException(400, "Invalid registry entry definition.") allowed_actions={"set","delete_value","delete_key"} allowed_hives={"HKLM","HKCU","HKCR","HKU","HKCC"} allowed_types={"REG_SZ","REG_EXPAND_SZ","REG_MULTI_SZ","REG_DWORD","REG_QWORD","REG_BINARY"} result=[] for item in items if isinstance(items,list) else []: if not isinstance(item,dict): continue raw_action=str(item.get("action") or "set") # Compatibility for the short-lived 0.5.4.1 permission-only action. legacy_permission_action = raw_action == "set_permissions" action = "set" if legacy_permission_action else raw_action hive=str(item.get("hive") or "HKLM").upper() path=str(item.get("path") or "").strip().strip("\\") name=str(item.get("name") or "").strip() value_type=str(item.get("value_type") or "REG_SZ").upper() if action not in allowed_actions or hive not in allowed_hives or not path: continue if value_type not in allowed_types: value_type="REG_SZ" result.append({ "action":action,"hive":hive,"path":path,"name":name, "value_type":value_type,"value":str(item.get("value") or ""), "set_value":False if legacy_permission_action else bool(item.get("set_value", True)), "only_if_missing":bool(item.get("only_if_missing")), "permissions_enabled":True if legacy_permission_action else bool(item.get("permissions_enabled")), "principal":str(item.get("principal") or "S-1-5-32-545").strip(), "rights":str(item.get("rights") or "FullControl").strip(), "access_type":"Deny" if str(item.get("access_type") or "Allow").lower()=="deny" else "Allow", "inherit_subkeys":bool(item.get("inherit_subkeys", True)), "replace_existing":bool(item.get("replace_existing", True)), }) return result def _apply_job_definition_form(row, request, name, description, platform, interpreter, source_type, source_path, inline_script, arguments, timeout_seconds, success_codes, enabled, job_kind='script', registry_entries_json='[]', registry_backup=None): kind = job_kind if job_kind in {'script','registry'} else 'script' row.name=name.strip();row.description=description.strip() or None;row.job_kind=kind row.platform='windows' if kind=='registry' else platform row.interpreter='powershell' if kind=='registry' else interpreter row.source_type='inline' if kind=='registry' else source_type row.source_path=None if kind=='registry' else (source_path.strip() or None) row.inline_script=None if kind=='registry' else (inline_script if source_type=='inline' else None) row.arguments=None if kind=='registry' else (arguments.strip() or None) row.configuration={"registry_entries":_parse_registry_entries_json(registry_entries_json),"registry_backup":registry_backup=='on'} if kind=='registry' else {} row.timeout_seconds=max(1,min(int(timeout_seconds or 300),86400));row.success_codes=success_codes.strip() or '0';row.enabled=enabled=='on';row.updated_by=(request.session.get('user') or {}).get('username') def _parse_job_parameters_json(raw: str) -> list[dict]: try: items = json.loads(raw or "[]") except (TypeError, ValueError): raise HTTPException(400, "Invalid job parameter definition.") result=[]; seen=set() for index,item in enumerate(items if isinstance(items,list) else []): if not isinstance(item,dict): continue key=re.sub(r"[^A-Za-z0-9_]", "_", str(item.get("parameter_key") or "").strip()).strip("_") if not key or key in seen: continue seen.add(key) result.append({"parameter_key":key,"label":str(item.get("label") or key).strip()[:140],"description":str(item.get("description") or "").strip() or None,"data_type":str(item.get("data_type") or "text") if str(item.get("data_type") or "text") in {"text","number","boolean","path"} else "text","default_value":str(item.get("default_value") or ""),"required":bool(item.get("required")),"sort_order":index}) return result def _sync_job_definition_parameters(db: Session, definition: JobDefinition, items: list[dict]) -> None: existing={x.parameter_key:x for x in definition.parameters} wanted={x["parameter_key"] for x in items} for row in list(definition.parameters): if row.parameter_key not in wanted: db.delete(row) for item in items: row=existing.get(item["parameter_key"]) if row is None: row=JobDefinitionParameter(definition_id=definition.id, parameter_key=item["parameter_key"]); db.add(row) for key,value in item.items(): setattr(row,key,value) db.flush() def _job_effective_parameters(db: Session, definition: JobDefinition, asset: Asset) -> dict[str,str]: parameter_ids=[x.id for x in definition.parameters] overrides={} if parameter_ids: overrides={x.definition_parameter_id:x.value_text for x in db.query(JobDefinitionAssetParameter).filter(JobDefinitionAssetParameter.asset_id==asset.id, JobDefinitionAssetParameter.definition_parameter_id.in_(parameter_ids)).all()} return {x.parameter_key: (overrides[x.id] if x.id in overrides else (x.default_value or "")) for x in definition.parameters} @app.post('/job-definitions') def job_definition_create(request:Request,name:str=Form(...),description:str=Form(''),job_kind:str=Form('script'),platform:str=Form('windows'),interpreter:str=Form('powershell'),source_type:str=Form('inline'),source_path:str=Form(''),inline_script:str=Form(''),arguments:str=Form(''),timeout_seconds:int=Form(300),success_codes:str=Form('0'),enabled:str|None=Form(None),parameters_json:str=Form('[]'),registry_entries_json:str=Form('[]'),registry_backup:str|None=Form(None),db:Session=Depends(get_db)): _require_admin(request); row=JobDefinition(created_by=(request.session.get('user') or {}).get('username')) _apply_job_definition_form(row,request,name,description,platform,interpreter,source_type,source_path,inline_script,arguments,timeout_seconds,success_codes,enabled,job_kind,registry_entries_json,registry_backup);db.add(row);db.flush();_sync_job_definition_parameters(db,row,_parse_job_parameters_json(parameters_json));db.commit();db.refresh(row) db.add(JobDefinitionRevision(definition_id=row.id,revision=row.revision,snapshot=_job_definition_snapshot(row),changed_by=row.updated_by));db.commit() return RedirectResponse('/job-definitions?toast_success='+quote(_translate_request(request,'jobdefs.saved','Job definition saved.')),303) @app.post('/job-definitions/{definition_id}') def job_definition_update(definition_id:int,request:Request,name:str=Form(...),description:str=Form(''),job_kind:str=Form('script'),platform:str=Form('windows'),interpreter:str=Form('powershell'),source_type:str=Form('inline'),source_path:str=Form(''),inline_script:str=Form(''),arguments:str=Form(''),timeout_seconds:int=Form(300),success_codes:str=Form('0'),enabled:str|None=Form(None),parameters_json:str=Form('[]'),registry_entries_json:str=Form('[]'),registry_backup:str|None=Form(None),db:Session=Depends(get_db)): _require_admin(request);row=db.get(JobDefinition,definition_id) if not row: raise HTTPException(404) row.revision=int(row.revision or 0)+1;_apply_job_definition_form(row,request,name,description,platform,interpreter,source_type,source_path,inline_script,arguments,timeout_seconds,success_codes,enabled,job_kind,registry_entries_json,registry_backup);_sync_job_definition_parameters(db,row,_parse_job_parameters_json(parameters_json));db.flush();db.add(JobDefinitionRevision(definition_id=row.id,revision=row.revision,snapshot=_job_definition_snapshot(row),changed_by=row.updated_by));db.commit() return RedirectResponse(f'/job-definitions/{row.id}/edit?toast_success='+quote(_translate_request(request,'jobdefs.saved','Job definition saved.')),303) @app.post('/job-definitions/{definition_id}/delete') def job_definition_delete(definition_id: int, request: Request, db: Session = Depends(get_db)): _require_admin(request) row = db.get(JobDefinition, definition_id) if not row: raise HTTPException(404) if row.is_system: return RedirectResponse('/job-definitions?toast_error=' + quote(_translate_request(request, 'jobdefs.delete.system_forbidden', 'System job definitions cannot be deleted.')), 303) referenced_jobs = [] for job in db.query(SoftwareJob).filter(SoftwareJob.job_type == 'job_definition').all(): try: referenced_id = int((job.parameters or {}).get('definition_id')) except (TypeError, ValueError): continue if referenced_id == row.id: referenced_jobs.append(job.id) if len(referenced_jobs) >= 1: break if referenced_jobs: message = _translate_request( request, 'jobdefs.delete.referenced', 'This job definition is referenced by existing jobs and cannot be deleted. Deactivate it instead.', ) return RedirectResponse('/job-definitions?toast_error=' + quote(message), 303) name = row.name db.delete(row) db.commit() message = _translate_request(request, 'jobdefs.deleted', 'Job definition "{name}" was deleted.').format(name=name) return RedirectResponse('/job-definitions?toast_success=' + quote(message), 303) @app.post('/job-definitions/{definition_id}/test', response_class=HTMLResponse) def job_definition_test(definition_id:int,request:Request,name:str=Form(...),description:str=Form(''),job_kind:str=Form('script'),platform:str=Form('windows'),interpreter:str=Form('powershell'),source_type:str=Form('inline'),source_path:str=Form(''),inline_script:str=Form(''),arguments:str=Form(''),timeout_seconds:int=Form(300),success_codes:str=Form('0'),enabled:str|None=Form(None),parameters_json:str=Form('[]'),registry_entries_json:str=Form('[]'),registry_backup:str|None=Form(None),db:Session=Depends(get_db)): _require_admin(request);row=db.get(JobDefinition,definition_id) if not row: raise HTTPException(404) _apply_job_definition_form(row,request,name,description,platform,interpreter,source_type,source_path,inline_script,arguments,timeout_seconds,success_codes,enabled,job_kind,registry_entries_json,registry_backup) _sync_job_definition_parameters(db,row,_parse_job_parameters_json(parameters_json)) result=_validate_job_definition(row, request) preview='' if row.source_type=='inline': preview_interpreter='powershell' if row.job_kind=='registry' else row.interpreter preview_source=build_registry_user_script(row.configuration) if row.job_kind=='registry' else (row.inline_script or '') wrapper=_generate_job_script(preview_interpreter,'wrapper') start_marker,end_marker=JOB_SCRIPT_MARKERS[preview_interpreter] block=start_marker+'\n\n'+end_marker indent={'powershell':' ','python':' ','bash':'','cmd':''}[preview_interpreter] commands='\n'.join(indent+line if line.strip() else line for line in preview_source.splitlines()) preview=wrapper.replace(block,start_marker+'\n'+commands+'\n'+end_marker,1) values=_job_preview_values() for parameter in row.parameters: values[parameter.parameter_key]=parameter.default_value or '' preview=_replace_job_placeholders(preview,values) db.rollback() return templates.TemplateResponse('job_definition_form.html', {'request':request,'definition':row,'test_result':result,'script_preview':preview,'preview_values':_job_preview_values()}) JOB_SCRIPT_TEMPLATE_DIR = BASE_DIR / "templates" / "jobs" JOB_SCRIPT_TEMPLATE_FILES = { "powershell": ("powershell_wrapper.ps1", "powershell_test.txt"), "python": ("python_wrapper.py.txt", "python_test.txt"), "bash": ("bash_wrapper.sh", "bash_test.txt"), "cmd": ("cmd_wrapper.cmd", "cmd_test.txt"), } JOB_SCRIPT_MARKERS = { "powershell": (" # --- Eigene Befehle hier einfügen ---", " # --- Ende eigene Befehle ---"), "python": (" # --- Eigene Befehle hier einfügen ---", " # --- Ende eigene Befehle ---"), "bash": ("# --- Eigene Befehle hier einfügen ---", "# --- Ende eigene Befehle ---"), "cmd": ("rem --- Eigene Befehle hier einfügen ---", "rem --- Ende eigene Befehle ---"), } def _read_job_script_template(filename: str) -> str: path = (JOB_SCRIPT_TEMPLATE_DIR / filename).resolve() root = JOB_SCRIPT_TEMPLATE_DIR.resolve() if root not in path.parents or not path.is_file(): raise HTTPException(500, "Job script template is missing.") return path.read_text(encoding="utf-8").replace("\r\n", "\n") def _generate_job_script(interpreter: str, action: str) -> str: interpreter = str(interpreter or "").strip().lower() action = str(action or "").strip().lower() if interpreter not in JOB_SCRIPT_TEMPLATE_FILES: raise HTTPException(400, "Unsupported interpreter.") if action not in {"wrapper", "test"}: raise HTTPException(400, "Unsupported generator action.") wrapper_file, test_file = JOB_SCRIPT_TEMPLATE_FILES[interpreter] wrapper = _read_job_script_template(wrapper_file) if action == "wrapper": return wrapper commands = _read_job_script_template(test_file).rstrip("\n") start_marker, end_marker = JOB_SCRIPT_MARKERS[interpreter] marker_block = start_marker + "\n\n" + end_marker replacement = start_marker + "\n" + commands + "\n" + end_marker if marker_block not in wrapper: raise HTTPException(500, "Job script template markers are invalid.") return wrapper.replace(marker_block, replacement, 1) @app.post('/api/job-definitions/script-generator') async def job_definition_script_generator(request: Request): _require_admin(request) try: payload = await request.json() except Exception as exc: raise HTTPException(400, "Invalid JSON request.") from exc interpreter = str((payload or {}).get("interpreter") or "") action = str((payload or {}).get("action") or "") logger.info( "Job script generator requested | user=%s interpreter=%s action=%s", (_session_user(request) or {}).get("username"), interpreter, action, ) script = _generate_job_script(interpreter, action) logger.info( "Job script generator completed | interpreter=%s action=%s bytes=%s", interpreter, action, len(script.encode("utf-8")), ) return JSONResponse({"script": script, "interpreter": interpreter, "action": action}) @app.on_event("startup") def start_backup_scheduler(): global BACKUP_THREAD storage = system_storage_information() backup_status = storage["backups"] logger.info( "Backup directory: %s | exists=%s | writable=%s | mount=%s", backup_status["path"], backup_status["exists"], backup_status["writable"], backup_status["is_mount"], ) if not backup_status["writable"]: logger.error("Backup directory is not writable: %s", backup_status.get("error") or backup_status["path"]) if BACKUP_THREAD is None or not BACKUP_THREAD.is_alive(): BACKUP_STOP_EVENT.clear() BACKUP_THREAD = threading.Thread(target=automatic_backup_loop, args=(BACKUP_STOP_EVENT,), daemon=True, name="assetmanager-backup") BACKUP_THREAD.start() @app.on_event("shutdown") def stop_backup_scheduler(): BACKUP_STOP_EVENT.set() 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 _ensure_local_emergency_admin(db: Session) -> None: """Create or protect the configured local break-glass administrator. The password from the environment is used only when the account is first created or when an existing local account has no password hash. Existing password hashes are never overwritten during container startup. """ username = str(os.getenv("LOCAL_ADMIN_USERNAME", "") or "").strip() password = str(os.getenv("LOCAL_ADMIN_PASSWORD", "") or "") if not username and not password: return if not username or not password: logger.error( "Lokaler Notfall-Administrator wurde nicht eingerichtet: " "LOCAL_ADMIN_USERNAME und LOCAL_ADMIN_PASSWORD müssen gemeinsam gesetzt sein." ) return if len(username) > 120: logger.error("LOCAL_ADMIN_USERNAME ist länger als 120 Zeichen; Konto wurde nicht eingerichtet.") return if len(password) < 12: logger.error("LOCAL_ADMIN_PASSWORD muss mindestens 12 Zeichen lang sein; Konto wurde nicht eingerichtet.") return matches = db.query(User).filter(func.lower(User.username) == username.casefold()).all() if len(matches) > 1: logger.error( "Lokaler Notfall-Administrator konnte nicht eingerichtet werden: " "Der Benutzername %r existiert mehrfach mit unterschiedlicher Schreibweise.", username, ) return user = matches[0] if matches else None if user and user.auth_source != "local": logger.error( "Lokaler Notfall-Administrator konnte nicht eingerichtet werden: " "Benutzername %r gehört bereits zu einem %s-Konto.", username, user.auth_source, ) return created = False password_initialized = False if not user: user = User( username=username, display_name="Lokaler Notfall-Administrator", password_hash=_hash_password(password), auth_source="local", is_active=True, is_admin=True, is_protected=True, allowed_mesh_groups=[], asset_access_scopes=["all"], language_code=load_config().get("general", {}).get("default_language", "de"), ) db.add(user) created = True password_initialized = True else: user.is_active = True user.is_admin = True user.is_protected = True user.auth_source = "local" if not user.display_name: user.display_name = "Lokaler Notfall-Administrator" if not user.password_hash: user.password_hash = _hash_password(password) password_initialized = True db.commit() logger.info( "Lokaler Notfall-Administrator %r ist aktiv und geschützt " "(neu=%s, Passwort initialisiert=%s).", user.username, created, password_initialized, ) 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), "is_protected": bool(getattr(user, "is_protected", False)), "allowed_mesh_groups": user.allowed_mesh_groups or [], "asset_access_scopes": user.asset_access_scopes or [], "profile_department": user.profile_department or "", "profile_location": user.profile_location or "", "language_code": user.language_code or "en", "persist_browser_state": bool(getattr(user, "persist_browser_state", False)), "toast_position": getattr(user, "toast_position", "top-right") or "top-right", "toast_duration_seconds": int(getattr(user, "toast_duration_seconds", 6) or 6), "theme_mode": getattr(user, "theme_mode", "light") if getattr(user, "theme_mode", "light") in {"light", "dark"} else "light", } 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.") ASSET_ACCESS_SCOPES = {"self", "department", "location", "all"} 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).strip() for x in (user.get("allowed_mesh_groups") or []) if str(x).strip()] def _asset_access_conditions(request: Request): """Build additive (OR-linked) asset visibility conditions for a non-admin user.""" user = _session_user(request) if not user or user.get("is_admin"): return None scopes = {str(value).strip().lower() for value in (user.get("asset_access_scopes") or [])} scopes &= ASSET_ACCESS_SCOPES if "all" in scopes: return None conditions = [] username = str(user.get("username") or "").strip() department = str(user.get("profile_department") or "").strip() location = str(user.get("profile_location") or "").strip() if "self" in scopes and username: conditions.append(func.lower(func.trim(Asset.assigned_to)) == username.casefold()) if "department" in scopes and department: conditions.append(func.lower(func.trim(Asset.department)) == department.casefold()) if "location" in scopes and location: conditions.append(func.lower(func.trim(Asset.location)) == location.casefold()) groups = _allowed_groups(request) or [] if groups: conditions.append(Asset.mesh_group.in_(groups)) return conditions def _apply_asset_access(query, request: Request): conditions = _asset_access_conditions(request) if conditions is None: return query return query.filter(or_(*conditions) if conditions else false()) 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_debug_enabled(auth_config: dict) -> bool: return bool(auth_config.get("ldap", {}).get("debug_logging", False)) 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", {}) debug_logging = _ldap_debug_enabled(auth_config) 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("LDAP login lookup started: username=%r", username) if debug_logging: ldap_logger.debug( "LDAP connection settings: server=%s port=%s ssl=%s start_tls=%s base_dn=%r bind_dn=%r password_env=%r configured=%s", server_name, port, use_ssl, start_tls, base_dn, bind_dn, env_name, bool(bind_password), ) if not server_name: ldap_logger.error("LDAP authentication unavailable: server is not configured") return None if not base_dn: ldap_logger.error("LDAP authentication unavailable: user base DN is not configured") return None if not password: ldap_logger.warning("LDAP login rejected: username=%r reason=empty-password", username) return None if bind_dn and not bind_password: ldap_logger.error("LDAP authentication unavailable: service-account password is missing") if debug_logging: ldap_logger.debug("Expected LDAP bind-password environment variable: %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 start_tls and not use_ssl: search_connection.open() if debug_logging: ldap_logger.debug("LDAP TCP connection opened") if not search_connection.start_tls(): ldap_logger.error("LDAP authentication unavailable: StartTLS failed") if debug_logging: ldap_logger.debug("StartTLS details: result=%r last_error=%r", search_connection.result, search_connection.last_error) return None if debug_logging: ldap_logger.debug("LDAP StartTLS completed") if not search_connection.bind(): ldap_logger.error("LDAP authentication unavailable: service-account bind failed") if debug_logging: ldap_logger.debug("Service-account bind details: result=%r last_error=%r", search_connection.result, search_connection.last_error) return None if debug_logging: ldap_logger.debug("LDAP service-account bind completed") 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"])) if debug_logging: ldap_logger.debug("LDAP user search: base_dn=%r filter=%r attributes=%r", base_dn, user_filter, attributes) search_ok = search_connection.search( base_dn, user_filter, search_scope=SUBTREE, attributes=attributes, ) entry_count = len(search_connection.entries) if debug_logging: ldap_logger.debug( "LDAP user search details: success=%s entries=%s result=%r last_error=%r", search_ok, entry_count, search_connection.result, search_connection.last_error, ) if entry_count != 1: ldap_logger.info("LDAP user lookup completed: username=%r found=no", username) if debug_logging and entry_count > 1: ldap_logger.debug("LDAP user lookup returned multiple entries: username=%r entries=%s", username, entry_count) return None ldap_logger.info("LDAP user lookup completed: username=%r found=yes", username) 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 "") if debug_logging: ldap_logger.debug("LDAP user details: dn=%r display_name=%r email=%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 start_tls and not use_ssl: user_connection.open() if not user_connection.start_tls(): ldap_logger.info("LDAP login completed: username=%r success=no", username) if debug_logging: ldap_logger.debug("User StartTLS details: result=%r last_error=%r", user_connection.result, user_connection.last_error) return None if not user_connection.bind(): ldap_logger.info("LDAP login completed: username=%r success=no", username) if debug_logging: ldap_logger.debug("User bind details: dn=%r result=%r last_error=%r", user_dn, user_connection.result, user_connection.last_error) return None ldap_logger.info("LDAP login completed: username=%r success=yes", username) return {"username": username, "display_name": display_name, "email": email} except LDAPException as exc: ldap_logger.error("LDAP login failed because of an LDAP error: username=%r", username) if debug_logging: ldap_logger.exception("LDAP exception during login for username=%r: %s", username, exc) return None except Exception as exc: ldap_logger.error("LDAP login failed because of an unexpected error: username=%r", username) if debug_logging: ldap_logger.exception("Unexpected LDAP login error for username=%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"} is_agent_callback = (request.url.path.startswith("/api/software-jobs/") and request.url.path.endswith("/callback")) or request.url.path == "/api/software-callback/health" 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/") and not is_agent_callback: 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) app.add_middleware( SessionMiddleware, secret_key=_session_secret, same_site="lax", https_only=False, ) 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() def _seed_field_definitions(db: Session) -> None: existing = {item.field_name: item for item in db.query(FieldDefinition).all()} for order, (field_name, label) in enumerate(ASSET_FIELDS.items()): item = existing.get(field_name) if not item: item = FieldDefinition( field_name=field_name, label=label, description=None, data_type=("boolean" if field_name == "mesh_online" else "integer" if field_name == "mesh_mtype" else "long_text" if field_name in {"notes","storage_details","memory_details","gpu_name","antivirus"} else "date" if field_name in {"purchase_date","warranty_until","assigned_on"} else "status" if field_name == "status" else "ip" if field_name == "ip_address" else "short_text"), readonly=field_name in SYSTEM_READONLY_FIELDS, is_system=True, translation_key=f"field.{field_name}", sort_order=order, excel_import=field_name not in SYSTEM_READONLY_FIELDS, bulk_edit=field_name not in SYSTEM_READONLY_FIELDS, is_default=field_name in DEFAULT_FIELDS, ) db.add(item); db.flush(); existing[field_name] = item elif field_name in SYSTEM_READONLY_FIELDS: item.readonly = True; item.excel_import = False; item.bulk_edit = False db.flush() for link in db.query(CategoryField).all(): definition = existing.get(link.field_name) if definition and link.field_definition_id != definition.id: link.field_definition_id = definition.id # Every category gets every available field; new links are inactive. categories = db.query(Category).all() definitions = db.query(FieldDefinition).filter(FieldDefinition.is_active.is_(True)).order_by(FieldDefinition.sort_order).all() for category in categories: linked = {x.field_definition_id for x in category.visible_fields if x.field_definition_id} linked_names = {x.field_name for x in category.visible_fields} next_order = max((x.sort_order for x in category.visible_fields), default=-1) + 1 for definition in definitions: if definition.id in linked or definition.field_name in linked_names: continue db.add(CategoryField(category_id=category.id, field_name=definition.field_name, label=definition.label, field_definition_id=definition.id, active=False, required=False, show_in_list=False, readonly=definition.readonly, sort_order=next_order)) next_order += 1 db.commit() DEFAULT_MESH_FIELD_MAPPINGS = [ # source_path, target, transform, multi mode, separator, enabled, priority, description ("node._id|node.nodeid|node.nodeId|node.id|_id|nodeid|nodeId|id", "mesh_node_id", "string", "first", "\n", True, 10, "Eindeutige MeshCentral Node-ID"), ("node.name|node.hostname|node.rname|name|hostname|rname", "name", "string", "first", "\n", True, 20, "Gerätename aus MeshCentral"), ("node.hostname|node.name|node.rname|hostname|name|rname", "hostname", "string", "first", "\n", True, 30, "Computername/Hostname"), ("node.osdesc|node.os|node.operatingSystem|osdesc|os|operatingSystem", "operating_system", "string", "first", "\n", True, 40, "Betriebssystembeschreibung"), ("sys.hardware.windows.osinfo.BuildNumber|sys.hardware.windows.osinfo.Version", "os_patch_level", "string", "first", "\n", True, 50, "Windows Build- oder Versionsnummer"), ("net.netif2", "ip_address", "active_ipv4_address", "first", "\n", True, 60, "IPv4-Adresse des ersten aktiven Nicht-Loopback-Adapters"), ("net.netif2", "mac_address", "active_ipv4_mac", "first", "\n", True, 70, "MAC-Adresse desselben aktiven IPv4-Adapters"), ("sys.hardware.identifiers.board_vendor|sys.hardware.identifiers.bios_vendor", "manufacturer", "string", "first", "\n", True, 80, "System- oder BIOS-Hersteller"), ("sys.hardware.identifiers.product_name", "model", "string", "first", "\n", True, 90, "Produkt-/Modellbezeichnung"), ("sys.hardware.identifiers.bios_serial|sys.hardware.windows.bios.SerialNumber|node.serial_number|node.serialNumber", "serial_number", "normalize_serial", "first", "\n", True, 100, "BIOS-/Geräteseriennummer"), ("sys.hardware.identifiers.cpu_name|sys.hardware.windows.cpu[].Name", "cpu", "string", "first", "\n", True, 110, "CPU-Bezeichnung"), ("sys.hardware.windows.memory", "memory_gb", "sum_memory_gb", "first", "\n", True, 120, "Summe der Speichermodule in GiB"), ("sys.hardware.windows.memory", "memory_details", "format_memory_modules", "first", "\n", True, 130, "Formatierte Liste der Speichermodule"), ("sys.hardware.windows.drives", "storage_gb", "sum_storage_gb", "first", "\n", True, 140, "Gesamtkapazität der Datenträger in GB"), ("sys.hardware.windows.drives", "storage_details", "format_drives", "first", "\n", True, 150, "Formatierte Liste der Datenträger"), ("sys.hardware.windows.gpu", "gpu_name", "gpu_names", "first", "\n", True, 160, "Namen der erkannten Grafikkarten"), ("sys.hardware.tpm.SpecVersion", "tpm_version", "string", "first", "\n", True, 170, "TPM-Spezifikationsversion"), ("node.av|av", "antivirus", "active_antivirus", "first", "\n", True, 180, "Aktive Antivirenprodukte"), ("node.groupname|groupname", "mesh_group", "string", "first", "\n", True, 190, "MeshCentral-Gerätegruppe"), ("node.mtype|node.icon|mtype|icon", "mesh_mtype", "integer", "first", "\n", True, 200, "MeshCentral Gerätetyp (mtype)"), ] def _seed_mesh_field_mappings(db: Session) -> None: """Create missing defaults without overwriting persisted user settings.""" rows = db.query(MeshFieldMapping).order_by(MeshFieldMapping.id).all() by_target: dict[str, list[MeshFieldMapping]] = {} for row in rows: by_target.setdefault(row.target_field_name, []).append(row) # Import legacy config mappings only when that exact database mapping does # not already exist. Existing database rows remain the source of truth. config_mappings = load_config().get("meshcentral", {}).get("field_mappings", []) or [] for index, item in enumerate(config_mappings): if not isinstance(item, dict): continue target = str(item.get("target_field") or item.get("target_field_name") or "").strip() source = str(item.get("source_path") or "").strip() if not target or not source: continue if any(row.source_path == source and row.target_field_name == target for row in rows): continue row = MeshFieldMapping( source_path=source, target_field_name=target, transform=str(item.get("transform") or "raw"), multi_value_mode=str(item.get("multi_value_mode") or "first"), separator=str(item.get("separator") or "\\n"), enabled=bool(item.get("enabled", True)), priority=int(item.get("priority") or (1000 + index)), description=item.get("description"), update_rule=str(item.get("update_rule") or "fill_empty"), is_system=False, ) db.add(row) rows.append(row) by_target.setdefault(target, []).append(row) legacy_rules = load_config().get("meshcentral", {}).get("field_rules", {}) or {} for source, target, transform, multi, separator, enabled, priority, description in DEFAULT_MESH_FIELD_MAPPINGS: candidates = by_target.get(target, []) system_row = next((row for row in candidates if bool(getattr(row, "is_system", False))), None) if system_row is None: # A legacy database may contain exactly one mapping for the target. # Adopt it as the system row without changing its configured values. system_row = candidates[0] if candidates else None if system_row is None: system_row = MeshFieldMapping( source_path=source, target_field_name=target, transform=transform, multi_value_mode=multi, separator=separator, enabled=enabled, priority=priority, description=description, update_rule=str(legacy_rules.get(target) or "fill_empty"), is_system=True, ) db.add(system_row) by_target.setdefault(target, []).append(system_row) continue # Only fill genuinely missing values. Never reset a user's persisted # "who wins" rule, enabled state, source path, transform or priority. system_row.is_system = True if not str(system_row.source_path or "").strip(): system_row.source_path = source if not str(system_row.transform or "").strip(): system_row.transform = transform if not str(system_row.multi_value_mode or "").strip(): system_row.multi_value_mode = multi if system_row.separator is None: system_row.separator = separator if system_row.priority is None: system_row.priority = priority if not str(system_row.description or "").strip(): system_row.description = description if not str(system_row.update_rule or "").strip(): configured_rule = legacy_rules.get(target) system_row.update_rule = configured_rule if configured_rule in {"fill_empty", "meshcentral_wins", "local_wins"} else "fill_empty" db.commit() def _definition_label(field: CategoryField) -> str: return field.definition.label if field.definition else field.label def _definition_readonly(field: CategoryField) -> bool: return bool(field.definition.readonly) if field.definition else bool(getattr(field, "readonly", False)) def _custom_value_get(asset: Asset, definition: FieldDefinition, db: Session): row = db.query(AssetFieldValue).filter(AssetFieldValue.asset_id == asset.id, AssetFieldValue.field_definition_id == definition.id).first() if not row: return None return row.value_json if definition.data_type == "json" else row.value_boolean if definition.data_type == "boolean" else row.value_datetime if definition.data_type == "datetime" else row.value_date if definition.data_type == "date" else row.value_number if definition.data_type in {"integer","decimal"} else row.value_text def _custom_value_set(asset: Asset, definition: FieldDefinition, raw_value, db: Session) -> None: row = db.query(AssetFieldValue).filter(AssetFieldValue.asset_id == asset.id, AssetFieldValue.field_definition_id == definition.id).first() if not row: row = AssetFieldValue(asset_id=asset.id, field_definition_id=definition.id); db.add(row) row.value_text=row.value_number=row.value_date=None; row.value_boolean=None; row.value_datetime=None; row.value_json=None value = None if raw_value in (None, "") else raw_value if definition.data_type == "boolean": row.value_boolean = str(value).lower() in {"1","true","on","yes"} if value is not None else None elif definition.data_type in {"integer","decimal"}: row.value_number = str(value) if value is not None else None elif definition.data_type == "date": row.value_date = str(value) if value is not None else None elif definition.data_type == "datetime": row.value_datetime = datetime.fromisoformat(str(value)) if value else None elif definition.data_type == "json": import json; row.value_json = json.loads(str(value)) if value else None else: row.value_text = str(value) if value is not None else None templates.env.globals["field_label"] = _definition_label templates.env.globals["field_readonly"] = _definition_readonly SOFTWARE_INVENTORY_WINDOWS_SCRIPT = r''' $paths = @( 'HKLM:\Software\Microsoft\Windows\CurrentVersion\Uninstall\*', 'HKLM:\Software\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*' ) $software = Get-ItemProperty $paths -ErrorAction SilentlyContinue | Where-Object { $_.DisplayName } | ForEach-Object { [PSCustomObject]@{ name = [string]$_.DisplayName version = [string]$_.DisplayVersion publisher = [string]$_.Publisher install_date = [string]$_.InstallDate architecture = $(if ($_.PSPath -like '*WOW6432Node*') { 'x86' } else { 'x64' }) } } | Sort-Object name, version -Unique Write-JobLog ("Software inventory completed; entries=" + $software.Count) $JobResult = @{ software = $software } ''' SOFTWARE_INVENTORY_LINUX_SCRIPT = r''' import os import subprocess items = [] if os.path.exists("/usr/bin/dpkg-query"): output = subprocess.check_output( ["dpkg-query", "-W", "-f=${binary:Package}\t${Version}\t${Maintainer}\n"], text=True, errors="replace", ) for line in output.splitlines(): parts = line.split("\t") items.append({ "name": parts[0], "version": parts[1] if len(parts) > 1 else "", "publisher": parts[2] if len(parts) > 2 else "", "architecture": "", "install_date": None, }) elif os.path.exists("/usr/bin/rpm"): output = subprocess.check_output( ["rpm", "-qa", "--qf", "%{NAME}\t%{VERSION}-%{RELEASE}\t%{VENDOR}\t%{ARCH}\n"], text=True, errors="replace", ) for line in output.splitlines(): parts = line.split("\t") items.append({ "name": parts[0], "version": parts[1] if len(parts) > 1 else "", "publisher": parts[2] if len(parts) > 2 else "", "architecture": parts[3] if len(parts) > 3 else "", "install_date": None, }) elif os.path.exists("/sbin/apk") or os.path.exists("/usr/sbin/apk"): output = subprocess.check_output(["apk", "info", "-v"], text=True, errors="replace") for line in output.splitlines(): items.append({"name": line, "version": "", "publisher": "", "architecture": "", "install_date": None}) print("Software inventory completed; entries=" + str(len(items))) job_result = {"software": items} ''' def _seed_job_definitions(db: Session) -> None: defaults = [ dict(name='Software-Inventur Windows', system_key='software_inventory_windows', platform='windows', interpreter='powershell', inline_script=SOFTWARE_INVENTORY_WINDOWS_SCRIPT), dict(name='Software-Inventur Linux', system_key='software_inventory_linux', platform='linux', interpreter='python', inline_script=SOFTWARE_INVENTORY_LINUX_SCRIPT), ] for data in defaults: row=db.query(JobDefinition).filter(JobDefinition.system_key==data['system_key']).first() if row is None: row=JobDefinition(description='Systemdefinition für die Softwareinventur. Enthält ausschließlich den fachlichen Skriptcode; der technische Rahmen wird zur Laufzeit erzeugt.', source_type='inline', enabled=True, is_system=True, timeout_seconds=600, success_codes='0', **data) db.add(row) else: # System definitions are maintained by the application. This one-time # normalization removes previously stored callback/wrapper code. row.name=data['name']; row.platform=data['platform']; row.interpreter=data['interpreter'] row.source_type='inline'; row.inline_script=data['inline_script']; row.source_path=None row.description='Systemdefinition für die Softwareinventur. Enthält ausschließlich den fachlichen Skriptcode; der technische Rahmen wird zur Laufzeit erzeugt.' row.enabled=True; row.is_system=True; row.timeout_seconds=600; row.success_codes='0' db.commit() def _job_definition_snapshot(row: JobDefinition) -> dict: return {**{k:getattr(row,k) for k in ('name','description','job_kind','configuration','system_key','platform','interpreter','source_type','source_path','inline_script','arguments','timeout_seconds','success_codes','enabled','is_system','revision')}, 'parameters':[{'parameter_key':x.parameter_key,'label':x.label,'description':x.description,'data_type':x.data_type,'default_value':x.default_value,'required':x.required,'sort_order':x.sort_order} for x in row.parameters]} def _job_preview_values() -> dict[str, str]: return { "JobId": "12345", "CallbackUrl": "https://assetmanager.example.invalid/api/software-jobs/12345/callback?token=preview-token", "AssetName": "DEMO-PC-001", "Hostname": "DEMO-PC-001", "IPAddress": "192.0.2.25", "CurrentUser": "demo.user", "SerialNumber": "DEMO-SERIAL-001", "Manufacturer": "Example Manufacturer", "Model": "Example Model", "Department": "IT", "Location": "Head Office", "MeshNodeId": "node/demo-preview" } def _replace_job_placeholders(script: str, values: dict[str, str] | None = None) -> str: rendered = str(script or "") for key, value in (values or _job_preview_values()).items(): rendered = rendered.replace("{{" + key + "}}", str(value)) return rendered def _validate_job_definition(row: JobDefinition, request: Request | None = None) -> str: def tr(key: str, default: str, **values) -> str: return _translate_request(request, key, default, **values) if request else default.format(**values) errors=[]; notes=[] script=build_registry_user_script(row.configuration) if row.job_kind=='registry' else ((row.inline_script or '') if row.source_type=='inline' else '') if row.job_kind=='registry' and not (row.configuration or {}).get('registry_entries'): errors.append(tr('jobdefs.validation.registry_empty','At least one registry entry is required.')) if row.job_kind!='registry' and row.source_type=='inline' and not script.strip(): errors.append(tr('jobdefs.validation.inline_empty','The inline script is empty.')) if row.source_type=='mounted': path=Path(row.source_path or '') if not path.is_absolute(): errors.append(tr('jobdefs.validation.mounted_absolute','The mounted path must be absolute.')) elif not path.is_file(): errors.append(tr('jobdefs.validation.file_missing','File not found: {path}',path=path)) else: script=path.read_text(encoding='utf-8',errors='replace') notes.append(tr('jobdefs.validation.file_readable','File readable: {path} ({count} characters)',path=path,count=len(script))) if row.source_type=='unc': path=str(row.source_path or '') if not path.startswith('\\\\'): errors.append(tr('jobdefs.validation.unc_prefix','A UNC path must begin with \\\\.')) else: notes.append(tr('jobdefs.validation.unc_ok','UNC syntax is valid. Reachability is checked on the target device.')) rendered = _replace_job_placeholders(script) if rendered and row.interpreter=='python': try: compile(rendered,'','exec');notes.append(tr('jobdefs.validation.python_ok','Python syntax is valid.')) except SyntaxError as exc: errors.append(tr('jobdefs.validation.python_error','Python syntax error on line {line}: {message}',line=exc.lineno,message=exc.msg)) elif rendered and row.interpreter=='bash' and shutil.which('bash'): with tempfile.NamedTemporaryFile('w',suffix='.sh',delete=False,encoding='utf-8') as f:f.write(rendered);name=f.name try: r=subprocess.run(['bash','-n',name],capture_output=True,text=True,timeout=10) if r.returncode: errors.append(r.stderr.strip()) else: notes.append(tr('jobdefs.validation.bash_ok','Bash syntax is valid.')) finally: Path(name).unlink(missing_ok=True) elif rendered and row.interpreter in {'powershell','cmd'}: notes.append(tr('jobdefs.validation.windows_later','Content is present. Full syntax validation is performed later on a Windows test device.')) if errors: return 'ERROR\n'+'\n'.join('- '+x for x in errors)+'\n\n'+'\n'.join(x for x in notes if x) return 'OK\n'+'\n'.join(x for x in notes if x) def _seed_software_inventory_package(db: Session) -> SoftwarePackage: package = db.query(SoftwarePackage).filter(SoftwarePackage.name == "Software-Inventur").first() if package is None: package = SoftwarePackage( name="Software-Inventur", description="Liest installierte Software über den vorhandenen MeshCentral-Agenten aus.", package_type="inventory", enabled=True, is_system=True, callback_timeout_minutes=30, ) db.add(package) db.commit() db.refresh(package) return package def _seed_generic_job_package(db: Session) -> SoftwarePackage: package = db.query(SoftwarePackage).filter(SoftwarePackage.name == "Jobdefinition").first() if package is None: package = SoftwarePackage( name="Jobdefinition", description="Technisches Systempaket für ausführbare Jobdefinitionen.", package_type="job_definition", enabled=True, is_system=True, callback_timeout_minutes=30, ) db.add(package) db.commit() db.refresh(package) return package @app.on_event("startup") def startup(): Base.metadata.create_all(bind=engine) apply_lightweight_migrations() from .database import SessionLocal db = SessionLocal() try: _ensure_local_emergency_admin(db) seed_i18n(db) _seed_field_definitions(db) _seed_mesh_field_mappings(db) _seed_software_inventory_package(db) _seed_generic_job_package(db) _seed_job_definitions(db) backfill_asset_job_states(db) 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() _seed_field_definitions(db) # Earlier Excel imports could store date values as # "YYYY-MM-DD 00:00:00". Browsers render such values as empty in # . Existing system date fields are therefore # normalized to YYYY-MM-DD once. date_repairs = 0 for asset in db.query(Asset).filter( (Asset.purchase_date.like("% %")) | (Asset.purchase_date.like("%T%")) | (Asset.warranty_until.like("% %")) | (Asset.warranty_until.like("%T%")) | (Asset.assigned_on.like("% %")) | (Asset.assigned_on.like("%T%")) ).all(): for field_name in ("purchase_date", "warranty_until", "assigned_on"): old_value = getattr(asset, field_name, None) new_value = _normalize_date_value(old_value) if old_value and new_value and old_value != new_value: setattr(asset, field_name, new_value) date_repairs += 1 if date_repairs: db.commit() logger.info("%s ältere Datumswerte auf YYYY-MM-DD normalisiert", date_repairs) finally: db.close() @app.on_event("startup") def start_software_timeout_service(): global SOFTWARE_TIMEOUT_THREAD if SOFTWARE_TIMEOUT_THREAD is None or not SOFTWARE_TIMEOUT_THREAD.is_alive(): SOFTWARE_TIMEOUT_STOP_EVENT.clear() SOFTWARE_TIMEOUT_THREAD = threading.Thread( target=_software_job_timeout_loop, args=(SOFTWARE_TIMEOUT_STOP_EVENT,), daemon=True, name="assetmanager-software-timeouts", ) SOFTWARE_TIMEOUT_THREAD.start() @app.on_event("shutdown") def stop_software_timeout_service(): SOFTWARE_TIMEOUT_STOP_EVENT.set() @app.on_event("startup") def start_mesh_presence_service(): global PRESENCE_THREAD if PRESENCE_THREAD is None or not PRESENCE_THREAD.is_alive(): PRESENCE_STOP_EVENT.clear() PRESENCE_THREAD = threading.Thread( target=mesh_presence_loop, args=(PRESENCE_STOP_EVENT,), daemon=True, name="assetmanager-mesh-presence", ) PRESENCE_THREAD.start() @app.on_event("shutdown") def stop_mesh_presence_service(): PRESENCE_STOP_EVENT.set() def delete_asset_image(image_path: str | None) -> None: """Delete only individually uploaded asset images from the upload directory.""" 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: """Create a real XLSX file and return complete bytes instead of a stream. This prevents corrupted downloads with certain reverse proxies and browsers. """ 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] = {"asset-id": "asset_id", "asset_id": "asset_id", "id": "asset_id", "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 _normalize_date_value(value: Any) -> str | None: """Normalize Excel and database values for HTML date fields to YYYY-MM-DD.""" if value in (None, ""): return None if isinstance(value, datetime): return value.date().isoformat() if isinstance(value, date): return value.isoformat() text = str(value).strip() if not text: return None # ISO date or timestamp: the date portion is the first 10 characters. if re.match(r"^\d{4}-\d{2}-\d{2}(?:[ T].*)?$", text): return text[:10] # German date notation from manually maintained Excel files. match = re.match(r"^(\d{1,2})\.(\d{1,2})\.(\d{4})(?:\s+.*)?$", text) if match: day, month, year = match.groups() return f"{year}-{int(month):02d}-{int(day):02d}" return text def _normalize_datetime_value(value: Any) -> str | None: """Normalize values for .""" if value in (None, ""): return None if isinstance(value, datetime): return value.strftime("%Y-%m-%dT%H:%M") if isinstance(value, date): return f"{value.isoformat()}T00:00" text = str(value).strip() if not text: return None text = text.replace(" ", "T") if re.match(r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}", text): return text[:16] if re.match(r"^\d{4}-\d{2}-\d{2}$", text): return f"{text}T00:00" return text def _html_field_value(value: Any, data_type: str | None = None) -> str: if data_type == "date": return _normalize_date_value(value) or "" if data_type == "datetime": return _normalize_datetime_value(value) or "" return _display_value(value) templates.env.globals["html_field_value"] = _html_field_value def _request_language(request: Request | None) -> str: if request is None: return "en" user = request.session.get("user") or {} return str(user.get("language_code") or request.session.get("language_code") or load_config().get("general", {}).get("default_language", "en")).lower() def _localized_text_key(value: Any, language_code: str = "en") -> str: text = str(value or "").strip().casefold() # German users expect umlauts near their expanded base letters instead of after Z. if language_code.startswith("de"): text = text.replace("ä", "ae").replace("ö", "oe").replace("ü", "ue").replace("ß", "ss") return "".join(ch for ch in unicodedata.normalize("NFKD", text) if not unicodedata.combining(ch)) def _sort_localized(items: list[Any], attribute: str, request: Request | None) -> list[Any]: language_code = _request_language(request) return sorted(items, key=lambda item: _localized_text_key(getattr(item, attribute, ""), language_code)) def _active_status_options(db: Session, request: Request | None = None) -> list[StatusOption]: items = db.query(StatusOption).filter(StatusOption.active.is_(True)).all() return _sort_localized(items, "name", request) 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 _normalize_optional_text(value: str | None) -> str | None: text = (value or "").strip() return text or None def _validate_asset_identifiers(db: Session, *, asset_tag: str | None, manufacturer: str | None, serial_number: str | None, exclude_id: int | None = None) -> None: asset_tag = _normalize_optional_text(asset_tag) manufacturer = _normalize_optional_text(manufacturer) serial_number = _normalize_optional_text(serial_number) if asset_tag: query = db.query(Asset).filter(Asset.asset_tag == asset_tag) if exclude_id is not None: query = query.filter(Asset.id != exclude_id) if query.first(): raise HTTPException(400, f"Die Inventarnummer {asset_tag} ist bereits vergeben.") if manufacturer and serial_number: query = db.query(Asset).filter(Asset.manufacturer == manufacturer, Asset.serial_number == serial_number) if exclude_id is not None: query = query.filter(Asset.id != exclude_id) if query.first(): raise HTTPException(400, f"Die Seriennummer {serial_number} ist beim Hersteller {manufacturer} bereits vorhanden.") def _would_create_parent_cycle(db: Session, asset_id: int, parent_id: int | None) -> bool: seen = {asset_id} current = parent_id while current: if current in seen: return True seen.add(current) parent = db.query(Asset.parent_asset_id).filter(Asset.id == current).scalar() current = parent return False def _list_fields_for_categories(categories: list[Category]) -> list[CategoryField]: """Build a unique, sorted union of the configured list columns.""" 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 CHART_FIELDS = { "category": "Kategorie", "status": "Status", "operating_system": "Betriebssystem", "manufacturer": "Hersteller", "model": "Modell", "location": "Standort", "room_number": "Raumnummer", "department": "Abteilung", "mesh_group": "MeshCentral-Gruppe", "antivirus": "Antivirus", "tpm_version": "TPM-Version", } CHART_TYPES = { "donut": "Donutdiagramm", "pie": "Kuchendiagramm", "bar": "Balkendiagramm", "stacked_bar": "Gestapeltes Balkendiagramm", } def _parse_value_mappings(text_value: str) -> list[dict[str, str]]: """Read rules in the format search text => display name, one rule per line.""" mappings: list[dict[str, str]] = [] for line_number, raw_line in enumerate((text_value or "").splitlines(), start=1): line = raw_line.strip() if not line or line.startswith("#"): continue separator = "=>" if "=>" in line else "=" if "=" in line else None if not separator: raise HTTPException(400, f"Ungültige Wertzuordnung in Zeile {line_number}. Erwartet: Suchtext => Anzeigename") search, label = (part.strip() for part in line.split(separator, 1)) if not search or not label: raise HTTPException(400, f"Unvollständige Wertzuordnung in Zeile {line_number}.") mappings.append({"search": search, "label": label}) return mappings def _value_mappings_text(chart: ChartDefinition | None) -> str: if not chart: return "" return "\n".join( f"{entry.get('search', '')} => {entry.get('label', '')}" for entry in (chart.value_mappings or []) if isinstance(entry, dict) and entry.get("search") and entry.get("label") ) def _automatic_chart_value(field: str, value: str) -> str: """Summarize technical raw values for charts without modifying asset data.""" text = value.strip() lower = text.casefold() if field == "operating_system": if "windows server" in lower: match = re.search(r"windows server\s*(20\d{2})", text, re.IGNORECASE) return f"Windows Server {match.group(1)}" if match else "Windows Server" if "windows 11" in lower: return "Windows 11" if "windows 10" in lower: return "Windows 10" if "ubuntu" in lower: return "Ubuntu" if "debian" in lower: return "Debian" if "red hat" in lower or "rhel" in lower: return "Red Hat Enterprise Linux" if "centos" in lower: return "CentOS" if "fedora" in lower: return "Fedora" if "macos" in lower or "mac os" in lower: return "macOS" if "linux" in lower: return "Linux" if field == "manufacturer": if lower.startswith("hewlett") or lower in {"hp", "hp inc.", "hp inc"}: return "HP" if lower.startswith("dell"): return "Dell" if lower.startswith("lenovo"): return "Lenovo" if lower.startswith("microsoft"): return "Microsoft" return text def _transform_chart_value(chart: ChartDefinition, field: str, value: str) -> str: # Custom rules take precedence; matching is case-insensitive. lower = value.casefold() for entry in chart.value_mappings or []: if not isinstance(entry, dict): continue search = str(entry.get("search") or "").strip() label = str(entry.get("label") or "").strip() if search and label and search.casefold() in lower: return label if (chart.value_mode or "raw") == "automatic": return _automatic_chart_value(field, value) return value def _chart_value(asset: Asset, chart: ChartDefinition, field: str, show_empty: bool) -> str | None: if field == "category": value = asset.category.name if asset.category else None else: value = getattr(asset, field, None) if isinstance(value, str): value = value.strip() if value in (None, ""): return "Nicht angegeben" if show_empty else None return _transform_chart_value(chart, field, str(value)) def _chart_payload(chart: ChartDefinition, assets: list[Asset]) -> dict: grouped: dict[str, dict[str, int] | int] = {} stack_values: set[str] = set() excluded_statuses = {str(value).casefold() for value in (chart.excluded_statuses or []) if value not in (None, "")} for asset in assets: if excluded_statuses and str(asset.status or "").casefold() in excluded_statuses: continue group = _chart_value(asset, chart, chart.group_field, chart.show_empty) if group is None: continue if chart.chart_type == "stacked_bar" and chart.stack_field: stack = _chart_value(asset, chart, chart.stack_field, chart.show_empty) if stack is None: continue stack_values.add(stack) bucket = grouped.setdefault(group, {}) bucket[stack] = bucket.get(stack, 0) + 1 else: grouped[group] = int(grouped.get(group, 0)) + 1 def total(item): value = item[1] return sum(value.values()) if isinstance(value, dict) else value items = list(grouped.items()) if chart.sort_mode == "alpha": items.sort(key=lambda item: item[0].casefold()) else: items.sort(key=total, reverse=True) limit = max(1, min(int(chart.max_items or 12), 50)) if len(items) > limit and chart.chart_type != "stacked_bar": kept, rest = items[:limit-1], items[limit-1:] kept.append(("Sonstige", sum(int(v) for _, v in rest))) items = kept else: items = items[:limit] maximum = max((total(item) for item in items), default=1) total_count = sum(total(item) for item in items) donut_stops = [] current = 0.0 for index, (_, value) in enumerate(items): count = total(("", value)) end = current + (count / total_count * 100 if total_count else 0) donut_stops.append(f"var(--chart-color-{index % 12}) {current:.2f}% {end:.2f}%") current = end return { "chart": chart, "items": items, "stack_values": sorted(stack_values, key=str.casefold), "maximum": maximum, "total": total_count, "donut_gradient": ", ".join(donut_stops) or "#e5e7eb 0 100%", } def _seed_default_charts(db: Session) -> None: defaults = [ {"name": "Betriebssysteme", "chart_type": "donut", "group_field": "operating_system", "value_mode": "automatic", "dashboard_visible": True, "max_items": 10}, {"name": "Geräte nach Kategorie und Status", "chart_type": "stacked_bar", "group_field": "category", "stack_field": "status", "dashboard_visible": True, "max_items": 15}, {"name": "Hersteller", "chart_type": "bar", "group_field": "manufacturer", "dashboard_visible": False, "max_items": 10}, {"name": "Statusübersicht", "chart_type": "donut", "group_field": "status", "dashboard_visible": False, "max_items": 10}, ] changed = False for values in defaults: chart = db.query(ChartDefinition).filter(ChartDefinition.name == values["name"]).first() if chart is None: chart = ChartDefinition(**values, is_system=True) db.add(chart) changed = True elif not chart.is_system: chart.is_system = True changed = True if changed: db.commit() @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) _seed_default_charts(db) chart_assets = _apply_asset_access(db.query(Asset), request).options(joinedload(Asset.category)).all() dashboard_charts = [_chart_payload(chart, chart_assets) for chart in db.query(ChartDefinition).filter(ChartDefinition.dashboard_visible.is_(True)).order_by(ChartDefinition.id).all()] 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), "dashboard_charts": dashboard_charts}, ) BULK_EDIT_EXCLUDED_FIELDS = {"asset_tag", "serial_number", "mesh_node_id"} def _bulk_edit_fields_for_assets(assets: list[Asset]) -> list[dict]: """Return active fields shared by all selected asset categories.""" if not assets: return [] category_maps = [] for asset in assets: fields = { field.field_name: _definition_label(field) for field in asset.category.visible_fields if field.active and hasattr(asset, field.field_name) and field.field_name not in BULK_EDIT_EXCLUDED_FIELDS and not _definition_readonly(field) and (not field.definition or field.definition.bulk_edit) } category_maps.append(fields) common = set(category_maps[0]) for item in category_maps[1:]: common &= set(item) return [{"field_name": name, "label": category_maps[0][name]} for name in sorted(common, key=lambda n: category_maps[0][n].casefold())] def _selected_asset_ids(values: list[str]) -> list[int]: result = [] for value in values: try: number = int(value) except (TypeError, ValueError): continue if number > 0 and number not in result: result.append(number) return result ASSET_JOB_FILTER_STATUSES = ( "never_run", "created", "sending", "sent", "running", "success", "partial", "failed", "timeout", "cancelled", ) def _resolve_asset_job_filter(db: Session, selection: str) -> dict[str, Any] | None: selection = str(selection or "").strip() if selection == "software_inventory": return { "selection": selection, "state_key": filter_state_key("software_inventory"), "job_type": "software_inventory", "definition": None, "definition_id": None, "current_revision": None, "label": "Softwareinventur", "label_key": "jobs.type.software_inventory", } if not selection.startswith("definition:"): return None try: definition_id = int(selection.split(":", 1)[1]) except (TypeError, ValueError): return None definition = db.get(JobDefinition, definition_id) if not definition: return None return { "selection": selection, "state_key": filter_state_key("job_definition", definition.id), "job_type": "job_definition", "definition": definition, "definition_id": definition.id, "current_revision": int(definition.revision or 0), "label": definition.name, "label_key": None, } def _normalize_job_filter_selections(value: Any) -> list[str]: """Normalize repeated query/form values while preserving their order.""" if value is None: raw_values: list[Any] = [] elif isinstance(value, (list, tuple, set)): raw_values = list(value) else: raw_values = [value] result: list[str] = [] for raw in raw_values: for item in str(raw or "").split(","): selection = item.strip() if not selection or selection in result: continue if selection == "software_inventory" or re.fullmatch(r"definition:\d+", selection): result.append(selection) return result def _legacy_job_filter_exclusions(condition: str) -> list[str]: """Preserve links generated by 0.5.5.17 while switching to exclusions.""" include_by_condition = { "successful": {"success"}, "not_successful": set(ASSET_JOB_FILTER_STATUSES) - {"success"}, "never_run": {"never_run"}, "last_failed": {"failed"}, "last_partial": {"partial"}, "timeout": {"timeout"}, "callback_pending": {"sent"}, "active": {"created", "sending", "running"}, } included = include_by_condition.get(str(condition or "").strip()) if included is None: return [] return [status for status in ASSET_JOB_FILTER_STATUSES if status not in included] def _normalize_job_filter_exclusions(value: Any) -> list[str]: """Normalize repeated form/query values or a comma-separated status list.""" if value is None: raw_values: list[Any] = [] elif isinstance(value, (list, tuple, set)): raw_values = list(value) else: raw_values = [value] requested: set[str] = set() for raw in raw_values: for item in str(raw or "").split(","): status = item.strip().lower() if status in ASSET_JOB_FILTER_STATUSES: requested.add(status) return [status for status in ASSET_JOB_FILTER_STATUSES if status in requested] def _normalize_asset_job_filter( db: Session, *, selection: Any, excluded_statuses: Any = None, revision_mode: str = "all", ) -> dict[str, Any] | None: selections = _normalize_job_filter_selections(selection) specs: list[dict[str, Any]] = [] state_keys: set[str] = set() for selected_value in selections: spec = _resolve_asset_job_filter(db, selected_value) if spec is None or spec["state_key"] in state_keys: continue specs.append(spec) state_keys.add(spec["state_key"]) if not specs: return None revision_mode = "current" if str(revision_mode or "").strip() == "current" else "all" if not any(spec["job_type"] == "job_definition" for spec in specs): revision_mode = "all" return { "selections": [spec["selection"] for spec in specs], "specs": specs, "state_keys": [spec["state_key"] for spec in specs], "labels": [spec["label"] for spec in specs], "startable_specs": [spec for spec in specs if spec.get("definition") is None or spec["definition"].enabled], "excluded_statuses": _normalize_job_filter_exclusions(excluded_statuses), "revision_mode": revision_mode, } def _asset_job_filter_url(job_filter: dict[str, Any], category_id: int | None = None, **toast: str) -> str: params: list[tuple[str, Any]] = [] params.extend(("job_filter", selection) for selection in job_filter.get("selections") or []) params.append(("job_filter_revision", job_filter["revision_mode"])) params.extend(("job_filter_exclude", status) for status in job_filter.get("excluded_statuses") or []) if category_id: params.append(("category_id", category_id)) params.extend((key, value) for key, value in toast.items() if value) return "/assets?" + urllib.parse.urlencode(params, doseq=True) def _asset_job_filter_view(state: AssetJobState | None, job_filter: dict[str, Any] | None = None) -> dict[str, Any]: effective_never_run = state is None if state is not None and job_filter and job_filter.get("revision_mode") == "current": current_revision = job_filter.get("current_revision") effective_never_run = current_revision is not None and state.definition_revision != current_revision if state is None: return { "status": "never_run", "last_execution": None, "last_success": None, "definition_revision": None, "last_success_revision": None, "execution_count": 0, } return { "status": "never_run" if effective_never_run else (state.last_status or "created"), "last_execution": state.last_started_at or state.last_sent_at or state.last_status_at or state.last_created_at, "last_success": state.last_success_at, "definition_revision": state.definition_revision, "last_success_revision": state.last_success_revision, "execution_count": int(state.execution_count or 0), } def _combined_asset_job_filter_view( states_by_key: dict[str, AssetJobState], job_filter: dict[str, Any], ) -> dict[str, Any]: """Combine alternative selected jobs using the most recently executed state. This is intentional for cases such as the generic software inventory job and platform-specific inventory definitions: an asset counts as successful when its latest execution among the selected alternatives was successful. """ candidates: list[tuple[datetime, dict[str, Any]]] = [] latest_success: datetime | None = None total_executions = 0 for spec in job_filter.get("specs") or []: state = states_by_key.get(spec["state_key"]) view = _asset_job_filter_view( state, { "revision_mode": job_filter.get("revision_mode"), "current_revision": spec.get("current_revision"), }, ) total_executions += int(view.get("execution_count") or 0) if state is not None: success_is_current = True if job_filter.get("revision_mode") == "current" and spec.get("current_revision") is not None: success_is_current = state.last_success_revision == spec.get("current_revision") if success_is_current and state.last_success_at and (latest_success is None or state.last_success_at > latest_success): latest_success = state.last_success_at if view["status"] == "never_run": continue timestamp = view.get("last_execution") or (state.updated_at if state is not None else None) or datetime.min view.update({ "job_label": spec["label"], "job_selection": spec["selection"], }) candidates.append((timestamp, view)) if not candidates: return { "status": "never_run", "last_execution": None, "last_success": latest_success, "definition_revision": None, "last_success_revision": None, "execution_count": total_executions, "job_label": None, "job_selection": None, } result = max(candidates, key=lambda item: item[0])[1] result["last_success"] = latest_success result["execution_count"] = total_executions return result def _filter_assets_by_job_states( db: Session, assets: list[Asset], job_filter: dict[str, Any], ) -> tuple[list[Asset], dict[int, dict[str, Any]]]: if not assets: return [], {} asset_ids = [asset.id for asset in assets] rows = ( db.query(AssetJobState) .filter( AssetJobState.asset_id.in_(asset_ids), AssetJobState.state_key.in_(job_filter.get("state_keys") or [""]), ) .all() ) states_by_asset: dict[int, dict[str, AssetJobState]] = {} for state in rows: states_by_asset.setdefault(state.asset_id, {})[state.state_key] = state excluded = set(job_filter.get("excluded_statuses") or []) filtered_assets: list[Asset] = [] views: dict[int, dict[str, Any]] = {} for asset in assets: view = _combined_asset_job_filter_view(states_by_asset.get(asset.id, {}), job_filter) if view["status"] in excluded: continue filtered_assets.append(asset) views[asset.id] = view return filtered_assets, views @app.get("/assets") def assets_list(request: Request, category_id: int | None = None, db: Session = Depends(get_db)): categories = ( db.query(Category) .options(selectinload(Category.visible_fields).joinedload(CategoryField.definition)) .order_by(Category.name) .all() ) requested_exclusions = request.query_params.getlist("job_filter_exclude") if not requested_exclusions and request.query_params.get("job_filter_condition"): requested_exclusions = _legacy_job_filter_exclusions(request.query_params.get("job_filter_condition", "")) job_filter = _normalize_asset_job_filter( db, selection=request.query_params.getlist("job_filter"), excluded_statuses=requested_exclusions, revision_mode=request.query_params.get("job_filter_revision", "all"), ) query = _apply_asset_access(db.query(Asset), request) 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 .options(joinedload(Asset.category)) .order_by(func.lower(Asset.name), Asset.id) .all() ) job_state_by_asset: dict[int, dict[str, Any]] = {} if job_filter: assets, job_state_by_asset = _filter_assets_by_job_states(db, assets, job_filter) 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) ) issue_status = _action_status(db, "issue") enabled_job_definitions = ( db.query(JobDefinition) .filter(JobDefinition.enabled.is_(True)) .order_by(func.lower(JobDefinition.name), JobDefinition.id) .all() if _is_admin(request) else [] ) filter_job_definitions = ( db.query(JobDefinition) .filter(JobDefinition.enabled.is_(True)) .order_by(func.lower(JobDefinition.name), JobDefinition.id) .all() if _is_admin(request) else [] ) return templates.TemplateResponse( "assets.html", { "request": request, "assets": assets, "categories": categories, "selected": selected, "fields": fields, "is_admin": _is_admin(request), "issue_status_name": issue_status.name if issue_status else None, "total_assets": len(assets), "job_definitions": enabled_job_definitions, "job_filter_definitions": filter_job_definitions, "job_filter": job_filter, "job_filter_statuses": ASSET_JOB_FILTER_STATUSES, "job_state_by_asset": job_state_by_asset, "asset_platforms": {asset.id: detect_platform(asset) for asset in assets}, }, ) @app.get("/charts") def charts_page(request: Request, db: Session = Depends(get_db)): _seed_default_charts(db) assets = _apply_asset_access(db.query(Asset), request).options(joinedload(Asset.category)).all() charts = [_chart_payload(chart, assets) for chart in db.query(ChartDefinition).order_by(ChartDefinition.id).all()] return templates.TemplateResponse("charts.html", {"request": request, "charts": charts, "is_admin": _is_admin(request)}) @app.get("/charts/manage") def charts_manage(request: Request, db: Session = Depends(get_db)): _require_admin(request) _seed_default_charts(db) charts = db.query(ChartDefinition).order_by(ChartDefinition.name).all() return templates.TemplateResponse("charts_manage.html", {"request": request, "charts": charts, "chart_fields": CHART_FIELDS, "chart_types": CHART_TYPES}) def _chart_form_context(request: Request, db: Session, chart: ChartDefinition | None = None) -> dict[str, Any]: language_code = _request_language(request) statuses = db.query(StatusOption).filter(StatusOption.active.is_(True)).all() statuses.sort(key=lambda item: _localized_text_key(item.name, language_code)) return { "request": request, "chart": chart, "chart_fields": CHART_FIELDS, "chart_types": CHART_TYPES, "value_mappings_text": _value_mappings_text(chart), "status_options": statuses, "excluded_statuses": set(chart.excluded_statuses or []) if chart else set(), } @app.get("/charts/new") def chart_new(request: Request, db: Session = Depends(get_db)): _require_admin(request) return templates.TemplateResponse("chart_form.html", _chart_form_context(request, db)) @app.post("/charts/new") def chart_create(request: Request, name: str = Form(...), description: str = Form(""), chart_type: str = Form("bar"), group_field: str = Form("category"), stack_field: str = Form(""), dashboard_visible: str | None = Form(None), show_empty: str | None = Form(None), max_items: int = Form(12), sort_mode: str = Form("count_desc"), value_mode: str = Form("raw"), value_mappings_text: str = Form(""), excluded_statuses: list[str] = Form([]), db: Session = Depends(get_db)): _require_admin(request) if chart_type not in CHART_TYPES or group_field not in CHART_FIELDS: raise HTTPException(400, "Ungültige Diagrammkonfiguration") if chart_type == "stacked_bar" and stack_field not in CHART_FIELDS: raise HTTPException(400, "Für ein gestapeltes Diagramm ist eine gültige Unterteilung erforderlich.") if value_mode not in {"raw", "automatic", "custom"}: raise HTTPException(400, "Ungültige Wertedarstellung") mappings = _parse_value_mappings(value_mappings_text) valid_statuses = {row.name for row in db.query(StatusOption).all()} selected_statuses = [value for value in excluded_statuses if value in valid_statuses] chart = ChartDefinition(name=name.strip(), description=description.strip() or None, chart_type=chart_type, group_field=group_field, stack_field=stack_field if chart_type == "stacked_bar" else None, dashboard_visible=dashboard_visible == "on", show_empty=show_empty == "on", max_items=max(1, min(max_items, 50)), sort_mode=sort_mode, value_mode=value_mode, value_mappings=mappings, excluded_statuses=selected_statuses, created_by=_changed_by(request), is_system=False) db.add(chart) try: db.commit() except IntegrityError: db.rollback(); raise HTTPException(400, "Ein Diagramm mit diesem Namen existiert bereits.") return RedirectResponse("/charts/manage?toast_success=Diagramm angelegt", status_code=303) @app.get("/charts/{chart_id}/edit") def chart_edit(chart_id: int, request: Request, db: Session = Depends(get_db)): _require_admin(request) chart = db.get(ChartDefinition, chart_id) if not chart: raise HTTPException(404, "Diagramm nicht gefunden") return templates.TemplateResponse("chart_form.html", _chart_form_context(request, db, chart)) @app.post("/charts/{chart_id}/edit") def chart_update(chart_id: int, request: Request, name: str = Form(...), description: str = Form(""), chart_type: str = Form("bar"), group_field: str = Form("category"), stack_field: str = Form(""), dashboard_visible: str | None = Form(None), show_empty: str | None = Form(None), max_items: int = Form(12), sort_mode: str = Form("count_desc"), value_mode: str = Form("raw"), value_mappings_text: str = Form(""), excluded_statuses: list[str] = Form([]), db: Session = Depends(get_db)): _require_admin(request) chart = db.get(ChartDefinition, chart_id) if not chart: raise HTTPException(404, "Diagramm nicht gefunden") if chart_type not in CHART_TYPES or group_field not in CHART_FIELDS or (chart_type == "stacked_bar" and stack_field not in CHART_FIELDS): raise HTTPException(400, "Ungültige Diagrammkonfiguration") if value_mode not in {"raw", "automatic", "custom"}: raise HTTPException(400, "Ungültige Wertedarstellung") mappings = _parse_value_mappings(value_mappings_text) valid_statuses = {row.name for row in db.query(StatusOption).all()} chart.name=name.strip(); chart.description=description.strip() or None; chart.chart_type=chart_type; chart.group_field=group_field; chart.stack_field=stack_field if chart_type == "stacked_bar" else None; chart.dashboard_visible=dashboard_visible == "on"; chart.show_empty=show_empty == "on"; chart.max_items=max(1,min(max_items,50)); chart.sort_mode=sort_mode; chart.value_mode=value_mode; chart.value_mappings=mappings; chart.excluded_statuses=[value for value in excluded_statuses if value in valid_statuses] try: db.commit() except IntegrityError: db.rollback(); raise HTTPException(400, "Ein Diagramm mit diesem Namen existiert bereits.") return RedirectResponse("/charts/manage?toast_success=Diagramm gespeichert", status_code=303) @app.post("/charts/{chart_id}/duplicate") def chart_duplicate(chart_id: int, request: Request, db: Session = Depends(get_db)): _require_admin(request) source = db.get(ChartDefinition, chart_id) if not source: raise HTTPException(404, "Diagramm nicht gefunden") base_name = f"{source.name} (Kopie)" name = base_name number = 2 while db.query(ChartDefinition).filter(ChartDefinition.name == name).first(): name = f"{source.name} (Kopie {number})" number += 1 duplicate = ChartDefinition(name=name, description=source.description, chart_type=source.chart_type, group_field=source.group_field, stack_field=source.stack_field, dashboard_visible=False, show_empty=source.show_empty, max_items=source.max_items, sort_mode=source.sort_mode, value_mode=source.value_mode, value_mappings=list(source.value_mappings or []), excluded_statuses=list(source.excluded_statuses or []), created_by=_changed_by(request), is_system=False) db.add(duplicate); db.commit(); db.refresh(duplicate) return RedirectResponse(f"/charts/{duplicate.id}/edit?toast_success=Diagramm dupliziert", status_code=303) @app.post("/charts/{chart_id}/delete") def chart_delete(chart_id: int, request: Request, db: Session = Depends(get_db)): _require_admin(request) chart=db.get(ChartDefinition, chart_id) if not chart: raise HTTPException(404, "Diagramm nicht gefunden") if chart.is_system: return RedirectResponse("/charts/manage?toast_warning=Standarddiagramme können bearbeitet, aber nicht gelöscht werden", status_code=303) db.delete(chart); db.commit() return RedirectResponse("/charts/manage?toast_success=Diagramm gelöscht", status_code=303) @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, request), "duplicate_mode": False, "parent_candidates": db.query(Asset).order_by(Asset.name).all(), "custom_values": {}}) @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, request), "duplicate_mode": True, "parent_candidates": db.query(Asset).filter(Asset.id != original.id).order_by(Asset.name).all(), "custom_values": {}, }) @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) and f.field_name != "parent_asset_id" and not _definition_readonly(f)} for field in category.visible_fields: if field.field_name not in data or not field.definition: continue if field.definition.data_type == "date": data[field.field_name] = _normalize_date_value(data[field.field_name]) elif field.definition.data_type == "datetime": data[field.field_name] = _normalize_datetime_value(data[field.field_name]) data["room_number"] = _normalize_optional_text(form.get("room_number")) parent_raw = _normalize_optional_text(form.get("parent_asset_id")) data["parent_asset_id"] = int(parent_raw) if parent_raw else None if not data.get("name"): raise HTTPException(400, "Bezeichnung fehlt") _validate_asset_identifiers(db, asset_tag=data.get("asset_tag"), manufacturer=data.get("manufacturer"), serial_number=data.get("serial_number")) asset = Asset(category_id=category_id, image_path=save_upload(image), **data) db.add(asset) try: db.flush() for field in category.visible_fields: if field.active and field.definition and not field.definition.is_system and not field.definition.readonly: _custom_value_set(asset, field.definition, form.get(field.field_name), db) created_changes = {_definition_label(field): {"old": "", "new": _display_value(getattr(asset, field.field_name, None))} for field in category.visible_fields if field.active and hasattr(asset, field.field_name) 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 = _sort_localized(db.query(Category).options(joinedload(Category.visible_fields)).all(), "name", request) 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, "import_only_active_fields": False}, ) @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).filter(Category.id == category_id).first() if not category: raise HTTPException(404, "Kategorie nicht gefunden") definitions = ( db.query(FieldDefinition) .filter( FieldDefinition.is_active.is_(True), FieldDefinition.excel_import.is_(True), FieldDefinition.readonly.is_(False), ) .order_by(FieldDefinition.sort_order, FieldDefinition.label) .all() ) import_definitions = [definition for definition in definitions if definition.field_name != "name"] headers = ["Asset-ID", "Kategorie", "Bezeichnung"] + [definition.label for definition in import_definitions] 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"), import_only_active_fields: str | None = Form(None), import_mode: str = Form("import"), db: Session = Depends(get_db), ): _require_admin(request) dry_run = import_mode == "dry_run" category = db.query(Category).options(joinedload(Category.visible_fields)).filter(Category.id == category_id).first() categories = _sort_localized(db.query(Category).options(joinedload(Category.visible_fields)).all(), "name", request) 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.") # Excel columns are recognized from the global field catalog. Category # visibility is only applied when the optional checkbox is enabled. import_definitions = ( db.query(FieldDefinition) .filter( FieldDefinition.is_active.is_(True), FieldDefinition.excel_import.is_(True), FieldDefinition.readonly.is_(False), ) .order_by(FieldDefinition.sort_order, FieldDefinition.label) .all() ) definitions_by_name = {definition.field_name: definition for definition in import_definitions} field_map: dict[str, str] = { "asset-id": "asset_id", "asset_id": "asset_id", "id": "asset_id", "bezeichnung": "name", "name": "name", "kategorie": "category", "category": "category", "category_id": "category", } for definition in import_definitions: field_map[definition.label.strip().casefold()] = definition.field_name field_map[definition.field_name.strip().casefold()] = definition.field_name categories_by_id = {item.id: item for item in categories} categories_by_name = {item.name.strip().casefold(): item for item in categories} 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] = [] row_results: list[dict[str, Any]] = [] changed_by = _changed_by(request) for excel_row_number, values in enumerate(rows_iter, start=2): raw_data: dict[str, str | None] = {} for index, value in enumerate(values): if index >= len(columns): break field_name = columns[index] if field_name: raw_data[field_name] = _excel_cell_value(value) if not any(value not in (None, "") for value in raw_data.values()): continue asset_id_value = raw_data.pop("asset_id", None) category_value = raw_data.pop("category", None) category_was_supplied = category_value not in (None, "") row_category = category if category_was_supplied: try: row_category = categories_by_id.get(int(category_value)) except (TypeError, ValueError): row_category = categories_by_name.get(str(category_value).strip().casefold()) if row_category is None: message = f"Kategorie ‚{category_value}‘ wurde nicht gefunden" errors.append(f"Zeile {excel_row_number}: {message}") row_results.append({"row": excel_row_number, "asset_id": "", "action": "Fehler", "reason": message}) continue if import_only_active_fields == "on": allowed_fields = { field.field_name for field in row_category.visible_fields if field.active and field.definition and field.definition.is_active and field.definition.excel_import and not field.definition.readonly } | {"name"} else: allowed_fields = set(definitions_by_name) | {"name"} # Empty cells are intentionally not applied to updates. data = { name: value for name, value in raw_data.items() if name in allowed_fields and value not in (None, "") } for field_name, value in list(data.items()): definition = definitions_by_name.get(field_name) if definition and definition.data_type == "date": data[field_name] = _normalize_date_value(value) elif definition and definition.data_type == "datetime": data[field_name] = _normalize_datetime_value(value) system_data = {name: value for name, value in data.items() if hasattr(Asset, name)} custom_data = { name: value for name, value in data.items() if name in definitions_by_name and not definitions_by_name[name].is_system } if asset_id_value not in (None, ""): try: parsed_asset_id = int(str(asset_id_value).strip()) except (TypeError, ValueError): message = f"Asset-ID ‚{asset_id_value}‘ ist ungültig" errors.append(f"Zeile {excel_row_number}: {message}") row_results.append({"row": excel_row_number, "asset_id": str(asset_id_value or ""), "action": "Fehler", "reason": message}) continue existing = db.query(Asset).filter(Asset.id == parsed_asset_id).first() if existing is None: message = f"Asset-ID {parsed_asset_id} wurde nicht gefunden" errors.append(f"Zeile {excel_row_number}: {message}") row_results.append({"row": excel_row_number, "asset_id": parsed_asset_id, "action": "Fehler", "reason": message}) continue changes: dict[str, dict[str, str]] = {} if category_was_supplied and existing.category_id != row_category.id: changes["Kategorie"] = { "old": existing.category.name if existing.category else str(existing.category_id), "new": row_category.name, } existing.category_id = row_category.id for field_name, value in system_data.items(): old_value = getattr(existing, field_name, None) if _display_value(old_value) != _display_value(value): definition = definitions_by_name.get(field_name) label = definition.label if definition else ASSET_FIELDS.get(field_name, field_name) changes[label] = { "old": _display_value(old_value), "new": _display_value(value), } setattr(existing, field_name, value) for field_name, value in custom_data.items(): definition = definitions_by_name[field_name] old_value = _custom_value_get(existing, definition, db) if _display_value(old_value) != _display_value(value): changes[definition.label] = { "old": _display_value(old_value), "new": _display_value(value), } _custom_value_set(existing, definition, value, db) if changes: _record_asset_history( db, existing, changes, source="excel-import-update", changed_by=changed_by, ) updated += 1 changed_labels = ", ".join(changes.keys()) row_results.append({ "row": excel_row_number, "asset_id": existing.id, "action": "Aktualisiert", "reason": f"Geänderte Felder: {changed_labels}", }) else: skipped += 1 row_results.append({ "row": excel_row_number, "asset_id": existing.id, "action": "Übersprungen", "reason": "Keine Änderungen; alle ausgefüllten Werte entsprechen bereits dem Datensatz.", }) continue # Without an asset ID, always create a new record. if not system_data.get("name"): message = "Für ein neues Asset fehlt die Bezeichnung" errors.append(f"Zeile {excel_row_number}: {message}") row_results.append({"row": excel_row_number, "asset_id": "", "action": "Fehler", "reason": message}) continue try: with db.begin_nested(): asset = Asset(category_id=row_category.id, **system_data) db.add(asset) db.flush() for field_name, value in custom_data.items(): _custom_value_set(asset, definitions_by_name[field_name], value, db) created_changes = { "Kategorie": {"old": "", "new": row_category.name}, **{ (definitions_by_name[name].label if name in definitions_by_name else ASSET_FIELDS.get(name, name)): {"old": "", "new": _display_value(value)} for name, value in data.items() }, } _record_asset_history( db, asset, created_changes, source="excel-import-create", changed_by=changed_by, ) created += 1 row_results.append({ "row": excel_row_number, "asset_id": asset.id, "action": "Neu angelegt", "reason": f"Neues Asset in Kategorie {row_category.name}", }) except IntegrityError: message = ( "Das neue Asset verletzt eine Eindeutigkeitsregel " "(zum Beispiel Inventarnummer, Seriennummer oder MeshCentral Node-ID)" ) errors.append(f"Zeile {excel_row_number}: {message}") row_results.append({"row": excel_row_number, "asset_id": "", "action": "Fehler", "reason": message}) continue if dry_run: # Flushes and nested savepoints above provide the same validation as a # real import. The outer transaction is rolled back so no asset, # custom field value or history entry remains stored. db.rollback() else: 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, "row_results": row_results, "dry_run": dry_run, } return templates.TemplateResponse( "assets_import.html", {"request": request, "categories": categories, "selected": category, "result": result, "import_only_active_fields": import_only_active_fields == "on", "import_mode": import_mode}, ) @app.get("/asset-tree") def asset_tree( request: Request, root_id: str | None = None, name_filter: str = "", hide_without_parent: str | None = None, db: Session = Depends(get_db), ): """Display the filtered device hierarchy. Multiple categories are passed as repeated ``category_id`` query parameters. Filters apply to every device, not only to root nodes. If the immediate parent node is filtered out, the device is rendered as a temporary root so it remains visible in the hierarchy. """ try: parsed_root_id = int(root_id) if root_id not in (None, "") else None except (TypeError, ValueError): parsed_root_id = None selected_category_ids: list[int] = [] for value in request.query_params.getlist("category_id"): try: category_id = int(value) except (TypeError, ValueError): continue if category_id not in selected_category_ids: selected_category_ids.append(category_id) # First load all assets visible to the signed-in user. Filtering # is then performed in Python so matches at any hierarchy # depth are not separated from their parents by a SQL query # or accidentally restricted to root nodes only. all_assets = ( _apply_asset_access(db.query(Asset), request) .options(joinedload(Asset.category)) .order_by(Asset.name) .all() ) all_by_id = {asset.id: asset for asset in all_assets} all_children: dict[int | None, list[Asset]] = {} for asset in all_assets: parent_key = asset.parent_asset_id if asset.parent_asset_id in all_by_id else None all_children.setdefault(parent_key, []).append(asset) # When a top-level node is selected, include only its subtree. scope_ids: set[int] selected_root = all_by_id.get(parsed_root_id) if parsed_root_id else None if selected_root: scope_ids = set() pending = [selected_root.id] while pending: current_id = pending.pop() if current_id in scope_ids: continue scope_ids.add(current_id) pending.extend(child.id for child in all_children.get(current_id, [])) else: scope_ids = set(all_by_id) needle = name_filter.strip().casefold() selected_categories = set(selected_category_ids) visible_assets: list[Asset] = [] for asset in all_assets: if asset.id not in scope_ids: continue category_matches = not selected_categories or asset.category_id in selected_categories name_matches = not needle or needle in (asset.name or "").casefold() if category_matches and name_matches: visible_assets.append(asset) # Keep the explicitly selected top-level node visible for orientation, # even when it does not itself match the name or category filter. if selected_root and selected_root not in visible_assets: visible_assets.insert(0, selected_root) visible_ids = {asset.id for asset in visible_assets} by_parent: dict[int | None, list[Asset]] = {} for asset in visible_assets: parent_key = asset.parent_asset_id if asset.parent_asset_id in visible_ids else None by_parent.setdefault(parent_key, []).append(asset) for children in by_parent.values(): children.sort(key=lambda item: (item.name or "").casefold()) hide_unassigned = str(hide_without_parent or "").lower() in {"1", "true", "on", "yes"} if selected_root: roots = [selected_root] # Also render matches whose intermediate nodes were filtered out as # separate roots so no match is lost. roots.extend( asset for asset in by_parent.get(None, []) if asset.id != selected_root.id ) else: roots = list(by_parent.get(None, [])) if hide_unassigned: # Hide only genuine standalone devices without a stored parent device. # Devices that appear as roots only because of filtering # remain visible. A selected top-level node also remains visible. roots = [ asset for asset in roots if asset.id == parsed_root_id or asset.parent_asset_id is not None or bool(by_parent.get(asset.id)) ] categories = db.query(Category).order_by(Category.name).all() actual_roots = [ asset for asset in all_assets if asset.parent_asset_id is None or asset.parent_asset_id not in all_by_id ] actual_roots.sort(key=lambda item: (item.name or "").casefold()) return templates.TemplateResponse( "asset_tree.html", { "request": request, "roots": roots, "by_parent": by_parent, "categories": categories, "all_roots": actual_roots, "selected_root_id": parsed_root_id, "selected_category_ids": selected_category_ids, "name_filter": name_filter, "hide_without_parent": hide_unassigned, }, ) def _inventory_fallback(asset: Asset) -> tuple[dict, list | dict, dict]: raw = asset.mesh_source_data if isinstance(asset.mesh_source_data, dict) else {} hardware = asset.mesh_hardware_data if isinstance(getattr(asset, "mesh_hardware_data", None), dict) else {} software = getattr(asset, "mesh_software_data", None) # Rebuild from the raw snapshot whenever possible. This upgrades older snapshots # with language-neutral i18n keys immediately, without waiting for another sync. if raw: try: from app.meshcentral import _build_hardware_inventory hardware = _build_hardware_inventory(raw) except Exception: if not hardware: hardware = (((raw.get("sys") or {}).get("hardware")) if isinstance(raw.get("sys"), dict) else {}) or {} if not isinstance(software, (list, dict)) or not software: candidates = [] sys_data = raw.get("sys") if isinstance(raw.get("sys"), dict) else {} windows = (((sys_data.get("hardware") or {}).get("windows")) if isinstance(sys_data.get("hardware"), dict) else {}) or {} for candidate in (sys_data.get("software"), windows.get("software") if isinstance(windows, dict) else None, raw.get("software"), raw.get("installedSoftware"), raw.get("installedApplications")): if isinstance(candidate, (list, dict)) and candidate: software = candidate break else: software = [] return hardware, software, raw def _software_table(software: list | dict) -> tuple[list[str], list[dict]]: if isinstance(software, dict): if all(isinstance(value, dict) for value in software.values()): rows = [{"Name": key, **value} for key, value in software.items()] else: rows = [{"Eigenschaft": key, "Wert": value} for key, value in software.items()] elif isinstance(software, list): rows = [item if isinstance(item, dict) else {"Wert": item} for item in software] else: rows = [] preferred = ["Name", "DisplayName", "name", "Version", "DisplayVersion", "version", "Publisher", "publisher", "InstallDate", "installDate"] keys = [] for key in preferred: if any(key in row for row in rows) and key not in keys: keys.append(key) for row in rows: for key in row.keys(): if key not in keys: keys.append(key) return keys, rows @app.get("/assets/{asset_id:int}") 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() hardware_inventory, software_inventory, raw_inventory = _inventory_fallback(asset) # The software tab on the asset detail page uses the dedicated callback # inventory. MeshCentral raw details remain available independently. stored_software = ( db.query(InstalledSoftware) .filter(InstalledSoftware.asset_id == asset.id) .order_by(InstalledSoftware.name.asc(), InstalledSoftware.version.asc()) .all() ) latest_software_job = ( db.query(SoftwareJob) .options(joinedload(SoftwareJob.package)) .filter(SoftwareJob.asset_id == asset.id) .order_by(SoftwareJob.created_at.desc()) .first() ) 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(), "parent_asset": asset.parent_asset, "child_assets": sorted(asset.child_assets, key=lambda item: item.name.casefold()), "custom_values": { f.field_name: _custom_value_get(asset, f.definition, db) for f in active_fields if f.definition and not f.definition.is_system }, "hardware_inventory": hardware_inventory, "software_inventory": software_inventory, "installed_software": stored_software, "latest_software_job": latest_software_job, "raw_inventory": raw_inventory, "asset_platform": detect_platform(asset), "job_definitions": ( db.query(JobDefinition).options(selectinload(JobDefinition.parameters)) .filter(JobDefinition.enabled.is_(True), JobDefinition.platform.in_([detect_platform(asset), "all"])) .order_by(JobDefinition.name).all() if _is_admin(request) and asset.mesh_node_id and detect_platform(asset) != "unknown" else [] ), "job_parameter_overrides": ({x.definition_parameter_id:x.value_text for x in db.query(JobDefinitionAssetParameter).filter(JobDefinitionAssetParameter.asset_id==asset.id).all()} if _is_admin(request) else {}), }) def _reset_mesh_link_runtime_state(asset: Asset) -> None: """Clear only connection-derived runtime state after a manual relink. Inventory and normal asset fields intentionally remain untouched until the next regular MeshCentral synchronization applies the configured mappings. """ asset.mesh_online = None asset.mesh_online_state = "unknown" asset.mesh_presence_updated_at = None asset.mesh_last_online_at = None asset.mesh_last_seen = None asset.mesh_presence_source = None asset.mesh_last_sync = None def _mesh_link_asset(db: Session, asset_id: int) -> Asset: asset = db.query(Asset).filter(Asset.id == asset_id).first() if not asset: raise HTTPException(404, "Asset nicht gefunden") return asset @app.get("/assets/{asset_id}/mesh-link") def asset_mesh_link_page(asset_id: int, request: Request, db: Session = Depends(get_db)): _require_admin(request) asset = _mesh_link_asset(db, asset_id) try: devices = fetch_device_summaries_for_linking() except RuntimeError as exc: return RedirectResponse( f"/assets/{asset.id}?toast_error=" + quote( _translate_request( request, "mesh.link.fetch_failed", "The MeshCentral device list could not be loaded: {error}", error=str(exc), ) ), status_code=303, ) node_ids = [item["node_id"] for item in devices if item.get("node_id")] assigned_rows = ( db.query(Asset.id, Asset.name, Asset.mesh_node_id) .filter(Asset.mesh_node_id.in_(node_ids)) .all() if node_ids else [] ) assigned = {str(row.mesh_node_id): {"asset_id": row.id, "asset_name": row.name} for row in assigned_rows if row.mesh_node_id} available_devices: list[dict[str, Any]] = [] current_device: dict[str, Any] | None = None for item in devices: row = dict(item) owner = assigned.get(row["node_id"]) row["assigned_asset_id"] = owner["asset_id"] if owner else None row["assigned_asset_name"] = owner["asset_name"] if owner else None row["is_current"] = row["node_id"] == (asset.mesh_node_id or "") if row["is_current"]: current_device = row continue if owner is None: available_devices.append(row) return templates.TemplateResponse( "asset_mesh_link.html", { "request": request, "asset": asset, "current_device": current_device, "available_devices": available_devices, }, ) @app.post("/assets/{asset_id}/mesh-link") def asset_mesh_link_save( asset_id: int, request: Request, node_id: str = Form(...), db: Session = Depends(get_db), ): _require_admin(request) asset = _mesh_link_asset(db, asset_id) requested_node_id = str(node_id or "").strip() if not requested_node_id: return RedirectResponse( f"/assets/{asset.id}/mesh-link?toast_error=" + quote(_translate_request(request, "mesh.link.select_required", "Select a MeshCentral device.")), status_code=303, ) try: device_ids = {item["node_id"] for item in fetch_device_summaries_for_linking() if item.get("node_id")} except RuntimeError as exc: return RedirectResponse( f"/assets/{asset.id}?toast_error=" + quote( _translate_request( request, "mesh.link.fetch_failed", "The MeshCentral device list could not be loaded: {error}", error=str(exc), ) ), status_code=303, ) if requested_node_id not in device_ids: return RedirectResponse( f"/assets/{asset.id}/mesh-link?toast_error=" + quote(_translate_request(request, "mesh.link.not_found", "The selected MeshCentral device no longer exists.")), status_code=303, ) owner = db.query(Asset).filter(Asset.mesh_node_id == requested_node_id, Asset.id != asset.id).first() if owner: message = _translate_request( request, "mesh.link.already_assigned", "This MeshCentral device is already linked to asset {asset}.", asset=owner.name, ) return RedirectResponse( f"/assets/{asset.id}/mesh-link?toast_error=" + quote(message), status_code=303, ) old_node_id = asset.mesh_node_id or "" if old_node_id == requested_node_id: return RedirectResponse( f"/assets/{asset.id}?toast_warning=" + quote(_translate_request(request, "mesh.link.unchanged", "The MeshCentral link is already set to this device.")), status_code=303, ) asset.mesh_node_id = requested_node_id _reset_mesh_link_runtime_state(asset) asset.mesh_sync_status = "pending" asset.mesh_sync_message = _translate_request( request, "mesh.link.pending_sync", "MeshCentral link changed manually; synchronization is pending.", ) _record_asset_history( db, asset, { _translate_request(request, "field.mesh_node_id", "MeshCentral node ID"): { "old": old_node_id, "new": requested_node_id, } }, source="mesh-link-manual", changed_by=_changed_by(request), ) try: db.commit() except IntegrityError: db.rollback() return RedirectResponse( f"/assets/{asset.id}/mesh-link?toast_error=" + quote(_translate_request(request, "mesh.link.conflict", "The MeshCentral node ID was assigned by another operation.")), status_code=303, ) return RedirectResponse( f"/assets/{asset.id}?toast_success=" + quote(_translate_request(request, "mesh.link.changed", "MeshCentral link changed.")), status_code=303, ) @app.post("/assets/{asset_id}/mesh-unlink") def asset_mesh_unlink(asset_id: int, request: Request, db: Session = Depends(get_db)): _require_admin(request) asset = _mesh_link_asset(db, asset_id) old_node_id = asset.mesh_node_id or "" if not old_node_id: return RedirectResponse( f"/assets/{asset.id}?toast_warning=" + quote(_translate_request(request, "mesh.link.already_unlinked", "This asset is not linked to MeshCentral.")), status_code=303, ) asset.mesh_node_id = None _reset_mesh_link_runtime_state(asset) asset.mesh_sync_status = "unlinked" asset.mesh_sync_message = _translate_request(request, "mesh.link.unlinked_message", "MeshCentral link removed manually.") _record_asset_history( db, asset, { _translate_request(request, "field.mesh_node_id", "MeshCentral node ID"): { "old": old_node_id, "new": "", } }, source="mesh-unlink-manual", changed_by=_changed_by(request), ) db.commit() return RedirectResponse( f"/assets/{asset.id}?toast_success=" + quote(_translate_request(request, "mesh.link.unlinked", "MeshCentral link removed.")), status_code=303, ) def _presence_payload(asset: Asset) -> dict[str, Any]: return { "asset_id": asset.id, "has_mesh_node_id": bool(asset.mesh_node_id), "state": asset.mesh_online_state or "unknown", "online": asset.mesh_online, "updated_at": _utc_iso(asset.mesh_presence_updated_at) or None, "last_online_at": _utc_iso(asset.mesh_last_online_at) or None, "last_seen_at": _utc_iso(asset.mesh_presence_updated_at if (asset.mesh_online_state or "unknown") == "online" else asset.mesh_last_seen) or None, "historical_last_seen_at": _utc_iso(asset.mesh_last_seen) or None, "source": asset.mesh_presence_source, } @app.get("/api/assets/presence") def assets_presence(request: Request, db: Session = Depends(get_db)): assets = ( _apply_asset_access(db.query(Asset), request) .filter(Asset.mesh_node_id.isnot(None), Asset.mesh_node_id != "") .all() ) return {"items": [_presence_payload(asset) for asset in assets]} @app.get("/api/assets/{asset_id}/presence") def asset_presence(asset_id: int, request: Request, db: Session = Depends(get_db)): asset = _get_visible_asset(db, request, asset_id) return _presence_payload(asset) @app.get("/assets/{asset_id}/mesh-inventory.json") def asset_mesh_inventory_json(asset_id: int, request: Request, db: Session = Depends(get_db)): asset = _apply_asset_access(db.query(Asset), request).filter(Asset.id == asset_id).first() if not asset: raise HTTPException(404, "Asset nicht gefunden") hardware, software, raw = _inventory_fallback(asset) payload = { "asset_id": asset.id, "asset_name": asset.name, "mesh_node_id": asset.mesh_node_id, "updated_at": _utc_iso(asset.mesh_inventory_updated_at) or None, "hardware": hardware, "software": software, "raw": raw, } filename = re.sub(r"[^A-Za-z0-9._-]+", "_", asset.name or f"asset-{asset.id}") + "-mesh-inventory.json" return Response(content=json.dumps(payload, ensure_ascii=False, indent=2, default=str), media_type="application/json", headers={"Content-Disposition": f'attachment; filename="{filename}"'}) @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, request), "duplicate_mode": False, "parent_candidates": db.query(Asset).filter(Asset.id != asset.id).order_by(Asset.name).all(), "custom_values": {f.field_name: _custom_value_get(asset, f.definition, db) for f in selected.visible_fields if f.definition and not f.definition.is_system}}) @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)): _require_admin(request) 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]] = {} # mesh_node_id is a protected system field. On the administrator-only # edit page it may nevertheless be changed directly or cleared. Do this # separately from the generic field loop because readonly system fields # are intentionally skipped there. if "mesh_node_id" in form: old_node_id = _normalize_optional_text(asset.mesh_node_id) requested_node_id = _normalize_optional_text(form.get("mesh_node_id")) if old_node_id != requested_node_id: if requested_node_id: owner = ( db.query(Asset.id, Asset.name) .filter(Asset.mesh_node_id == requested_node_id, Asset.id != asset.id) .first() ) if owner: message = _translate_request( request, "mesh.link.already_assigned", "This MeshCentral device is already linked to asset {asset}.", ).replace("{asset}", owner.name or str(owner.id)) return RedirectResponse( f"/assets/{asset.id}/edit?toast_error=" + quote(message), status_code=303, ) changes[_translate_request(request, "field.mesh_node_id", "MeshCentral node ID")] = { "old": old_node_id or "", "new": requested_node_id or "", } asset.mesh_node_id = requested_node_id _reset_mesh_link_runtime_state(asset) if requested_node_id: asset.mesh_sync_status = "pending" asset.mesh_sync_message = _translate_request( request, "mesh.link.pending_sync", "MeshCentral link changed manually; synchronization is pending.", ) else: asset.mesh_sync_status = "unlinked" asset.mesh_sync_message = _translate_request( request, "mesh.link.unlinked_message", "MeshCentral link removed manually.", ) 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) and field.field_name not in {"room_number", "parent_asset_id"} and not _definition_readonly(field) ): old_value = getattr(asset, field.field_name) new_value = form.get(field.field_name) or None if field.definition and field.definition.data_type == "date": new_value = _normalize_date_value(new_value) elif field.definition and field.definition.data_type == "datetime": new_value = _normalize_datetime_value(new_value) if _display_value(old_value) != _display_value(new_value): changes[_definition_label(field)] = {"old": _display_value(old_value), "new": _display_value(new_value)} setattr(asset, field.field_name, new_value) new_room = _normalize_optional_text(form.get("room_number")) if _display_value(asset.room_number) != _display_value(new_room): changes["Raumnummer"] = {"old": _display_value(asset.room_number), "new": _display_value(new_room)} asset.room_number = new_room parent_raw = _normalize_optional_text(form.get("parent_asset_id")) new_parent_id = int(parent_raw) if parent_raw else None if new_parent_id and _would_create_parent_cycle(db, asset.id, new_parent_id): raise HTTPException(400, "Diese Zuordnung würde einen Kreis in der Baustruktur erzeugen.") current_parent_id = int(asset.parent_asset_id) if asset.parent_asset_id not in (None, "") else None if current_parent_id != new_parent_id: old_parent = db.query(Asset).filter(Asset.id == current_parent_id).first() if current_parent_id else None new_parent = db.query(Asset).filter(Asset.id == new_parent_id).first() if new_parent_id else None changes["Übergeordnetes Gerät"] = {"old": old_parent.name if old_parent else "", "new": new_parent.name if new_parent else ""} asset.parent_asset_id = new_parent_id for field in category.visible_fields: if field.active and field.definition and not field.definition.is_system and not field.definition.readonly: old_custom = _custom_value_get(asset, field.definition, db) new_custom = form.get(field.field_name) if _display_value(old_custom) != _display_value(new_custom): changes[_definition_label(field)] = {"old": _display_value(old_custom), "new": _display_value(new_custom)} _custom_value_set(asset, field.definition, new_custom, db) _validate_asset_identifiers(db, asset_tag=asset.asset_tag, manufacturer=asset.manufacturer, serial_number=asset.serial_number, exclude_id=asset.id) 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 = ["Asset-ID"] + ((["Kategorie"] if not selected else []) + [field.label for field in fields]) if not any(field.field_name == "name" for field in fields): headers.insert(1, "Bezeichnung") rows = [] for asset in assets: row = [asset.id] + (([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(1, 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) MERGE_PREVIEW_FIELDS = [ ("id", "Asset-ID"), ("category", "Kategorie"), ("name", "Bezeichnung"), ("model", "Modell"), ("serial_number", "Seriennummer"), ("hostname", "Computername"), ("notes", "Notizen"), ("mesh_node_id", "MeshCentral Node-ID"), ] MERGE_FILL_FIELDS = ["model", "serial_number", "hostname", "notes", "mesh_node_id"] def _merge_display_value(asset: Asset, field_name: str) -> str: if field_name == "id": return str(asset.id) if field_name == "category": return asset.category.name if asset.category else "" return _display_value(getattr(asset, field_name, None)) def _merge_preview(assets: list[Asset]) -> list[dict]: result = [] for field_name, label in MERGE_PREVIEW_FIELDS: source = assets[0] value = _merge_display_value(source, field_name) if field_name not in {"id", "category", "name"} and not value: for candidate in assets[1:]: candidate_value = _merge_display_value(candidate, field_name) if candidate_value: source = candidate value = candidate_value break result.append({ "field_name": field_name, "label": label, "value": value, "source_asset_id": source.id, }) return result def _ordered_merge_assets(db: Session, asset_ids: list[str]) -> list[Asset]: ids = _selected_asset_ids(asset_ids) if len(ids) < 2: raise HTTPException(400, "Bitte mindestens zwei Assets auswählen.") rows = db.query(Asset).options(joinedload(Asset.category)).filter(Asset.id.in_(ids)).all() by_id = {asset.id: asset for asset in rows} if len(by_id) != len(ids): raise HTTPException(404, "Mindestens ein ausgewähltes Asset wurde nicht gefunden.") return [by_id[asset_id] for asset_id in ids] @app.post("/assets/merge") def assets_merge_order(request: Request, asset_ids: list[str] = Form([]), db: Session = Depends(get_db)): _require_admin(request) assets = _ordered_merge_assets(db, asset_ids) return templates.TemplateResponse( "assets_merge_order.html", {"request": request, "assets": assets}, ) @app.post("/assets/merge/preview") def assets_merge_preview(request: Request, asset_ids: list[str] = Form([]), db: Session = Depends(get_db)): _require_admin(request) assets = _ordered_merge_assets(db, asset_ids) return templates.TemplateResponse( "assets_merge_preview.html", {"request": request, "assets": assets, "preview": _merge_preview(assets)}, ) @app.post("/assets/merge/apply") def assets_merge_apply(request: Request, asset_ids: list[str] = Form([]), db: Session = Depends(get_db)): _require_admin(request) assets = _ordered_merge_assets(db, asset_ids) target = assets[0] sources = assets[1:] source_ids = [asset.id for asset in sources] changes = {} for field_name in MERGE_FILL_FIELDS: if _display_value(getattr(target, field_name, None)): continue for source in sources: candidate = getattr(source, field_name, None) if not _display_value(candidate): continue if field_name == "mesh_node_id": # mesh_node_id is unique. When a manually imported asset stays # and absorbs a MeshCentral asset, the source must release the # node ID before the target can receive it. Without this flush, # PostgreSQL may update the target first and reject the merge. source.mesh_node_id = None db.flush() setattr(target, field_name, candidate) changes[field_name] = { "old": "", "new": _display_value(candidate), "source_asset_id": str(source.id), } break target_values = { row.field_definition_id: row for row in db.query(AssetFieldValue).filter(AssetFieldValue.asset_id == target.id).all() } for source in sources: for source_value in db.query(AssetFieldValue).filter(AssetFieldValue.asset_id == source.id).all(): existing = target_values.get(source_value.field_definition_id) current = "" if existing: current = _display_value( existing.value_text if existing.value_text is not None else existing.value_number if existing.value_number is not None else existing.value_boolean if existing.value_boolean is not None else existing.value_date if existing.value_date is not None else existing.value_datetime if existing.value_datetime is not None else existing.value_json ) if current: continue if existing is None: source_value.asset_id = target.id target_values[source_value.field_definition_id] = source_value else: existing.value_text = source_value.value_text existing.value_number = source_value.value_number existing.value_boolean = source_value.value_boolean existing.value_date = source_value.value_date existing.value_datetime = source_value.value_datetime existing.value_json = source_value.value_json db.delete(source_value) db.query(AssetHistory).filter(AssetHistory.asset_id.in_(source_ids)).update( {AssetHistory.asset_id: target.id}, synchronize_session=False ) db.query(SoftwareJob).filter(SoftwareJob.asset_id.in_(source_ids)).update( {SoftwareJob.asset_id: target.id}, synchronize_session=False ) # Compact job states are derived data. Rebuild them for the surviving asset # after the complete job history has been moved. db.query(AssetJobState).filter(AssetJobState.asset_id.in_([target.id, *source_ids])).delete(synchronize_session=False) db.flush() for merged_job in ( db.query(SoftwareJob) .filter(SoftwareJob.asset_id == target.id) .order_by(SoftwareJob.created_at.asc(), SoftwareJob.id.asc()) .all() ): sync_asset_job_state( db, merged_job, increment_execution=True, status_at=merged_job.finished_at or merged_job.callback_completed_at or merged_job.started_at or merged_job.sent_at or merged_job.created_at, ) existing_software = { (row.name.casefold(), (row.version or "").casefold(), (row.publisher or "").casefold(), (row.architecture or "").casefold()) for row in db.query(InstalledSoftware).filter(InstalledSoftware.asset_id == target.id).all() } for row in db.query(InstalledSoftware).filter(InstalledSoftware.asset_id.in_(source_ids)).all(): identity = (row.name.casefold(), (row.version or "").casefold(), (row.publisher or "").casefold(), (row.architecture or "").casefold()) if identity in existing_software: db.delete(row) else: row.asset_id = target.id existing_software.add(identity) db.query(Asset).filter(Asset.parent_asset_id.in_(source_ids)).update( {Asset.parent_asset_id: target.id}, synchronize_session=False ) if target.parent_asset_id in source_ids: target.parent_asset_id = None _record_asset_history( db, target, { "Duplikate zusammengeführt": { "old": ", ".join(str(asset_id) for asset_id in source_ids), "new": str(target.id), }, **changes, }, source="duplicate-merge", changed_by=_changed_by(request), ) for source in sources: db.delete(source) try: db.commit() except IntegrityError: db.rollback() message = _translate_request( request, "duplicates.merge_unique_error", "The duplicates could not be merged because a unique value is already assigned to another asset.", ) # Do not redirect back to /assets/merge/preview: that page is POST-only. # A GET redirect there caused ERR_TOO_MANY_REDIRECTS instead of a toast. return RedirectResponse(f"/assets?toast_error={quote(message)}", status_code=303) return RedirectResponse( f"/assets/{target.id}?toast_success={quote(f'{len(sources)} Duplikate wurden in Asset {target.id} zusammengeführt')}", status_code=303, ) @app.post("/assets/bulk-edit") def assets_bulk_edit_form(request: Request, asset_ids: list[str] = Form([]), category_id: int | None = Form(None), db: Session = Depends(get_db)): _require_admin(request) ids = _selected_asset_ids(asset_ids) if not ids: raise HTTPException(400, "Bitte mindestens ein Asset markieren.") assets = (db.query(Asset).options(joinedload(Asset.category).joinedload(Category.visible_fields)).filter(Asset.id.in_(ids)).order_by(Asset.name).all()) if len(assets) != len(ids): raise HTTPException(404, "Mindestens ein ausgewähltes Asset wurde nicht gefunden.") fields = [{"field_name": "category_id", "label": "Kategorie"}] + _bulk_edit_fields_for_assets(assets) categories = _sort_localized(db.query(Category).all(), "name", request) return templates.TemplateResponse("assets_bulk_edit.html", { "request": request, "assets": assets, "asset_ids": ids, "fields": fields, "status_options": _active_status_options(db, request), "parent_candidates": _sort_localized(db.query(Asset).filter(~Asset.id.in_(ids)).all(), "name", request), "categories": categories, "category_id": category_id, }) @app.post("/assets/bulk-edit/apply") def assets_bulk_edit_apply(request: Request, asset_ids: list[str] = Form([]), field_name: str = Form(...), new_value: str = Form(""), category_id: int | None = Form(None), db: Session = Depends(get_db)): _require_admin(request) ids = _selected_asset_ids(asset_ids) assets = (db.query(Asset).options(joinedload(Asset.category).joinedload(Category.visible_fields)).filter(Asset.id.in_(ids)).all()) if not ids or len(assets) != len(ids): raise HTTPException(400, "Die Auswahl ist leer oder nicht mehr vollständig vorhanden.") allowed = {"category_id": "Kategorie", **{item["field_name"]: item["label"] for item in _bulk_edit_fields_for_assets(assets)}} if field_name not in allowed: raise HTTPException(400, "Das gewählte Feld ist nicht in allen ausgewählten Gerätekategorien zugelassen.") value = _normalize_optional_text(new_value) parent_asset = None target_category = None if field_name == "category_id": try: target_category_id = int(value) if value else None except (TypeError, ValueError) as exc: raise HTTPException(400, "Die Kategorie ist ungültig.") from exc if target_category_id is None: raise HTTPException(400, "Bitte eine Kategorie auswählen.") target_category = db.query(Category).filter(Category.id == target_category_id).first() if not target_category: raise HTTPException(404, "Die gewählte Kategorie wurde nicht gefunden.") value = target_category_id if field_name == "parent_asset_id": try: parent_id = int(value) if value else None except (TypeError, ValueError) as exc: raise HTTPException(400, "Das übergeordnete Gerät ist ungültig.") from exc if parent_id in ids: raise HTTPException(400, "Ein ausgewähltes Asset kann nicht gleichzeitig als übergeordnetes Gerät für die gesamte Auswahl verwendet werden.") if parent_id is not None: parent_asset = db.query(Asset).filter(Asset.id == parent_id).first() if not parent_asset: raise HTTPException(404, "Das gewählte übergeordnete Gerät wurde nicht gefunden.") value = parent_id for asset in assets: if parent_id is not None and _would_create_parent_cycle(db, asset.id, parent_id): raise HTTPException(400, f"Die Zuordnung würde beim Asset {asset.name} einen Kreis in der Baumstruktur erzeugen.") changed = 0 for asset in assets: old_value = getattr(asset, field_name, None) if old_value == value or _display_value(old_value) == _display_value(value): continue if field_name == "parent_asset_id": old_parent_id = int(old_value) if old_value not in (None, "") else None old_parent = db.query(Asset).filter(Asset.id == old_parent_id).first() if old_parent_id else None history_old = old_parent.name if old_parent else "" history_new = parent_asset.name if parent_asset else "" elif field_name == "category_id": history_old = asset.category.name if asset.category else str(old_value or "") history_new = target_category.name else: history_old = _display_value(old_value) history_new = _display_value(value) setattr(asset, field_name, value) _record_asset_history(db, asset, {allowed[field_name]: {"old": history_old, "new": history_new}}, source="bulk-update", changed_by=_changed_by(request)) changed += 1 try: db.commit() except IntegrityError as exc: db.rollback() raise HTTPException(400, "Die Daten konnten nicht für alle Assets gespeichert werden.") from exc target = f"/assets?category_id={category_id}" if category_id else "/assets" return RedirectResponse(f"{target}{'&' if '?' in target else '?'}toast_success={quote(str(changed) + ' Assets aktualisiert')}", status_code=303) @app.post("/assets/bulk-delete") def assets_bulk_delete(request: Request, asset_ids: list[str] = Form([]), category_id: int | None = Form(None), db: Session = Depends(get_db)): _require_admin(request) ids = _selected_asset_ids(asset_ids) if not ids: raise HTTPException(400, "Bitte mindestens ein Asset markieren.") assets = db.query(Asset).filter(Asset.id.in_(ids)).all() if len(assets) != len(ids): raise HTTPException(404, "Mindestens ein ausgewähltes Asset wurde nicht gefunden.") 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) target = f"/assets?category_id={category_id}" if category_id else "/assets" return RedirectResponse(f"{target}{'&' if '?' in target else '?'}toast_success={quote(str(len(assets)) + ' Assets gelöscht')}", 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 = _get_visible_asset(db, request, asset_id) image_path = asset.image_path db.delete(asset) db.commit() delete_asset_image(image_path) return RedirectResponse("/assets", 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(selectinload(Category.visible_fields)) .order_by(Category.name) .all() ) asset_counts = dict( db.query(Asset.category_id, func.count(Asset.id)) .group_by(Asset.category_id) .all() ) return templates.TemplateResponse( "categories.html", {"request": request, "categories": categories, "asset_counts": asset_counts, "definitions": db.query(FieldDefinition).filter(FieldDefinition.is_active.is_(True)).order_by(FieldDefinition.sort_order, FieldDefinition.label).all()}, ) @app.post("/categories/bulk-edit") async def categories_bulk_edit( request: Request, category_ids: list[str] = Form([]), field_name: str = Form(...), field_value: str = Form(""), clear_value: str | None = Form(None), image: UploadFile | None = File(None), db: Session = Depends(get_db), ): _require_admin(request) ids = _selected_asset_ids(category_ids) if not ids: message = _translate_request(request, "categories.bulk.no_selection", "No categories selected") return RedirectResponse(f"/categories?toast_warning={quote(message)}", status_code=303) categories = db.query(Category).filter(Category.id.in_(ids)).all() if not categories: message = _translate_request(request, "categories.bulk.no_selection", "No categories selected") return RedirectResponse(f"/categories?toast_warning={quote(message)}", status_code=303) clear = clear_value == "on" if field_name == "name": if len(categories) != 1: message = _translate_request(request, "categories.bulk.name_single_only", "The name can only be changed for one category at a time") return RedirectResponse(f"/categories?toast_warning={quote(message)}", status_code=303) value = field_value.strip() if not value: message = _translate_request(request, "categories.bulk.name_required", "The category name must not be empty") return RedirectResponse(f"/categories?toast_warning={quote(message)}", status_code=303) categories[0].name = value elif field_name == "description": value = None if clear else (field_value.strip() or None) for category in categories: category.description = value elif field_name == "image": if clear: for category in categories: category.image_path = None else: new_image = save_upload(image) if not new_image: message = _translate_request(request, "categories.bulk.image_required", "Select an image or choose clear value") return RedirectResponse(f"/categories?toast_warning={quote(message)}", status_code=303) for category in categories: category.image_path = new_image else: raise HTTPException(400, _translate_request(request, "categories.bulk.unsupported_field", "Unsupported category field")) try: db.commit() except IntegrityError: db.rollback() message = _translate_request(request, "categories.bulk.name_duplicate", "The category name already exists") return RedirectResponse(f"/categories?toast_error={quote(message)}", status_code=303) message = _translate_request(request, "categories.bulk.updated", "Updated {count} categories", count=len(categories)) return RedirectResponse(f"/categories?toast_success={quote(message)}", status_code=303) @app.post("/categories/bulk-fields") async def categories_bulk_fields( request: Request, category_ids: list[str] = Form([]), operation: str = Form(...), field_ids: list[str] = Form([]), field_definition_id: str = Form(""), apply_required: str | None = Form(None), required_value: str | None = Form(None), apply_show_in_list: str | None = Form(None), show_in_list_value: str | None = Form(None), source_category_id: str = Form(""), db: Session = Depends(get_db), ): """Manage category field assignments without touching generic table behavior.""" _require_admin(request) ids = _selected_asset_ids(category_ids) if not ids: message = _translate_request(request, "categories.fields.no_selection", "No categories selected") return RedirectResponse(f"/categories?toast_warning={quote(message)}", status_code=303) categories = ( db.query(Category) .options(selectinload(Category.visible_fields).joinedload(CategoryField.definition)) .filter(Category.id.in_(ids)) .all() ) if not categories: message = _translate_request(request, "categories.fields.no_selection", "No categories selected") return RedirectResponse(f"/categories?toast_warning={quote(message)}", status_code=303) definitions = {item.id: item for item in db.query(FieldDefinition).filter(FieldDefinition.is_active.is_(True)).all()} selected_definition_ids = _selected_asset_ids(field_ids) changed = 0 if operation in {"add", "remove"}: selected_definitions = [definitions[item_id] for item_id in selected_definition_ids if item_id in definitions] if not selected_definitions: message = _translate_request(request, "categories.fields.choose_fields", "Select at least one field") return RedirectResponse(f"/categories?toast_warning={quote(message)}", status_code=303) for category in categories: by_definition = {link.field_definition_id: link for link in category.visible_fields if link.field_definition_id} by_name = {link.field_name: link for link in category.visible_fields} next_order = max((link.sort_order for link in category.visible_fields), default=-1) + 1 for definition in selected_definitions: link = by_definition.get(definition.id) or by_name.get(definition.field_name) if operation == "add": if link is None: link = CategoryField( category_id=category.id, field_name=definition.field_name, label=definition.label, field_definition_id=definition.id, active=True, required=definition.field_name == "name", show_in_list=definition.field_name == "name", readonly=definition.readonly, sort_order=next_order, ) next_order += 1 db.add(link) category.visible_fields.append(link) changed += 1 elif not link.active: link.active = True if definition.field_name == "name": link.required = True link.show_in_list = True changed += 1 else: if definition.field_name == "name": continue if link is not None and (link.active or link.required or link.show_in_list): link.active = False link.required = False link.show_in_list = False changed += 1 elif operation == "settings": try: definition_id = int(field_definition_id) except (TypeError, ValueError): definition_id = 0 definition = definitions.get(definition_id) if not definition: message = _translate_request(request, "categories.fields.choose_field", "Select a field") return RedirectResponse(f"/categories?toast_warning={quote(message)}", status_code=303) change_required = apply_required == "on" change_list = apply_show_in_list == "on" if not change_required and not change_list: message = _translate_request(request, "categories.fields.choose_setting", "Select at least one setting to change") return RedirectResponse(f"/categories?toast_warning={quote(message)}", status_code=303) required = required_value == "on" show_in_list = show_in_list_value == "on" for category in categories: link = next((item for item in category.visible_fields if item.field_definition_id == definition.id or item.field_name == definition.field_name), None) if link is None or not link.active: continue before = (link.required, link.show_in_list) if change_required and definition.field_name != "name" and not definition.readonly: link.required = required if change_list: link.show_in_list = True if definition.field_name == "name" else show_in_list if before != (link.required, link.show_in_list): changed += 1 elif operation == "copy": try: source_id = int(source_category_id) except (TypeError, ValueError): source_id = 0 source = ( db.query(Category) .options(selectinload(Category.visible_fields).joinedload(CategoryField.definition)) .filter(Category.id == source_id) .first() ) if not source: message = _translate_request(request, "categories.fields.choose_source", "Select a source category") return RedirectResponse(f"/categories?toast_warning={quote(message)}", status_code=303) source_links = sorted(source.visible_fields, key=lambda item: item.sort_order) for category in categories: if category.id == source.id: continue target_by_name = {item.field_name: item for item in category.visible_fields} source_names = {item.field_name for item in source_links} # A template is an exact field-configuration copy. Existing links are # retained but deactivated when absent from the source, preserving data. for target in category.visible_fields: if target.field_name not in source_names and target.field_name != "name": target.active = False target.required = False target.show_in_list = False for source_link in source_links: target = target_by_name.get(source_link.field_name) if target is None: target = CategoryField( category_id=category.id, field_name=source_link.field_name, label=source_link.label, field_definition_id=source_link.field_definition_id, readonly=source_link.readonly, ) db.add(target) category.visible_fields.append(target) target.active = source_link.active or source_link.field_name == "name" target.required = True if source_link.field_name == "name" else source_link.required target.show_in_list = True if source_link.field_name == "name" else source_link.show_in_list target.sort_order = source_link.sort_order changed += 1 else: raise HTTPException(400, _translate_request(request, "categories.fields.unsupported_operation", "Unsupported field operation")) db.commit() message = _translate_request(request, "categories.fields.updated", "Field configuration updated for {count} categories", count=changed) return RedirectResponse(f"/categories?toast_success={quote(message)}", status_code=303) @app.post("/categories/activate-default-fields") def categories_activate_default_fields( request: Request, category_ids: list[str] = Form([]), db: Session = Depends(get_db), ): _require_admin(request) ids = _selected_asset_ids(category_ids) if not ids: return RedirectResponse("/categories?toast_warning=Keine Kategorien ausgewählt", status_code=303) categories = ( db.query(Category) .options(selectinload(Category.visible_fields).joinedload(CategoryField.definition)) .filter(Category.id.in_(ids)) .all() ) default_definitions = ( db.query(FieldDefinition) .filter(FieldDefinition.is_active.is_(True), FieldDefinition.is_default.is_(True)) .all() ) if not default_definitions: return RedirectResponse("/categories?toast_warning=Es sind keine Standardfelder markiert", status_code=303) activated = 0 created_links = 0 for category in categories: by_definition = {link.field_definition_id: link for link in category.visible_fields if link.field_definition_id} by_name = {link.field_name: link for link in category.visible_fields} next_order = max((link.sort_order for link in category.visible_fields), default=-1) + 1 for definition in default_definitions: link = by_definition.get(definition.id) or by_name.get(definition.field_name) if link is None: link = CategoryField( category_id=category.id, field_name=definition.field_name, label=definition.label, field_definition_id=definition.id, active=True, required=definition.field_name == "name", show_in_list=True, readonly=definition.readonly, sort_order=next_order, ) next_order += 1 db.add(link) created_links += 1 else: if not link.active: link.active = True activated += 1 link.show_in_list = True if definition.field_name == "name": link.required = True db.commit() message = f"Standardfelder aktiviert: {activated} vorhandene und {created_links} neue Zuordnungen" return RedirectResponse(f"/categories?toast_success={quote(message)}", status_code=303) @app.get("/categories/new") def category_new(request: Request, db: Session = Depends(get_db)): _require_admin(request) definitions=db.query(FieldDefinition).filter(FieldDefinition.is_active.is_(True)).order_by(FieldDefinition.sort_order, FieldDefinition.label).all() return templates.TemplateResponse("category_form.html", {"request": request, "category": None, "definitions": definitions, "selected_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() definitions = db.query(FieldDefinition).filter(FieldDefinition.is_active.is_(True)).order_by(FieldDefinition.sort_order).all() for order, definition in enumerate(definitions): active = form.get(f"active_{definition.id}") == "on" or definition.field_name == "name" db.add(CategoryField(category_id=category.id, field_name=definition.field_name, label=definition.label, field_definition_id=definition.id, active=active, required=True if definition.field_name == "name" else form.get(f"required_{definition.id}") == "on", show_in_list=True if definition.field_name == "name" else form.get(f"list_{definition.id}") == "on", readonly=definition.readonly, 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("/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} definitions=db.query(FieldDefinition).filter(FieldDefinition.is_active.is_(True)).order_by(FieldDefinition.sort_order, FieldDefinition.label).all() return templates.TemplateResponse("category_form.html", {"request": request, "category": category, "definitions": definitions, "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.strip() or None new_image = save_upload(image) if new_image: category.image_path = new_image # Explicitly delete and flush existing definitions. This prevents # unique-constraint violations when identical field names are inserted afterwards. db.query(CategoryField).filter(CategoryField.category_id == category.id).delete(synchronize_session=False) db.flush() definitions = db.query(FieldDefinition).filter(FieldDefinition.is_active.is_(True)).order_by(FieldDefinition.sort_order).all() for order, definition in enumerate(definitions): active = form.get(f"active_{definition.id}") == "on" or definition.field_name == "name" db.add(CategoryField( category_id=category.id, field_name=definition.field_name, label=definition.label, field_definition_id=definition.id, active=active, required=True if definition.field_name == "name" else form.get(f"required_{definition.id}") == "on", show_in_list=True if definition.field_name == "name" else form.get(f"list_{definition.id}") == "on", readonly=definition.readonly, 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.post("/categories/{category_id}/duplicate") def category_duplicate(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") base_name = f"{category.name} (Kopie)" new_name = base_name number = 2 while db.query(Category.id).filter(Category.name == new_name).first(): new_name = f"{category.name} (Kopie {number})" number += 1 duplicate = Category( name=new_name, description=category.description, image_path=category.image_path, ) db.add(duplicate) db.flush() for field in sorted(category.visible_fields, key=lambda item: item.sort_order): db.add(CategoryField( category_id=duplicate.id, field_name=field.field_name, label=field.label, field_definition_id=field.field_definition_id, active=field.active, required=field.required, show_in_list=field.show_in_list, readonly=field.readonly, sort_order=field.sort_order, )) db.commit() return RedirectResponse( f"/categories/{duplicate.id}/edit?toast_success=Kategorie wurde dupliziert", status_code=303, ) @app.post("/categories/{category_id}/delete") def category_delete(category_id: int, request: Request, db: Session = Depends(get_db)): _require_admin(request) category = db.query(Category).filter(Category.id == category_id).first() if not category: raise HTTPException(404, "Kategorie nicht gefunden") asset_count = db.query(Asset).filter(Asset.category_id == category.id).count() if asset_count > 0: return RedirectResponse( f"/categories?toast_error=Kategorie kann nicht gelöscht werden: Sie wird noch von {asset_count} Asset(s) verwendet", status_code=303, ) db.delete(category) db.commit() return RedirectResponse( "/categories?toast_success=Kategorie wurde gelöscht", status_code=303, ) @app.get("/api/software-callback/health") def software_callback_health(): return { "ok": True, "service": "assetmanager-software-callback", "version": application_version(), "time": datetime.utcnow().isoformat() + "Z", } def _software_overview_statistics(db: Session) -> dict[str, int]: """Return only the counters required by the software overview. Do not load every InstalledSoftware ORM row merely to display three numbers. PostgreSQL performs the counting and grouping and returns only scalar values. """ total_entries, initialized_assets = ( db.query( func.count(InstalledSoftware.id), func.count(func.distinct(InstalledSoftware.asset_id)), ) .one() ) # Fetch only the three short grouping columns. The final Python casefold # intentionally preserves the exact grouping semantics used by the existing # detailed summary (Unicode casefold is stronger than SQL LOWER). summary_rows = ( db.query( InstalledSoftware.name, InstalledSoftware.publisher, InstalledSoftware.platform, ) .distinct() .all() ) summary_count = len({ ( (name or "").strip().casefold(), (publisher or "").strip().casefold(), (platform or "unknown").strip().casefold(), ) for name, publisher, platform in summary_rows }) return { "software_entry_count": int(total_entries or 0), "software_summary_count": int(summary_count or 0), "initialized_assets": int(initialized_assets or 0), } def _software_group_expressions(): """Return normalized SQL expressions used by all software groupings.""" name_key = func.lower(func.trim(func.coalesce(InstalledSoftware.name, ""))) publisher_key = func.lower(func.trim(func.coalesce(InstalledSoftware.publisher, ""))) platform_key = func.lower(func.trim(func.coalesce(InstalledSoftware.platform, "unknown"))) version_key = func.lower(func.trim(func.coalesce(InstalledSoftware.version, ""))) return name_key, publisher_key, platform_key, version_key def _software_summary_rows(db: Session, grouping: str) -> list[dict[str, Any]]: """Build the aggregated software list inside PostgreSQL. The query returns only already aggregated result rows. ``grouping=name_platform`` combines changing publisher names and all versions by software name and platform. ``grouping=software`` combines all versions by name, publisher and platform. ``grouping=version`` keeps one row per version. """ name_key, publisher_key, platform_key, version_key = _software_group_expressions() common_columns = ( name_key.label("name_key"), publisher_key.label("publisher_key"), platform_key.label("platform_key"), func.min(func.coalesce(InstalledSoftware.name, "")).label("name"), func.min(func.coalesce(InstalledSoftware.publisher, "")).label("publisher"), func.min(func.coalesce(InstalledSoftware.platform, "unknown")).label("platform"), ) if grouping == "name_platform": publishers_array = func.array_agg( distinct(func.nullif(func.trim(func.coalesce(InstalledSoftware.publisher, "")), "")) ) versions_array = func.array_agg( distinct(func.nullif(func.trim(func.coalesce(InstalledSoftware.version, "")), "")) ) query_rows = ( db.query( name_key.label("name_key"), platform_key.label("platform_key"), func.min(func.coalesce(InstalledSoftware.name, "")).label("name"), func.min(func.coalesce(InstalledSoftware.platform, "unknown")).label("platform"), publishers_array.label("publishers"), versions_array.label("versions"), func.count(func.distinct(InstalledSoftware.asset_id)).label("asset_count"), func.count(InstalledSoftware.id).label("entries"), func.max(InstalledSoftware.last_seen_at).label("last_seen_at"), ) .group_by(name_key, platform_key) .order_by(platform_key, name_key) .all() ) result: list[dict[str, Any]] = [] for row in query_rows: publishers = sorted( {str(value).strip() for value in (row.publishers or []) if value and str(value).strip()}, key=str.casefold, ) versions = sorted( {str(value).strip() for value in (row.versions or []) if value and str(value).strip()}, key=str.casefold, ) result.append({ "name": row.name or "", "publisher": ", ".join(publishers), "platform": row.platform or "unknown", "versions": ", ".join(versions), "version_count": len(versions), "asset_count": int(row.asset_count or 0), "entries": int(row.entries or 0), "last_seen_at": row.last_seen_at, }) return result if grouping == "version": query_rows = ( db.query( *common_columns, version_key.label("version_key"), func.min(func.coalesce(InstalledSoftware.version, "")).label("version"), func.count(func.distinct(InstalledSoftware.asset_id)).label("asset_count"), func.count(InstalledSoftware.id).label("entries"), func.max(InstalledSoftware.last_seen_at).label("last_seen_at"), ) .group_by(name_key, publisher_key, platform_key, version_key) .order_by(platform_key, name_key, publisher_key, version_key) .all() ) return [ { "name": row.name or "", "publisher": row.publisher or "", "platform": row.platform or "unknown", "versions": row.version or "", "version_count": 1 if (row.version or "").strip() else 0, "asset_count": int(row.asset_count or 0), "entries": int(row.entries or 0), "last_seen_at": row.last_seen_at, } for row in query_rows ] # PostgreSQL performs the three-column grouping and returns the distinct # version values as a compact array. Assets are counted distinctly across # all versions, so the value remains correct even if an asset contains more # than one version record for the same software. versions_array = func.array_agg( distinct(func.nullif(func.trim(func.coalesce(InstalledSoftware.version, "")), "")) ) query_rows = ( db.query( *common_columns, versions_array.label("versions"), func.count(func.distinct(InstalledSoftware.asset_id)).label("asset_count"), func.count(InstalledSoftware.id).label("entries"), func.max(InstalledSoftware.last_seen_at).label("last_seen_at"), ) .group_by(name_key, publisher_key, platform_key) .order_by(platform_key, name_key, publisher_key) .all() ) result: list[dict[str, Any]] = [] for row in query_rows: versions = sorted( {str(value).strip() for value in (row.versions or []) if value and str(value).strip()}, key=str.casefold, ) result.append({ "name": row.name or "", "publisher": row.publisher or "", "platform": row.platform or "unknown", "versions": ", ".join(versions), "version_count": len(versions), "asset_count": int(row.asset_count or 0), "entries": int(row.entries or 0), "last_seen_at": row.last_seen_at, }) return result def _software_installation_rows(db: Session): """Return only columns rendered by the detailed installation table.""" return ( db.query( InstalledSoftware.id.label("id"), InstalledSoftware.asset_id.label("asset_id"), Asset.name.label("asset_name"), InstalledSoftware.name.label("name"), InstalledSoftware.version.label("version"), InstalledSoftware.publisher.label("publisher"), InstalledSoftware.architecture.label("architecture"), InstalledSoftware.platform.label("platform"), InstalledSoftware.install_date.label("install_date"), InstalledSoftware.last_seen_at.label("last_seen_at"), ) .join(Asset, InstalledSoftware.asset_id == Asset.id) .order_by( InstalledSoftware.name.asc(), Asset.name.asc(), InstalledSoftware.version.asc(), InstalledSoftware.id.asc(), ) .all() ) _SOFTWARE_JOB_FILTER_KEYS = ( "job_id", "created_at", "asset", "job_type", "package", "action", "priority", "platform", "status", ) def _software_job_filter_values(request: Request) -> dict[str, str]: """Read and normalize the server-side filters of the recent-job table.""" return { key: str(request.query_params.get(f"job_filter_{key}") or "").strip()[:200] for key in _SOFTWARE_JOB_FILTER_KEYS } def _sql_text_contains(column: Any, value: str): """Case-insensitive literal substring search for SQL text expressions.""" escaped = ( str(value or "") .casefold() .replace("\\", "\\\\") .replace("%", "\\%") .replace("_", "\\_") ) return func.lower(func.coalesce(cast(column, String), "")).like( f"%{escaped}%", escape="\\", ) def _translated_filter_codes( request: Request, prefix: str, codes: tuple[str, ...], search_value: str, ) -> list[str]: """Map translated, partially entered labels back to their internal codes. The translation dictionaries are cached on the request so one filter request does not open a database session for every possible status value. """ needle = str(search_value or "").strip().casefold() if not needle: return [] cache = getattr(request.state, "software_job_filter_translations", None) if cache is None: user = _session_user(request) or {} language_code = ( user.get("language_code") or request.session.get("language_code") or load_config().get("general", {}).get("default_language", "en") ) translation_db = SessionLocal() try: cache = { "own": translation_dictionary(translation_db, language_code), "en": translation_dictionary(translation_db, "en"), } finally: translation_db.close() request.state.software_job_filter_translations = cache matches: list[str] = [] for code in codes: key = f"{prefix}.{code}" label = cache["own"].get(key) or cache["en"].get(key) or code if needle in code.casefold() or needle in str(label).casefold(): matches.append(code) return matches def _software_job_date_filter(column: Any, search_value: str): """Support ISO/database text and the common German date formats.""" value = str(search_value or "").strip() conditions = [_sql_text_contains(column, value)] for date_format in ("%d.%m.%Y", "%d.%m.%y", "%Y-%m-%d"): try: parsed = datetime.strptime(value, date_format) except ValueError: continue conditions.append(and_(column >= parsed, column < parsed + timedelta(days=1))) break return or_(*conditions) def _apply_software_job_filters(query: Any, request: Request, filters: dict[str, str]): """Apply all recent-job filters before LIMIT is evaluated. This keeps an old job searchable even when the visible result size is only 25 rows. The large script, result and log columns are still excluded from the final SELECT by ``load_only`` below. """ parameters_text = cast(SoftwareJob.parameters, String) if filters["job_id"]: value = filters["job_id"] if value.isdigit(): query = query.filter(SoftwareJob.id == int(value)) else: query = query.filter(_sql_text_contains(SoftwareJob.id, value)) if filters["created_at"]: query = query.filter( _software_job_date_filter(SoftwareJob.created_at, filters["created_at"]) ) if filters["asset"]: query = query.filter(_sql_text_contains(Asset.name, filters["asset"])) if filters["job_type"]: value = filters["job_type"] conditions = [ _sql_text_contains(SoftwareJob.job_type, value), _sql_text_contains(parameters_text, value), ] job_type_codes = _translated_filter_codes( request, "jobs.type", ("software_inventory", "job_definition"), value, ) if job_type_codes: conditions.append(SoftwareJob.job_type.in_(job_type_codes)) job_kind_codes = _translated_filter_codes( request, "jobdefs.kind", ("script", "registry"), value, ) for kind in job_kind_codes: conditions.append( and_( SoftwareJob.job_type == "job_definition", _sql_text_contains(parameters_text, kind), ) ) query = query.filter(or_(*conditions)) if filters["package"]: value = filters["package"] query = query.filter( or_( _sql_text_contains(SoftwarePackage.name, value), _sql_text_contains(parameters_text, value), ) ) if filters["action"]: value = filters["action"] conditions = [_sql_text_contains(SoftwareJob.action, value)] action_codes = _translated_filter_codes( request, "jobs.action", ("run", "install", "uninstall", "update"), value, ) if action_codes: conditions.append(SoftwareJob.action.in_(action_codes)) query = query.filter(or_(*conditions)) if filters["priority"]: value = filters["priority"] if re.fullmatch(r"[-+]?\d+", value): query = query.filter(SoftwareJob.priority == int(value)) else: query = query.filter(_sql_text_contains(SoftwareJob.priority, value)) if filters["platform"]: query = query.filter( _sql_text_contains(SoftwareJob.platform, filters["platform"]) ) if filters["status"]: value = filters["status"] conditions = [_sql_text_contains(SoftwareJob.status, value)] status_codes = _translated_filter_codes( request, "software.status", ( "created", "sending", "sent", "running", "success", "partial", "failed", "timeout", "cancelled", ), value, ) if status_codes: conditions.append(SoftwareJob.status.in_(status_codes)) query = query.filter(or_(*conditions)) return query @app.get("/software") def software_overview(request: Request, db: Session = Depends(get_db)): _require_admin(request) page_started = time.perf_counter() timeout_started = time.perf_counter() _expire_stale_software_jobs(db) timeout_seconds = time.perf_counter() - timeout_started allowed_job_limits = (25, 50, 100, 500, 1000) raw_limit = request.query_params.get("job_limit") if raw_limit is None: raw_limit = request.session.get("software_job_limit", 25) try: requested_limit = int(raw_limit) except (TypeError, ValueError): requested_limit = 25 job_limit = requested_limit if requested_limit in allowed_job_limits else 25 request.session["software_job_limit"] = job_limit stats_started = time.perf_counter() statistics = _software_overview_statistics(db) stats_seconds = time.perf_counter() - stats_started jobs_started = time.perf_counter() job_filters = _software_job_filter_values(request) filters_active = any(job_filters.values()) jobs_query = ( db.query(SoftwareJob) .join(Asset, Asset.id == SoftwareJob.asset_id) .outerjoin(SoftwarePackage, SoftwarePackage.id == SoftwareJob.package_id) ) jobs_query = _apply_software_job_filters(jobs_query, request, job_filters) # Count after filtering but before LIMIT. This value is only needed while a # filter is active and therefore adds no overhead to the normal overview. jobs_filtered_total = ( int(jobs_query.with_entities(func.count(SoftwareJob.id)).scalar() or 0) if filters_active else 0 ) jobs = ( jobs_query .options( load_only( SoftwareJob.id, SoftwareJob.asset_id, SoftwareJob.package_id, SoftwareJob.status, SoftwareJob.job_type, SoftwareJob.action, SoftwareJob.priority, SoftwareJob.platform, SoftwareJob.parameters, SoftwareJob.created_at, ), contains_eager(SoftwareJob.asset).load_only(Asset.id, Asset.name, Asset.mesh_node_id), contains_eager(SoftwareJob.package).load_only(SoftwarePackage.id, SoftwarePackage.name), ) .order_by(SoftwareJob.created_at.desc(), SoftwareJob.id.desc()) .limit(job_limit) .all() ) _decorate_jobs_for_display(db, jobs) jobs_seconds = time.perf_counter() - jobs_started response = templates.TemplateResponse( "software.html", { "request": request, "jobs": jobs, "job_limit": job_limit, "job_limit_options": allowed_job_limits, "job_filters": job_filters, "jobs_filters_active": filters_active, "jobs_filtered_total": jobs_filtered_total, **statistics, }, ) total_seconds = time.perf_counter() - page_started if total_seconds >= 0.5: logger.info( "Software overview timing: total=%.3fs timeout=%.3fs statistics=%.3fs jobs=%.3fs rows=%s", total_seconds, timeout_seconds, stats_seconds, jobs_seconds, len(jobs), ) return response @app.get("/software/job-states") def software_job_states_page(request: Request, db: Session = Depends(get_db)): _require_admin(request) _expire_stale_software_jobs(db) rows = ( db.query(AssetJobState, Asset, JobDefinition) .join(Asset, Asset.id == AssetJobState.asset_id) .outerjoin(JobDefinition, JobDefinition.id == AssetJobState.job_definition_id) .order_by(AssetJobState.updated_at.desc(), func.lower(Asset.name), AssetJobState.state_key) .all() ) job_states: list[dict[str, Any]] = [] for state, asset, definition in rows: if state.job_type == "job_definition": job_label = definition.name if definition is not None else state.state_key job_type_key = "jobs.type.job_definition" job_type_fallback = "Jobdefinition" elif state.job_type == "software_inventory": job_label = _translate_request(request, "jobs.type.software_inventory", "Software inventory") job_type_key = "jobs.type.software_inventory" job_type_fallback = "Software inventory" else: job_label = state.state_key job_type_key = f"jobs.type.{state.job_type}" job_type_fallback = state.job_type job_states.append({ "state": state, "asset": asset, "job_label": job_label, "job_type_key": job_type_key, "job_type_fallback": job_type_fallback, "last_execution": state.last_started_at or state.last_sent_at or state.last_status_at or state.last_created_at, }) return templates.TemplateResponse( "software_job_states.html", { "request": request, "job_states": job_states, }, ) @app.post("/software/inventory/delete") async def delete_software_inventory_entries(request: Request, db: Session = Depends(get_db)): _require_admin(request) form = await request.form() redirect_to = str(form.get("redirect_to") or "/software/installations") if not redirect_to.startswith("/") or redirect_to.startswith("//"): redirect_to = "/software/installations" entry_ids = [] for value in form.getlist("entry_ids"): try: entry_ids.append(int(value)) except (TypeError, ValueError): continue entry_ids = list(dict.fromkeys(entry_ids)) if not entry_ids: return RedirectResponse(redirect_to + ("&" if "?" in redirect_to else "?") + "toast_error=" + quote(_translate_request(request, "software.delete.none_selected", "No software inventory entries were selected.")), status_code=303) deleted = db.query(InstalledSoftware).filter(InstalledSoftware.id.in_(entry_ids)).delete(synchronize_session=False) db.commit() message = _translate_request(request, "software.delete.result", "Deleted {count} software inventory entries.").format(count=deleted) return RedirectResponse(redirect_to + ("&" if "?" in redirect_to else "?") + "toast_success=" + quote(message), status_code=303) @app.get("/software/installations") def software_installations_page(request: Request, db: Session = Depends(get_db)): _require_admin(request) started = time.perf_counter() software_entries = _software_installation_rows(db) query_seconds = time.perf_counter() - started response = templates.TemplateResponse( "software_installations.html", { "request": request, "software_entries": software_entries, }, ) total_seconds = time.perf_counter() - started if total_seconds >= 0.5: logger.info( "Software installations timing: total=%.3fs query=%.3fs rows=%s", total_seconds, query_seconds, len(software_entries), ) return response @app.get("/software/summary") def software_summary_page(request: Request, db: Session = Depends(get_db)): _require_admin(request) started = time.perf_counter() allowed_groupings = {"name_platform", "software", "version"} grouping = str( request.query_params.get("grouping") or request.session.get("software_summary_grouping") or "software" ).strip().lower() if grouping not in allowed_groupings: grouping = "software" request.session["software_summary_grouping"] = grouping software_summary = _software_summary_rows(db, grouping) query_seconds = time.perf_counter() - started response = templates.TemplateResponse( "software_summary.html", { "request": request, "software_summary": software_summary, "grouping": grouping, }, ) total_seconds = time.perf_counter() - started if total_seconds >= 0.5: logger.info( "Software summary timing: total=%.3fs query=%.3fs rows=%s grouping=%s", total_seconds, query_seconds, len(software_summary), grouping, ) return response @app.get("/assets/{asset_id}/software") def asset_software(asset_id: int, request: Request, db: Session = Depends(get_db)): asset = _get_visible_asset(db, request, asset_id) software = db.query(InstalledSoftware).filter(InstalledSoftware.asset_id == asset.id).order_by(InstalledSoftware.name, InstalledSoftware.version).all() jobs = db.query(SoftwareJob).options(joinedload(SoftwareJob.package)).filter(SoftwareJob.asset_id == asset.id).order_by(SoftwareJob.created_at.desc()).limit(50).all() _decorate_jobs_for_display(db, jobs) return templates.TemplateResponse("asset_software.html", {"request": request, "asset": asset, "software": software, "jobs": jobs, "is_admin": _is_admin(request)}) @app.post("/assets/{asset_id}/job-parameters/{definition_id}") async def asset_job_parameter_overrides(asset_id:int, definition_id:int, request:Request, db:Session=Depends(get_db)): _require_admin(request) asset=_get_visible_asset(db,request,asset_id); definition=db.get(JobDefinition,definition_id) if not definition: raise HTTPException(404) form=await request.form(); valid_ids={x.id:x for x in definition.parameters} existing={x.definition_parameter_id:x for x in db.query(JobDefinitionAssetParameter).filter(JobDefinitionAssetParameter.asset_id==asset.id, JobDefinitionAssetParameter.definition_parameter_id.in_(list(valid_ids) or [-1])).all()} for parameter_id,parameter in valid_ids.items(): use_override=str(form.get(f"override_{parameter_id}") or "") == "on" row=existing.get(parameter_id) if use_override: if row is None: row=JobDefinitionAssetParameter(asset_id=asset.id,definition_parameter_id=parameter_id);db.add(row) row.value_text=str(form.get(f"value_{parameter_id}") or "");row.updated_by=_changed_by(request) elif row is not None: db.delete(row) db.commit() return RedirectResponse(f"/assets/{asset.id}?toast_success="+quote(_translate_request(request,"jobparams.saved","Asset-specific job parameters were saved.")),303) def _job_parameter_dict(job: SoftwareJob) -> dict[str, Any]: return job.parameters if isinstance(job.parameters, dict) else {} def _decorate_jobs_for_display(db: Session, jobs: list[SoftwareJob]) -> None: """Attach user-facing job metadata without changing technical job fields. Job definitions intentionally use the technical database values ``job_type=job_definition`` and the shared system package ``Jobdefinition``. Those values are required by the dispatcher but are not meaningful in the UI. The selected definition and its real kind are therefore exposed as transient display attributes. """ definition_ids: set[int] = set() definition_revisions: set[int] = set() for job in jobs: if job.job_type != "job_definition": continue parameters = _job_parameter_dict(job) try: definition_ids.add(int(parameters.get("definition_id"))) except (TypeError, ValueError): continue try: definition_revisions.add(int(parameters.get("definition_revision"))) except (TypeError, ValueError): pass definitions_by_id: dict[int, JobDefinition] = {} revision_snapshots: dict[tuple[int, int], dict[str, Any]] = {} if definition_ids: definitions_by_id = { definition.id: definition for definition in ( db.query(JobDefinition) .filter(JobDefinition.id.in_(definition_ids)) .all() ) } if definition_revisions: revision_snapshots = { (row.definition_id, row.revision): (row.snapshot if isinstance(row.snapshot, dict) else {}) for row in ( db.query(JobDefinitionRevision) .filter( JobDefinitionRevision.definition_id.in_(definition_ids), JobDefinitionRevision.revision.in_(definition_revisions), ) .all() ) } for job in jobs: parameters = _job_parameter_dict(job) package_name = job.package.name if job.package else "" job.display_job_type_key = f"jobs.type.{job.job_type}" job.display_job_type_fallback = job.job_type job.display_package_name = package_name job.display_definition_revision = None job.display_creation_mode = str(parameters.get("_creation_mode") or "single") job.display_bulk_batch_id = str(parameters.get("_bulk_batch_id") or "") if job.job_type != "job_definition": continue definition = None definition_id = None definition_revision = None try: definition_id = int(parameters.get("definition_id")) definition = definitions_by_id.get(definition_id) except (TypeError, ValueError): pass try: definition_revision = int(parameters.get("definition_revision")) except (TypeError, ValueError): pass revision_snapshot = ( revision_snapshots.get((definition_id, definition_revision), {}) if definition_id is not None and definition_revision is not None else {} ) definition_name = str( parameters.get("definition_name") or revision_snapshot.get("name") or "" ).strip() if not definition_name and definition is not None: definition_name = definition.name definition_kind = str( parameters.get("definition_job_kind") or revision_snapshot.get("job_kind") or "" ).strip().lower() if not definition_kind and definition is not None: definition_kind = str(definition.job_kind or "script").strip().lower() if definition_kind not in {"script", "registry"}: definition_kind = "script" job.display_job_type_key = f"jobdefs.kind.{definition_kind}" job.display_job_type_fallback = definition_kind job.display_package_name = definition_name or package_name job.display_definition_revision = ( parameters.get("definition_revision") if parameters.get("definition_revision") is not None else (definition.revision if definition is not None else None) ) def _job_creation_metadata( *, creation_mode: str = "single", bulk_batch_id: str | None = None, bulk_position: int | None = None, bulk_total: int | None = None, ) -> dict[str, Any]: metadata: dict[str, Any] = {"_creation_mode": "bulk" if creation_mode == "bulk" else "single"} if bulk_batch_id: metadata["_bulk_batch_id"] = bulk_batch_id if bulk_position is not None: metadata["_bulk_position"] = int(bulk_position) if bulk_total is not None: metadata["_bulk_total"] = int(bulk_total) return metadata def _create_definition_job( db: Session, request: Request, asset: Asset, definition: JobDefinition, *, creation_mode: str = "single", bulk_batch_id: str | None = None, bulk_position: int | None = None, bulk_total: int | None = None, ) -> SoftwareJob: if not asset.mesh_node_id: raise ValueError("missing_node_id") platform = detect_platform(asset) if platform == "unknown": raise ValueError("unknown_platform") if definition.platform not in {platform, "all"}: raise ValueError("platform_mismatch") if not definition.enabled: raise ValueError("definition_disabled") package = _seed_generic_job_package(db) token = secrets.token_urlsafe(32) job = SoftwareJob( asset_id=asset.id, package_id=package.id, status="created", job_type="job_definition", action="run", priority=100, platform=platform, parameters={ "definition_id": definition.id, "definition_name": definition.name, "definition_revision": definition.revision, "definition_job_kind": definition.job_kind, "definition_interpreter": definition.interpreter, "definition_source_type": definition.source_type, "effective_parameters": _job_effective_parameters(db, definition, asset), **_job_creation_metadata( creation_mode=creation_mode, bulk_batch_id=bulk_batch_id, bulk_position=bulk_position, bulk_total=bulk_total, ), }, result_data={}, attempt_count=0, max_attempts=1, callback_token_hash=token_hash(token), callback_expires_at=datetime.utcnow() + timedelta(seconds=max(60, min(int(definition.timeout_seconds or 300) + 120, 86400))), created_by=_changed_by(request), ) db.add(job) db.flush() sync_asset_job_state(db, job, increment_execution=True) _record_job_event( db, job, "created", "created", _translate_request( request, "jobs.event.created_named", 'Job "{name}" created', name=definition.name, ), _changed_by(request), ) db.commit() db.refresh(job) configured = str(_software_settings().get("callback_base_url", "") or "").strip().rstrip("/") callback_base = configured or str(request.base_url).rstrip("/") job_parameters = dict(job.parameters or {}) job_parameters["_callback_base_url"] = callback_base job.parameters = job_parameters db.commit() threading.Thread(target=execute_job, args=(job.id, token, callback_base), daemon=True).start() return job @app.post("/assets/{asset_id}/jobs/run") def asset_run_job_definition(asset_id: int, request: Request, definition_id: int = Form(...), db: Session = Depends(get_db)): _require_admin(request) asset = _get_visible_asset(db, request, asset_id) definition = db.get(JobDefinition, definition_id) if not definition or not definition.enabled: return RedirectResponse(f"/assets/{asset.id}?toast_error=" + quote(_translate_request(request, "jobs.definition_unavailable", "The selected job definition is not available.")), status_code=303) try: job = _create_definition_job(db, request, asset, definition) except ValueError as exc: key = str(exc) messages = { "missing_node_id": _translate_request(request, "jobs.asset_missing_node", "The asset has no MeshCentral node ID."), "unknown_platform": _translate_request(request, "jobs.asset_unknown_platform", "The operating system platform could not be detected."), "platform_mismatch": _translate_request(request, "jobs.platform_mismatch", "The job definition does not match the asset platform."), "definition_disabled": _translate_request(request, "jobs.definition_unavailable", "The selected job definition is not available."), } return RedirectResponse(f"/assets/{asset.id}?toast_error=" + quote(messages.get(key, key)), status_code=303) return RedirectResponse(f"/software/jobs/{job.id}?toast_success=" + quote(_translate_request(request, "jobs.started", "Job started.")), status_code=303) def _create_software_inventory_job( db: Session, request: Request, asset: Asset, *, creation_mode: str = "single", bulk_batch_id: str | None = None, bulk_position: int | None = None, bulk_total: int | None = None, ) -> SoftwareJob: if not asset.mesh_node_id: raise ValueError("missing_node_id") platform = detect_platform(asset) if platform == "android": raise ValueError("unsupported_android") if platform == "unknown": raise ValueError("unknown_platform") package = _seed_software_inventory_package(db) token = secrets.token_urlsafe(32) job = SoftwareJob( asset_id=asset.id, package_id=package.id, status="created", job_type="software_inventory", action="run", priority=100, platform=platform, parameters=_job_creation_metadata( creation_mode=creation_mode, bulk_batch_id=bulk_batch_id, bulk_position=bulk_position, bulk_total=bulk_total, ), result_data={}, attempt_count=0, max_attempts=1, callback_token_hash=token_hash(token), callback_expires_at=datetime.utcnow() + timedelta( minutes=max(5, min(int(_software_settings().get("callback_timeout_minutes", package.callback_timeout_minutes or 30) or 30), 240)) ), created_by=_changed_by(request), ) db.add(job) db.flush() sync_asset_job_state(db, job, increment_execution=True) _record_job_event(db, job, "created", "created", _translate_request(request, "jobs.event.created", "Job created"), _changed_by(request)) db.commit() db.refresh(job) configured = str(_software_settings().get("callback_base_url", "") or "").strip().rstrip("/") callback_base = configured or str(request.base_url).rstrip("/") job_parameters = dict(job.parameters or {}) job_parameters["_callback_base_url"] = callback_base job.parameters = job_parameters db.commit() threading.Thread(target=execute_job, args=(job.id, token, callback_base), daemon=True).start() return job @app.post("/assets/{asset_id}/software-inventory") def asset_software_inventory( asset_id: int, request: Request, return_to: str = Form("software-page"), db: Session = Depends(get_db), ): _require_admin(request) asset = _get_visible_asset(db, request, asset_id) try: _create_software_inventory_job(db, request, asset) except ValueError as exc: messages = { "missing_node_id": "Das Asset besitzt keine MeshCentral Node-ID.", "unsupported_android": "Der MeshCentral-Android-Agent stellt derzeit keinen vergleichbaren RunCommand-Kanal für diese Inventur bereit.", "unknown_platform": "Das Betriebssystem konnte nicht sicher erkannt werden.", } raise HTTPException(400, messages.get(str(exc), "Die Softwareinventur konnte nicht gestartet werden.")) target = ( f"/assets/{asset.id}?inventory_tab=software" if return_to == "asset-detail" else f"/assets/{asset.id}/software" ) return RedirectResponse( target + f"&toast_success={quote(_translate_request(request, 'software.inventory_queued', 'Software inventory was submitted to MeshCentral.'))}" if "?" in target else target + f"?toast_success={quote(_translate_request(request, 'software.inventory_queued', 'Software inventory was submitted to MeshCentral.'))}", status_code=303, ) @app.post("/jobs/bulk-create") async def bulk_create_jobs(request: Request, db: Session = Depends(get_db)): _require_admin(request) form = await request.form() job_type = str(form.get("job_type") or "").strip() definition_id_raw = str(form.get("definition_id") or "").strip() definition = None if definition_id_raw: try: definition = db.get(JobDefinition, int(definition_id_raw)) except (TypeError, ValueError): definition = None if not definition or not definition.enabled: return RedirectResponse("/assets?toast_error=" + quote(_translate_request(request, "jobs.definition_unavailable", "The selected job definition is not available.")), status_code=303) elif job_type != "software_inventory": return RedirectResponse("/assets?toast_error=" + quote(_translate_request(request, "jobs.bulk.invalid_type", "The selected job type is not available.")), status_code=303) asset_ids = [] for value in form.getlist("asset_ids"): try: asset_ids.append(int(value)) except (TypeError, ValueError): continue asset_ids = list(dict.fromkeys(asset_ids)) if not asset_ids: return RedirectResponse("/assets?toast_error=" + quote(_translate_request(request, "jobs.bulk.none_selected", "No assets were selected.")), status_code=303) visible_assets = _apply_asset_access(db.query(Asset), request).filter(Asset.id.in_(asset_ids)).all() visible_by_id = {asset.id: asset for asset in visible_assets} created = 0 skipped = 0 bulk_batch_id = uuid.uuid4().hex bulk_total = len(asset_ids) for bulk_position, asset_id in enumerate(asset_ids, start=1): asset = visible_by_id.get(asset_id) if not asset: skipped += 1 continue try: if definition is not None: _create_definition_job( db, request, asset, definition, creation_mode="bulk", bulk_batch_id=bulk_batch_id, bulk_position=bulk_position, bulk_total=bulk_total, ) else: _create_software_inventory_job( db, request, asset, creation_mode="bulk", bulk_batch_id=bulk_batch_id, bulk_position=bulk_position, bulk_total=bulk_total, ) created += 1 except ValueError: skipped += 1 if created: message = _translate_request(request, "jobs.bulk.result", "Created {created} jobs; skipped {skipped} assets.").format(created=created, skipped=skipped) return RedirectResponse("/assets?toast_success=" + quote(message), status_code=303) message = _translate_request(request, "jobs.bulk.none_created", "No job could be created; skipped {skipped} assets.").format(skipped=skipped) return RedirectResponse("/assets?toast_error=" + quote(message), status_code=303) @app.post("/assets/job-filter/start") async def start_job_for_filtered_assets(request: Request, db: Session = Depends(get_db)): _require_admin(request) form = await request.form() category_id = _optional_form_int(form.get("category_id"), field_name="category_id", minimum=1) job_filter = _normalize_asset_job_filter( db, selection=form.getlist("job_filter"), excluded_statuses=form.getlist("job_filter_exclude"), revision_mode=str(form.get("job_filter_revision") or "all"), ) if not job_filter: return RedirectResponse( "/assets?toast_error=" + quote(_translate_request(request, "assets.job_filter.invalid", "The job filter is invalid.")), status_code=303, ) start_selection = str(form.get("job_filter_start") or "").strip() if not start_selection: start_selection = job_filter["selections"][0] if start_selection not in job_filter["selections"]: return RedirectResponse( _asset_job_filter_url( job_filter, category_id, toast_error=_translate_request(request, "assets.job_filter.start_invalid", "Select one of the filtered jobs to run."), ), status_code=303, ) start_spec = next((spec for spec in job_filter["specs"] if spec["selection"] == start_selection), None) if start_spec is None: return RedirectResponse( _asset_job_filter_url( job_filter, category_id, toast_error=_translate_request(request, "assets.job_filter.start_invalid", "Select one of the filtered jobs to run."), ), status_code=303, ) definition = start_spec.get("definition") if definition is not None and not definition.enabled: return RedirectResponse( _asset_job_filter_url( job_filter, category_id, toast_error=_translate_request(request, "jobs.definition_unavailable", "The selected job definition is not available."), ), status_code=303, ) query = _apply_asset_access(db.query(Asset), request) if category_id: query = query.filter(Asset.category_id == category_id) candidate_assets = query.order_by(func.lower(Asset.name), Asset.id).all() assets, _views = _filter_assets_by_job_states(db, candidate_assets, job_filter) if not assets: return RedirectResponse( _asset_job_filter_url( job_filter, category_id, toast_error=_translate_request(request, "assets.job_filter.no_assets", "No assets match this job filter."), ), status_code=303, ) created = 0 skipped = 0 bulk_batch_id = uuid.uuid4().hex bulk_total = len(assets) for bulk_position, asset in enumerate(assets, start=1): try: if definition is not None: _create_definition_job( db, request, asset, definition, creation_mode="bulk", bulk_batch_id=bulk_batch_id, bulk_position=bulk_position, bulk_total=bulk_total, ) else: _create_software_inventory_job( db, request, asset, creation_mode="bulk", bulk_batch_id=bulk_batch_id, bulk_position=bulk_position, bulk_total=bulk_total, ) created += 1 except ValueError: skipped += 1 if created: message = _translate_request( request, "assets.job_filter.started", "Created {created} jobs for the filtered assets; skipped {skipped} assets.", created=created, skipped=skipped, ) return RedirectResponse( _asset_job_filter_url(job_filter, category_id, toast_success=message), status_code=303, ) message = _translate_request( request, "jobs.bulk.none_created", "No job could be created; skipped {skipped} assets.", skipped=skipped, ) return RedirectResponse( _asset_job_filter_url(job_filter, category_id, toast_error=message), status_code=303, ) def _validate_software_callback_token(job_id: int, token: str) -> bool: """Validate a callback outside the ASGI event loop. Returns True when the same valid token belongs to a job that was already completed. Callback delivery is intentionally idempotent so a client may safely retry after a lost HTTP response. """ db = SessionLocal() try: job = db.get(SoftwareJob, job_id) if not job: raise HTTPException(404, "Job nicht gefunden") if not token or not hmac.compare_digest(token_hash(token), job.callback_token_hash): raise HTTPException(401, "Ungültiger Callback-Token") if datetime.utcnow() > job.callback_expires_at: raise HTTPException(410, "Callback-Token ist abgelaufen") return bool(job.callback_completed_at) finally: db.close() def _decode_software_callback_body(raw_body: bytes) -> tuple[str, str, list[str]]: decoded_body = "" detected_encoding = "utf-8" decode_errors: list[str] = [] candidates: list[tuple[str, bytes]] = [] if raw_body.startswith(b"\xef\xbb\xbf"): candidates.append(("utf-8-sig", raw_body)) elif raw_body.startswith(b"\xff\xfe"): candidates.append(("utf-16-le", raw_body[2:])) elif raw_body.startswith(b"\xfe\xff"): candidates.append(("utf-16-be", raw_body[2:])) candidates.extend([ ("utf-8", raw_body), ("utf-8-sig", raw_body), ("utf-16-le", raw_body), ("utf-16-be", raw_body), ]) seen_encodings: set[str] = set() for encoding, value in candidates: if encoding in seen_encodings: continue seen_encodings.add(encoding) try: candidate = value.decode(encoding) if candidate.lstrip().startswith(("{", "[")): decoded_body = candidate detected_encoding = encoding break except UnicodeDecodeError as exc: decode_errors.append(f"{encoding}: {exc}") if not decoded_body: decoded_body = raw_body.decode("utf-8", errors="replace") detected_encoding = "utf-8-replacement" return decoded_body, detected_encoding, decode_errors def _process_software_job_callback( job_id: int, token: str, raw_body: bytes, content_type: str, client_host: str, queued_at: float, ) -> dict[str, Any]: """Process one callback in the dedicated callback worker pool. Every worker creates its own SQLAlchemy session. The job row is locked so duplicate callbacks cannot import the same result concurrently. Software inventory replacement additionally locks the asset row, serializing only callbacks that target the same asset while callbacks for other assets run in parallel. """ started_at = time.monotonic() _software_debug_log( f"CALLBACK worker started | job={job_id} | client={client_host} | " f"queue_wait_ms={(started_at - queued_at) * 1000:.1f} | workers={SOFTWARE_CALLBACK_WORKER_COUNT}" ) db = SessionLocal() try: job = ( db.query(SoftwareJob) .filter(SoftwareJob.id == job_id) .with_for_update() .first() ) if not job: raise HTTPException(404, "Job nicht gefunden") if not token or not hmac.compare_digest(token_hash(token), job.callback_token_hash): raise HTTPException(401, "Ungültiger Callback-Token") if datetime.utcnow() > job.callback_expires_at: raise HTTPException(410, "Callback-Token ist abgelaufen") if job.callback_completed_at: _software_debug_log(f"CALLBACK duplicate accepted | job={job.id} | status={job.status}") db.rollback() return {"ok": True, "job_id": job.id, "status": job.status, "duplicate": True} decoded_body, detected_encoding, decode_errors = _decode_software_callback_body(raw_body) content_length = len(raw_body) _software_debug_log( f"CALLBACK raw body | job={job_id} | encoding={detected_encoding} | BEGIN\n" + decoded_body + f"\nCALLBACK raw body | job={job_id} | END" ) try: payload = json.loads(decoded_body) except Exception as exc: failure_log = ( f"JSON parsing failed: {type(exc).__name__}: {exc}\n" f"Detected encoding: {detected_encoding}\n" f"Content-Type: {content_type}\n" f"Body bytes: {content_length}\n" f"\n--- Complete callback body ---\n{decoded_body}" ) job.log_text = ((job.log_text or "") + "\n\n--- Callback parsing error ---\n" + failure_log)[-2_000_000:] job.message = "Callback empfangen, aber JSON konnte nicht verarbeitet werden." db.commit() _software_debug_log( f"CALLBACK JSON ERROR | job={job_id} | {type(exc).__name__}: {exc}" + (f" | decode_errors={'; '.join(decode_errors)}" if decode_errors else "") ) raise HTTPException(400, "Ungültiges JSON") from exc if not isinstance(payload, dict): job.log_text = ( (job.log_text or "") + f"\n\n--- Callback parsing error ---\nJSON root type: {type(payload).__name__}\n" + f"\n--- Complete callback body ---\n{decoded_body}" )[-2_000_000:] job.message = "Callback empfangen, aber kein JSON-Objekt erhalten." db.commit() _software_debug_log(f"CALLBACK JSON TYPE ERROR | job={job_id} | root={type(payload).__name__}") raise HTTPException(400, "JSON-Objekt erwartet") _software_debug_log( f"CALLBACK JSON parsed | job={job_id} | keys={','.join(sorted(str(key) for key in payload.keys()))}" ) log_parts: list[str] = [] for label, key in (("Log", "log"), ("STDOUT", "stdout"), ("STDERR", "stderr")): value = payload.get(key) if value is None or value == "": continue text_value = str(value) if len(text_value) > 200000: raise HTTPException(413, f"{label} ist zu groß") log_parts.append(f"--- {label} ---\n{text_value}") status = str(payload.get("status") or "running").lower() job.status = status if status in {"running", "success", "failed", "partial"} else "running" if job.started_at is None: job.started_at = datetime.utcnow() try: job.exit_code = int(payload.get("exit_code")) if payload.get("exit_code") is not None else job.exit_code except (TypeError, ValueError): pass if payload.get("message") is not None: job.message = str(payload.get("message"))[:4000] summary_lines = [ f"Callback received: {datetime.utcnow().isoformat()}Z", f"Status: {job.status}", f"Exit code: {job.exit_code if job.exit_code is not None else '-'}", ] if job.message: summary_lines.append(f"Message: {job.message}") callback_log = "\n".join(summary_lines + (["", *log_parts] if log_parts else [])) job.log_text = ((job.log_text or "") + "\n\n--- Client callback ---\n" + callback_log).strip()[-2_000_000:] job.result_data = { str(key): value for key, value in payload.items() if key not in {"software", "log"} } software = payload.get("software") stored_software_count = 0 excluded_software_count = 0 if isinstance(software, list): _software_debug_log(f"CALLBACK software processing started | job={job.id} | received={len(software)}") # Serialize inventory replacement only per asset. Other assets are # still processed concurrently by the callback worker pool. db.query(Asset.id).filter(Asset.id == job.asset_id).with_for_update().first() now = datetime.utcnow() removed_count = ( db.query(InstalledSoftware) .filter(InstalledSoftware.asset_id == job.asset_id) .delete(synchronize_session=False) ) _software_debug_log(f"CALLBACK previous software removed | job={job.id} | rows={removed_count}") seen: set[tuple[str, str, str, str]] = set() inventory_rows: list[dict[str, Any]] = [] inventory_platform = (job.platform or "unknown")[:40].lower() exclusion_rules = _software_settings().get("inventory_exclusion_rules", []) for item in software[:50000]: if not isinstance(item, dict): continue name = str(item.get("name") or "").strip() if not name: continue if _software_name_is_excluded(name, inventory_platform, exclusion_rules): excluded_software_count += 1 continue values = ( name[:300], str(item.get("version") or "")[:160], str(item.get("publisher") or "")[:220], str(item.get("architecture") or "")[:40], ) identity = tuple(value.casefold() for value in values) if identity in seen: continue seen.add(identity) inventory_rows.append({ "asset_id": job.asset_id, "name": values[0], "version": values[1], "publisher": values[2], "architecture": values[3], "platform": inventory_platform, "install_date": str(item.get("install_date") or "")[:40] or None, "source": "meshcentral-inventory", "first_seen_at": now, "last_seen_at": now, }) if inventory_rows: db.bulk_insert_mappings(InstalledSoftware, inventory_rows) stored_software_count = len(inventory_rows) _software_debug_log( f"CALLBACK software filtered | job={job.id} | received={len(software)} | " f"excluded={excluded_software_count} | stored={stored_software_count} | " f"rules={len(exclusion_rules)}" ) if isinstance(job.result_data, dict): job.result_data["software_inventory"] = { "received": len(software), "excluded": excluded_software_count, "stored": stored_software_count, } if bool(payload.get("completed")) or job.status in {"success", "failed"}: job.callback_completed_at = datetime.utcnow() job.finished_at = datetime.utcnow() language_code = str(load_config().get("general", {}).get("default_language", "de") or "de") event_message = job.message or translate( db, language_code, "jobs.event.callback", "Callback received", ) _record_job_event(db, job, "callback", job.status, event_message) sync_asset_job_state(db, job) db.commit() processing_ms = (time.monotonic() - started_at) * 1000 _software_debug_log( f"CALLBACK database commit successful | job={job.id} | stored_software={stored_software_count} | excluded_software={excluded_software_count} | processing_ms={processing_ms:.1f}" ) _software_debug_log( f"CALLBACK accepted | job={job.id} | status={job.status} | completed={bool(job.callback_completed_at)} | " f"software_entries={len(software) if isinstance(software, list) else 0}" ) return {"ok": True, "job_id": job.id, "status": job.status} except HTTPException: db.rollback() raise except Exception: db.rollback() logger.exception("Software callback worker failed for job %s", job_id) raise finally: db.close() @app.post("/api/software-jobs/{job_id}/callback") async def software_job_callback(job_id: int, request: Request, token: str = ""): client_host = request.client.host if request.client else "-" content_type = request.headers.get("content-type", "-") _software_debug_log( f"CALLBACK received | job={job_id} | client={client_host} | content_type={content_type} | " f"workers={SOFTWARE_CALLBACK_WORKER_COUNT}" ) # The short authentication query runs outside the ASGI event loop. It is # repeated under a row lock inside the worker to close race conditions. already_completed = await asyncio.to_thread(_validate_software_callback_token, job_id, token) if already_completed: _software_debug_log(f"CALLBACK duplicate accepted before body read | job={job_id} | client={client_host}") return {"ok": True, "job_id": job_id, "duplicate": True} try: raw_body = await request.body() except ClientDisconnect: _software_debug_log( f"CALLBACK client disconnected while uploading body | job={job_id} | client={client_host}" ) return Response(status_code=499) _software_debug_log(f"CALLBACK body received | job={job_id} | bytes={len(raw_body)}") queued_at = time.monotonic() loop = asyncio.get_running_loop() return await loop.run_in_executor( SOFTWARE_CALLBACK_EXECUTOR, _process_software_job_callback, job_id, token, raw_body, content_type, client_host, queued_at, ) @app.get("/api/software-jobs/{job_id}") def software_job_status(job_id: int, request: Request, db: Session = Depends(get_db)): _require_admin(request) _expire_stale_software_jobs(db, job_id) job = db.get(SoftwareJob, job_id) if not job: raise HTTPException(404, "Job not found") return { "id": job.id, "job_type": job.job_type, "action": job.action, "priority": job.priority, "status": job.status, "status_label": _translate_request(request, f"software.status.{job.status}", job.status), "message": job.message or "", "exit_code": job.exit_code, "attempt_count": job.attempt_count, "max_attempts": job.max_attempts, "finished_at": _utc_iso(job.finished_at), "log_text": job.log_text or "", } def _prepare_existing_job_restart( db: Session, request: Request | None, job: SoftwareJob, *, automatic: bool = False, ) -> str: """Reset an existing job for another attempt and return its callback token. Manual and automatic retries use this same tested reset path. The caller commits the transaction and starts ``execute_job`` afterwards. """ token = secrets.token_urlsafe(32) package_timeout = getattr(job.package, "callback_timeout_minutes", None) or 30 timeout_minutes = max( 5, min( int(_software_settings().get("callback_timeout_minutes", package_timeout) or 30), 240, ), ) job.callback_token_hash = token_hash(token) job.callback_expires_at = datetime.utcnow() + timedelta(minutes=timeout_minutes) job.callback_completed_at = None job.status = "created" job.sent_at = None job.started_at = None job.finished_at = None job.exit_code = None job.message = None job.result_data = {} job.meshctrl_stdout = None job.meshctrl_stderr = None job.command_preview = None job.log_text = "" job.scheduled_at = None job.max_attempts = max(int(job.max_attempts or 1), int(job.attempt_count or 0) + 1) if automatic: language_code = str(load_config().get("general", {}).get("default_language", "de") or "de") event_type = "automatic_retry_requested" event_message = translate( db, language_code, "jobs.event.automatic_retry_requested", "Automatic retry requested", ) created_by = "system:auto-retry" else: event_type = "restart_requested" event_message = _translate_request(request, "jobs.event.restart_requested", "Manual restart requested") created_by = _changed_by(request) _record_job_event( db, job, event_type, "created", event_message, created_by, ) sync_asset_job_state(db, job, increment_execution=True) return token @app.post("/api/software-jobs/statuses") def software_job_statuses( request: Request, job_ids: list[int] = Body(...), db: Session = Depends(get_db), ): """Return compact live status data for the visible software-job rows.""" _require_admin(request) normalized_ids: list[int] = [] seen_ids: set[int] = set() for value in job_ids[:1000]: try: job_id = int(value) except (TypeError, ValueError): continue if job_id <= 0 or job_id in seen_ids: continue seen_ids.add(job_id) normalized_ids.append(job_id) if not normalized_ids: return {"jobs": []} _expire_stale_software_jobs(db) rows = ( db.query(SoftwareJob.id, SoftwareJob.status) .filter(SoftwareJob.id.in_(normalized_ids)) .all() ) user = _session_user(request) or {} language_code = ( user.get("language_code") or request.session.get("language_code") or load_config().get("general", {}).get("default_language", "en") ) status_labels: dict[str, str] = {} result = [] for job_id, status in rows: normalized_status = str(status or "created") if normalized_status not in status_labels: status_labels[normalized_status] = translate( db, language_code, f"software.status.{normalized_status}", normalized_status, ) result.append({ "id": int(job_id), "status": normalized_status, "status_label": status_labels[normalized_status], }) return {"jobs": result} @app.post("/jobs/{job_id}/restart") def restart_job(job_id: int, request: Request, db: Session = Depends(get_db)): _require_admin(request) job = db.query(SoftwareJob).options(joinedload(SoftwareJob.asset), joinedload(SoftwareJob.package)).filter(SoftwareJob.id == job_id).first() if not job: raise HTTPException(404, _translate_request(request, "jobs.not_found", "Job not found")) _expire_stale_software_jobs(db, job.id) db.refresh(job) if job.status in {"sending", "running"}: return RedirectResponse( f"/jobs/{job.id}?toast_error=" + quote(_translate_request(request, "jobs.restart.active", "An active job cannot be restarted.")), status_code=303, ) if not job.asset or not job.asset.mesh_node_id: return RedirectResponse( f"/jobs/{job.id}?toast_error=" + quote(_translate_request(request, "jobs.restart.no_node", "The asset has no MeshCentral node ID.")), status_code=303, ) token = _prepare_existing_job_restart(db, request, job) db.commit() configured = str(_software_settings().get("callback_base_url", "") or "").strip().rstrip("/") callback_base = configured or str(request.base_url).rstrip("/") threading.Thread(target=execute_job, args=(job.id, token, callback_base), daemon=True).start() return RedirectResponse( f"/jobs/{job.id}?toast_success=" + quote(_translate_request(request, "jobs.restart.started", "Job was queued again.")), status_code=303, ) @app.post("/jobs/restart-selected") async def restart_selected_jobs(request: Request, db: Session = Depends(get_db)): _require_admin(request) form = await request.form() job_ids: list[int] = [] for value in form.getlist("job_ids"): try: job_ids.append(int(value)) except (TypeError, ValueError): continue job_ids = list(dict.fromkeys(job_ids)) if not job_ids: return RedirectResponse( "/software?toast_error=" + quote(_translate_request(request, "jobs.bulk_restart.none_selected", "No jobs were selected.")), status_code=303, ) _expire_stale_software_jobs(db) jobs = ( db.query(SoftwareJob) .options(joinedload(SoftwareJob.asset), joinedload(SoftwareJob.package)) .filter(SoftwareJob.id.in_(job_ids)) .all() ) by_id = {job.id: job for job in jobs} queued: list[tuple[int, str]] = [] skipped = 0 for job_id in job_ids: job = by_id.get(job_id) if ( job is None or job.status in {"success", "sending", "running"} or not job.asset or not job.asset.mesh_node_id ): skipped += 1 continue token = _prepare_existing_job_restart(db, request, job) queued.append((job.id, token)) if not queued: db.rollback() return RedirectResponse( "/software?toast_error=" + quote(_translate_request(request, "jobs.bulk_restart.none_eligible", "None of the selected jobs can be restarted.")), status_code=303, ) db.commit() configured = str(_software_settings().get("callback_base_url", "") or "").strip().rstrip("/") callback_base = configured or str(request.base_url).rstrip("/") for queued_job_id, token in queued: threading.Thread(target=execute_job, args=(queued_job_id, token, callback_base), daemon=True).start() message = _translate_request( request, "jobs.bulk_restart.started", "{count} jobs were queued again; {skipped} were skipped.", count=len(queued), skipped=skipped, ) return RedirectResponse( "/software?toast_success=" + quote(message), status_code=303, ) @app.get("/jobs/{job_id}") def job_detail(job_id: int, request: Request, db: Session = Depends(get_db)): _require_admin(request) job = db.query(SoftwareJob).options(joinedload(SoftwareJob.asset), joinedload(SoftwareJob.package)).filter(SoftwareJob.id == job_id).first() if not job: raise HTTPException(404, _translate_request(request, "jobs.not_found", "Job not found")) _expire_stale_software_jobs(db, job.id) db.refresh(job) _decorate_jobs_for_display(db, [job]) events = db.query(JobEvent).filter(JobEvent.job_id == job.id).order_by(JobEvent.created_at.desc(), JobEvent.id.desc()).all() can_restart = job.status not in {"sending", "running"} return templates.TemplateResponse("software_job.html", {"request": request, "job": job, "events": events, "can_restart": can_restart}) @app.get("/software/jobs/{job_id}") def software_job_detail_legacy(job_id: int, request: Request): _require_admin(request) return RedirectResponse(f"/jobs/{job_id}", status_code=307) @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(func.lower(User.username) == username.casefold()).first() valid = False # A protected local account is authenticated only against its local password, # even in LDAP mode. If the password is incorrect, there is deliberately # no LDAP fallback for the same username. protected_local = bool( user and getattr(user, "is_protected", False) and user.auth_source == "local" ) if protected_local and mode in {"local", "ldap"}: valid = bool(user.is_active and _verify_password(password, user.password_hash)) elif 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, "languages": i18n_languages(db)}) @app.post("/profile/language") def profile_language(request: Request, language_code: str = Form(...), next_url: str = Form("/profile"), db: Session = Depends(get_db)): available = {item.code for item in i18n_languages(db)} if language_code not in available: raise HTTPException(400, "Unsupported language") session_user = _session_user(request) if session_user: user = db.query(User).filter(User.id == session_user.get("id")).first() if user: user.language_code = language_code db.commit() _login_user(request, user) request.session["language_code"] = language_code safe_next = next_url if next_url.startswith("/") and not next_url.startswith("//") else "/" return RedirectResponse(f"{safe_next}?toast_success=Language changed", status_code=303) @app.post("/profile/browser-state") def profile_browser_state( request: Request, persist_browser_state: str | None = Form(None), 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) user.persist_browser_state = persist_browser_state == "on" db.commit() _login_user(request, user) message = "Browserspeicherung aktiviert" if user.persist_browser_state else "Browserspeicherung deaktiviert" return RedirectResponse(f"/profile?toast_success={quote(message)}", status_code=303) @app.post("/profile/toast-settings") def profile_toast_settings( request: Request, toast_position: str = Form("top-right"), toast_duration_seconds: int = Form(6), 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) allowed_positions = {"top-left", "top-center", "top-right", "bottom-left", "bottom-center", "bottom-right"} user.toast_position = toast_position if toast_position in allowed_positions else "top-right" user.toast_duration_seconds = max(1, min(int(toast_duration_seconds or 6), 120)) db.commit() _login_user(request, user) return RedirectResponse("/profile?toast_success=Toast-Einstellungen gespeichert", status_code=303) @app.post("/profile/theme") def profile_theme( request: Request, theme_mode: str = Form("light"), next_url: str = Form("/profile"), 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) user.theme_mode = theme_mode if theme_mode in {"light", "dark"} else "light" db.commit() _login_user(request, user) safe_next = next_url if next_url.startswith("/") and not next_url.startswith("//") else "/profile" message = _translate_request(request, "appearance.theme_saved", "Display mode saved") return RedirectResponse(f"{safe_next}?toast_success={quote(message)}", status_code=303) @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") if db.query(User.id).filter(func.lower(User.username) == username.casefold()).first(): raise HTTPException(400, "Der Benutzername ist bereits vorhanden") 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", language_code=load_config().get("general", {}).get("default_language", "en")) 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(""), profile_department: str = Form(""), profile_location: str = Form(""), is_active: str | None = Form(None), is_admin: str | None = Form(None), new_password: str = Form(""), mesh_groups: list[str] = Form([]), asset_access_scopes: 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 if getattr(user, "is_protected", False): user.is_active = True user.is_admin = True user.auth_source = "local" else: user.is_active = is_active == "on" user.is_admin = is_admin == "on" user.allowed_mesh_groups = sorted({value.strip() for value in mesh_groups if value.strip()}) user.asset_access_scopes = sorted({value for value in asset_access_scopes if value in ASSET_ACCESS_SCOPES}) user.profile_department = profile_department.strip() or None user.profile_location = profile_location.strip() or None 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) @app.post("/users/{user_id}/delete") def user_delete(user_id: int, request: Request, db: Session = Depends(get_db)): _require_admin(request) user = db.get(User, user_id) if not user: raise HTTPException(404, "Benutzer nicht gefunden") if getattr(user, "is_protected", False): raise HTTPException(400, _translate_request( request, "users.protected_delete_forbidden", "Protected local administrators cannot be deleted.", )) session_user = _session_user(request) or {} if int(session_user.get("id") or 0) == user.id: raise HTTPException(400, "Der aktuell angemeldete Benutzer kann nicht gelöscht werden.") if user.is_admin and user.is_active: other_admins = db.query(User).filter(User.id != user.id, User.is_admin.is_(True), User.is_active.is_(True)).count() if other_admins == 0: raise HTTPException(400, "Der letzte aktive Administrator kann nicht gelöscht werden.") db.delete(user) db.commit() return RedirectResponse("/users?toast_success=Benutzer gelöscht", status_code=303) def _ldap_search_users(query_text: str, auth_config: dict) -> list[dict]: """List AD users through the configured LDAP service account. An empty query returns all active user accounts. A search term matches usernames, display names, and email addresses. """ 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() 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() if not server_name: raise HTTPException(400, "LDAP-Server ist nicht konfiguriert") if not base_dn: raise HTTPException(400, "LDAP Benutzer-Basis-DN ist nicht konfiguriert") if bind_dn and not bind_password: raise HTTPException(400, f"LDAP-Bind-Passwort fehlt (Umgebungsvariable {env_name})") raw_query = query_text.strip() def ldap_wildcard_pattern(value: str) -> str: # Allow user wildcards while safely escaping all other LDAP special characters. # * matches any number of characters. parts = [] literal = [] for char in value: if char == "*": if literal: parts.append(escape_filter_chars("".join(literal))) literal = [] parts.append("*") else: literal.append(char) if literal: parts.append(escape_filter_chars("".join(literal))) pattern = "".join(parts) # Without an explicit wildcard, retain the existing substring search. if "*" not in value: pattern = f"*{pattern}*" return pattern base_filter = "(&(objectCategory=person)(objectClass=user)(sAMAccountName=*)(!(userAccountControl:1.2.840.113556.1.4.803:=2)))" if raw_query: q = ldap_wildcard_pattern(raw_query) search_filter = ( "(&(objectCategory=person)(objectClass=user)(sAMAccountName=*)" "(!(userAccountControl:1.2.840.113556.1.4.803:=2))" f"(|(sAMAccountName={q})(displayName={q})(mail={q})))" ) else: search_filter = base_filter 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=30, raise_exceptions=False, ) try: # Depending on the version, ldap3.Connection.open() does not return a reliable # Boolean result. Open the connection and then bind explicitly. if start_tls and not use_ssl: conn.open() if not conn.start_tls(): raise HTTPException(400, f"LDAP StartTLS fehlgeschlagen: {conn.last_error}") if not conn.bind(): raise HTTPException(400, f"LDAP Bind fehlgeschlagen: {conn.last_error}") attributes = ["sAMAccountName", "displayName", "mail", "distinguishedName"] entries = conn.extend.standard.paged_search( search_base=base_dn, search_filter=search_filter, search_scope=SUBTREE, attributes=attributes, paged_size=500, generator=False, ) result = [] for entry in entries: if entry.get("type") != "searchResEntry": continue attrs = entry.get("attributes", {}) username = str(attrs.get("sAMAccountName") or "").strip() if not username: continue result.append({ "username": username, "display_name": str(attrs.get("displayName") or username), "email": str(attrs.get("mail") or ""), "dn": str(entry.get("dn") or attrs.get("distinguishedName") or ""), }) result.sort(key=lambda item: ((item.get("display_name") or item["username"]).casefold(), item["username"].casefold())) ldap_logger.info("LDAP directory search completed: query=%r matches=%s", raw_query, len(result)) if _ldap_debug_enabled(auth_config): ldap_logger.debug("LDAP directory search details: filter=%r base_dn=%r", search_filter, base_dn) return result except HTTPException: raise except LDAPException as exc: ldap_logger.error("LDAP directory search failed because of an LDAP error") if _ldap_debug_enabled(auth_config): ldap_logger.exception("LDAP exception during directory search: %s", exc) raise HTTPException(400, "LDAP query failed") from exc except Exception as exc: ldap_logger.error("LDAP directory search failed because of an unexpected error") if _ldap_debug_enabled(auth_config): ldap_logger.exception("Unexpected LDAP directory-search error: %s", exc) raise HTTPException(400, "LDAP query failed") from exc finally: try: conn.unbind() except Exception: pass def _mark_existing_ldap_users(results: list[dict], db: Session) -> list[dict]: usernames = [item["username"].casefold() for item in results] existing = {} if usernames: existing = { user.username.casefold(): user for user in db.query(User).filter(func.lower(User.username).in_(usernames)).all() } for item in results: user = existing.get(item["username"].casefold()) item["existing"] = bool(user) item["existing_source"] = user.auth_source if user else "" return results @app.get("/settings/ldap-search") def ldap_search_form(request: Request, show_all: int = 0, db: Session = Depends(get_db)): _require_admin(request) results = [] loaded = False if show_all: cfg = load_config() results = _mark_existing_ldap_users(_ldap_search_users("", cfg.get("authentication", {})), db) loaded = True return templates.TemplateResponse("ldap_search.html", { "request": request, "query": "", "results": results, "loaded": loaded, }) @app.post("/settings/ldap-search") def ldap_search_page(request: Request, ldap_query: str = Form(""), db: Session = Depends(get_db)): _require_admin(request) cfg = load_config() results = _mark_existing_ldap_users(_ldap_search_users(ldap_query, cfg.get("authentication", {})), db) return templates.TemplateResponse("ldap_search.html", { "request": request, "query": ldap_query, "results": results, "loaded": True, }) @app.post("/settings/ldap-search/import") def ldap_users_import( request: Request, usernames: list[str] = Form([]), db: Session = Depends(get_db), ): _require_admin(request) selected = {value.strip().casefold() for value in usernames if value.strip()} if not selected: return RedirectResponse("/settings/ldap-search?toast_warning=Keine Benutzer ausgewählt", status_code=303) cfg = load_config() ldap_users = _ldap_search_users("", cfg.get("authentication", {})) ldap_by_name = {item["username"].casefold(): item for item in ldap_users} created = 0 updated = 0 skipped = 0 for normalized in selected: item = ldap_by_name.get(normalized) if not item: skipped += 1 continue user = db.query(User).filter(func.lower(User.username) == item["username"].casefold()).first() if user: # Local accounts are not converted to LDAP accounts automatically. if user.auth_source != "ldap": skipped += 1 continue user.display_name = item["display_name"] or item["username"] user.email = item["email"] or None updated += 1 else: db.add(User( username=item["username"], display_name=item["display_name"] or item["username"], email=item["email"] or None, password_hash=None, auth_source="ldap", is_active=True, is_admin=False, allowed_mesh_groups=[], language_code=load_config().get("general", {}).get("default_language", "en"), )) created += 1 db.commit() return RedirectResponse( f"/users?toast_success={created} LDAP-Benutzer angelegt, {updated} aktualisiert, {skipped} übersprungen", status_code=303, ) @app.get("/settings/translations") def translations_page(request: Request, missing_only: int = 0, db: Session = Depends(get_db)): _require_admin(request) langs = i18n_languages(db, active_only=False) rows = {} for item in db.query(Translation).order_by(Translation.translation_key, Translation.language_code).all(): rows.setdefault(item.translation_key, {})[item.language_code] = item.text_value if missing_only: rows = {key: values for key, values in rows.items() if any(not values.get(lang.code) for lang in langs)} return templates.TemplateResponse("translations.html", {"request": request, "languages": langs, "rows": rows, "missing_only": bool(missing_only)}) @app.post("/settings/translations/save") async def translations_save(request: Request, db: Session = Depends(get_db)): _require_admin(request) form = await request.form() langs = {lang.code for lang in i18n_languages(db, active_only=False)} for field, value in form.multi_items(): if not field.startswith("tr__"): continue _, code, key = field.split("__", 2) if code not in langs: continue row = db.query(Translation).filter(Translation.translation_key == key, Translation.language_code == code).first() if row: row.text_value = str(value) elif str(value).strip(): db.add(Translation(translation_key=key, language_code=code, text_value=str(value))) db.commit(); clear_translation_cache() return RedirectResponse("/settings/translations?toast_success=Translations updated", status_code=303) @app.get("/settings/translations/export") def translations_export(request: Request, db: Session = Depends(get_db)): _require_admin(request) langs = i18n_languages(db, active_only=False) data = {} for item in db.query(Translation).all(): data.setdefault(item.translation_key, {})[item.language_code] = item.text_value headers = ["Key"] + [lang.code for lang in langs] rows = [[key] + [values.get(lang.code, "") for lang in langs] for key, values in sorted(data.items())] return _xlsx_response("assetmanager-translations.xlsx", headers, rows) @app.post("/settings/translations/import") async def translations_import(request: Request, file: UploadFile = File(...), ignore_empty: str | None = Form(None), db: Session = Depends(get_db)): _require_admin(request) payload = await file.read() workbook = load_workbook(io.BytesIO(payload), data_only=True) sheet = workbook.active headers = [str(cell.value or "").strip() for cell in sheet[1]] if not headers or headers[0].casefold() != "key": raise HTTPException(400, "The first Excel column must be Key") known = {lang.code for lang in i18n_languages(db, active_only=False)} columns = [(idx, code) for idx, code in enumerate(headers[1:], start=2) if code in known] changed = 0 for values in sheet.iter_rows(min_row=2, values_only=True): key = str(values[0] or "").strip() if not key: continue for idx, code in columns: value = values[idx-1] text_value = "" if value is None else str(value) if not text_value and ignore_empty == "on": continue row = db.query(Translation).filter(Translation.translation_key == key, Translation.language_code == code).first() if row: row.text_value = text_value else: db.add(Translation(translation_key=key, language_code=code, text_value=text_value)) changed += 1 db.commit(); clear_translation_cache() return RedirectResponse(f"/settings/translations?toast_success={changed} translations imported", status_code=303) @app.get("/settings/appearance") def settings_appearance_page(request: Request, db: Session = Depends(get_db)): _require_admin(request) categories = db.query(Category).order_by(Category.name.asc()).all() return templates.TemplateResponse( "settings.html", { "request": request, "settings": public_config(), "categories": categories, "languages": i18n_languages(db), "settings_section": "appearance", "system_storage": system_storage_information(), }, ) @app.get("/settings/authentication") def settings_authentication_page(request: Request, db: Session = Depends(get_db)): _require_admin(request) categories = db.query(Category).order_by(Category.name.asc()).all() return templates.TemplateResponse( "settings.html", { "request": request, "settings": public_config(), "categories": categories, "languages": i18n_languages(db), "settings_section": "authentication", "system_storage": system_storage_information(), }, ) @app.get("/settings/system") def settings_system_page(request: Request, db: Session = Depends(get_db)): _require_admin(request) categories = db.query(Category).order_by(Category.name.asc()).all() return templates.TemplateResponse( "settings.html", { "request": request, "settings": public_config(), "categories": categories, "languages": i18n_languages(db), "settings_section": "system", "system_storage": system_storage_information(), }, ) @app.get("/settings/import-export") def settings_import_export_page(request: Request): _require_admin(request) return templates.TemplateResponse( "settings_import_export.html", {"request": request}, ) @app.get("/settings/software") def settings_software_page(request: Request): _require_admin(request) settings = _software_settings() callback_base = str(settings.get("callback_base_url", "") or "").strip().rstrip("/") return templates.TemplateResponse( "settings_software.html", { "request": request, "software_settings": settings, "callback_health_url": f"{callback_base}/api/software-callback/health" if callback_base else "", "debug_log": _software_debug_tail(), "debug_log_path": str(SOFTWARE_CALLBACK_DEBUG_LOG), }, ) @app.post("/settings/software") def settings_software_save( request: Request, dispatch_delay_seconds: int = Form(2), callback_base_url: str = Form(""), callback_timeout_minutes: int = Form(30), callback_worker_count: int = Form(4), callback_test_timeout_seconds: int = Form(10), callback_test_verify_tls: str | None = Form(None), debug_logging: str | None = Form(None), debug_log_max_lines: int = Form(1000), automatic_retry_enabled: str | None = Form(None), automatic_retry_max_age_hours: int = Form(12), automatic_retry_interval_seconds: int = Form(60), automatic_retry_max_retries: int = Form(3), inventory_exclusion_platform: list[str] = Form(default=[]), inventory_exclusion_name: list[str] = Form(default=[]), ): _require_admin(request) callback_base_url = callback_base_url.strip().rstrip("/") if callback_base_url: parsed = urllib.parse.urlsplit(callback_base_url) if parsed.scheme not in {"http", "https"} or not parsed.hostname: return RedirectResponse( "/settings/software?toast_error=" + quote(_translate_request(request, "software.settings.invalid_url", "Enter a valid HTTP or HTTPS base URL.")), status_code=303, ) submitted_exclusion_rules = _normalize_software_exclusion_rules([ {"platform": platform, "name_contains": name} for platform, name in zip(inventory_exclusion_platform, inventory_exclusion_name) ]) current = load_config() software = dict(current.get("software", {})) software.update({ "dispatch_delay_seconds": max(0, min(int(dispatch_delay_seconds), 60)), "callback_base_url": callback_base_url, "callback_timeout_minutes": max(5, min(int(callback_timeout_minutes), 240)), "callback_worker_count": max(1, min(int(callback_worker_count), 8)), "callback_test_timeout_seconds": max(2, min(int(callback_test_timeout_seconds), 60)), "callback_test_verify_tls": callback_test_verify_tls == "on", "debug_logging": debug_logging == "on", "debug_log_max_lines": max(100, min(int(debug_log_max_lines), 10000)), "automatic_retry_enabled": automatic_retry_enabled == "on", "automatic_retry_max_age_hours": max(1, min(int(automatic_retry_max_age_hours), 168)), "automatic_retry_interval_seconds": max(10, min(int(automatic_retry_interval_seconds), 86400)), "automatic_retry_max_retries": max(1, min(int(automatic_retry_max_retries), 10)), "inventory_exclusion_rules": submitted_exclusion_rules, }) save_config({"software": software}) _software_debug_log( f"SETTINGS saved | dispatch_delay_seconds={software['dispatch_delay_seconds']} | callback_base_url={callback_base_url or ''} | callback_timeout_minutes={software['callback_timeout_minutes']} | callback_worker_count={software['callback_worker_count']} | automatic_retry_enabled={software['automatic_retry_enabled']} | automatic_retry_max_age_hours={software['automatic_retry_max_age_hours']} | automatic_retry_interval_seconds={software['automatic_retry_interval_seconds']} | automatic_retry_max_retries={software['automatic_retry_max_retries']} | inventory_exclusion_rules={len(submitted_exclusion_rules)} | verify_tls={software['callback_test_verify_tls']}", force=True, ) return RedirectResponse( "/settings/software?toast_success=" + quote(_translate_request(request, "software.settings.saved", "Software settings saved.")), status_code=303, ) @app.post("/settings/software/test-callback") def settings_software_test_callback(request: Request): _require_admin(request) settings = _software_settings() callback_base = str(settings.get("callback_base_url", "") or "").strip().rstrip("/") if not callback_base: callback_base = str(request.base_url).rstrip("/") health_url = f"{callback_base}/api/software-callback/health" timeout = max(2, min(int(settings.get("callback_test_timeout_seconds", 10) or 10), 60)) verify_tls = bool(settings.get("callback_test_verify_tls", True)) parsed = urllib.parse.urlsplit(health_url) _software_debug_log("=" * 72, force=True) _software_debug_log("CALLBACK TEST started", force=True) _software_debug_log(f"Base URL: {callback_base}", force=True) _software_debug_log(f"Health URL: {health_url}", force=True) _software_debug_log(f"Scheme: {parsed.scheme} | Host: {parsed.hostname} | Port: {parsed.port or ('443' if parsed.scheme == 'https' else '80')}", force=True) _software_debug_log(f"Timeout: {timeout}s | Verify TLS: {verify_tls}", force=True) try: dns_started = time.monotonic() addresses = sorted({item[4][0] for item in socket.getaddrinfo(parsed.hostname, parsed.port or (443 if parsed.scheme == "https" else 80), type=socket.SOCK_STREAM)}) _software_debug_log(f"DNS: {', '.join(addresses)} ({(time.monotonic()-dns_started)*1000:.1f} ms)", force=True) except Exception as exc: _software_debug_log(f"DNS ERROR: {type(exc).__name__}: {exc}", force=True) context = None if parsed.scheme == "https" and not verify_tls: context = ssl._create_unverified_context() request_started = time.monotonic() try: req = urllib.request.Request( health_url, headers={"User-Agent": f"AssetManager/{application_version()} CallbackTest"}, method="GET", ) with urllib.request.urlopen(req, timeout=timeout, context=context) as response: body = response.read(65536).decode("utf-8", errors="replace") elapsed = (time.monotonic() - request_started) * 1000 _software_debug_log(f"HTTP STATUS: {response.status} {response.reason} ({elapsed:.1f} ms)", force=True) _software_debug_log(f"Content-Type: {response.headers.get('Content-Type', '-')}", force=True) _software_debug_log(f"Response body: {body}", force=True) payload = json.loads(body) if response.status == 200 and payload.get("ok") is True: _software_debug_log("RESULT: Callback health endpoint is reachable.", force=True) return RedirectResponse( "/settings/software?toast_success=" + quote(_translate_request(request, "software.settings.test_success", "Callback endpoint is reachable.")), status_code=303, ) raise ValueError("Health response did not contain ok=true") except urllib.error.HTTPError as exc: body = exc.read(65536).decode("utf-8", errors="replace") _software_debug_log(f"HTTP ERROR: {exc.code} {exc.reason}", force=True) _software_debug_log(f"Response body: {body}", force=True) message = f"HTTP {exc.code}: {exc.reason}" except Exception as exc: _software_debug_log(f"REQUEST ERROR: {type(exc).__name__}: {exc}", force=True) message = f"{type(exc).__name__}: {exc}" _software_debug_log("RESULT: Callback health endpoint is not reachable.", force=True) return RedirectResponse( "/settings/software?toast_error=" + quote(_translate_request(request, "software.settings.test_failed", "Callback test failed: {error}", error=message)), status_code=303, ) @app.get("/settings/software/debug-log") def settings_software_debug_log(request: Request): _require_admin(request) return Response(_software_debug_tail(), media_type="text/plain; charset=utf-8", headers={"Cache-Control": "no-store"}) @app.post("/settings/software/debug-log/clear") def settings_software_debug_log_clear(request: Request): _require_admin(request) try: SOFTWARE_CALLBACK_DEBUG_LOG.unlink(missing_ok=True) except OSError: logger.exception("Software callback debug log could not be cleared") return RedirectResponse( "/settings/software?toast_success=" + quote(_translate_request(request, "software.settings.log_cleared", "Debug log cleared.")), status_code=303, ) @app.get("/settings/backup") def settings_backup_page(request: Request): _require_admin(request) return templates.TemplateResponse( "settings_backup.html", { "request": request, "backups": list_backups(), "backup_directory": str(BACKUP_DIR), "backup_interval_hours": BACKUP_INTERVAL_HOURS, "backup_retention_days": BACKUP_RETENTION_DAYS, }, ) @app.post("/settings/backup/create") def settings_backup_create(request: Request): _require_admin(request) try: created_by = _changed_by(request) or "admin" create_backup(created_by=created_by, reason="manual") return RedirectResponse("/settings/backup?toast_success=" + quote(_translate_request(request, "backup.created_success", "Backup created")), status_code=303) except Exception as exc: logger.exception("Backup creation failed") return RedirectResponse("/settings/backup?toast_error=" + quote(str(exc)), status_code=303) @app.get("/settings/backup/{backup_name}/download") def settings_backup_download(request: Request, backup_name: str): _require_admin(request) try: path = backup_path(backup_name) if not path.is_file(): raise HTTPException(status_code=404) return FileResponse(path, media_type="application/zip", filename=path.name) except ValueError: raise HTTPException(status_code=404) @app.post("/settings/backup/{backup_name}/delete") def settings_backup_delete(request: Request, backup_name: str): _require_admin(request) try: delete_backup(backup_name) return RedirectResponse("/settings/backup?toast_success=" + quote(_translate_request(request, "backup.deleted_success", "Backup deleted")), status_code=303) except Exception as exc: logger.exception("Backup deletion failed") return RedirectResponse("/settings/backup?toast_error=" + quote(str(exc)), status_code=303) @app.post("/settings/backup/upload") async def settings_backup_upload(request: Request, backup_file: UploadFile = File(...)): _require_admin(request) try: content = await backup_file.read() store_uploaded_backup(backup_file.filename or "backup.zip", content) return RedirectResponse("/settings/backup?toast_success=" + quote(_translate_request(request, "backup.uploaded_success", "Backup uploaded")), status_code=303) except Exception as exc: logger.exception("Backup upload failed") return RedirectResponse("/settings/backup?toast_error=" + quote(str(exc)), status_code=303) @app.post("/settings/backup/{backup_name}/restore") def settings_backup_restore(request: Request, backup_name: str, confirmation: str = Form("")): _require_admin(request) if confirmation.strip().upper() != "RESTORE": return RedirectResponse("/settings/backup?toast_warning=" + quote(_translate_request(request, "backup.restore_confirmation_missing", "Restore confirmation is missing")), status_code=303) try: # Safety copy immediately before replacing data. create_backup(created_by="system", reason="before_restore") restore_backup(backup_name) clear_translation_cache() return RedirectResponse("/settings/backup?toast_success=" + quote(_translate_request(request, "backup.restored_success", "Backup restored")), status_code=303) except Exception as exc: logger.exception("Backup restore failed") return RedirectResponse("/settings/backup?toast_error=" + quote(str(exc)), status_code=303) @app.get("/settings") def settings_page(request: Request, db: Session = Depends(get_db)): _require_admin(request) categories = db.query(Category).order_by(Category.name.asc()).all() return templates.TemplateResponse( "settings.html", { "request": request, "settings": public_config(), "categories": categories, "languages": i18n_languages(db), "settings_section": "appearance", "system_storage": system_storage_information(), }, ) THEME_COLOR_DEFAULTS = { "primary_color": "#0878bd", "primary_hover_color": "#075f94", "header_color": "#063c66", "navigation_color": "#ffffff", "page_background": "#f2f4f6", "surface_color": "#ffffff", "text_color": "#1f2933", "border_color": "#d7dde3", "success_color": "#198754", "warning_color": "#d97706", "danger_color": "#b42318", } def _safe_hex_color(value: str, fallback: str) -> str: candidate = (value or "").strip() return candidate.lower() if re.fullmatch(r"#[0-9a-fA-F]{6}", candidate) else fallback @app.post("/settings") async def settings_save( request: Request, title: str = Form("AssetManager"), company_name: str = Form(""), about_author: str = Form(""), about_organization: str = Form(""), about_contact: str = Form(""), about_website: str = Form(""), about_repository: str = Form(""), about_license: str = Form(""), about_copyright: str = Form(""), callback_base_url: str = Form(""), default_language: str = Form("en"), custom_css: str = Form(""), default_theme: str = Form("light"), primary_color: str = Form("#0878bd"), primary_hover_color: str = Form("#075f94"), header_color: str = Form("#063c66"), navigation_color: str = Form("#ffffff"), page_background: str = Form("#f2f4f6"), surface_color: str = Form("#ffffff"), text_color: str = Form("#1f2933"), border_color: str = Form("#d7dde3"), 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), 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"), ldap_debug_logging: str | None = Form(None), remove_logo: str | None = Form(None), remove_favicon: str | None = Form(None), logo: UploadFile | None = File(None), favicon: 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() general["about_author"] = about_author.strip() general["about_organization"] = about_organization.strip() general["about_contact"] = about_contact.strip() general["about_website"] = about_website.strip() general["about_repository"] = about_repository.strip() general["about_license"] = about_license.strip() general["about_copyright"] = about_copyright.strip() general["callback_base_url"] = callback_base_url.strip().rstrip("/") general["default_language"] = default_language if default_language in {"en", "de"} else "en" general["custom_css"] = custom_css.replace("\x00", "").strip() general["default_theme"] = default_theme if default_theme in {"light", "dark"} else "light" colors = {} submitted_colors = { "primary_color": primary_color, "primary_hover_color": primary_hover_color, "header_color": header_color, "navigation_color": navigation_color, "page_background": page_background, "surface_color": surface_color, "text_color": text_color, "border_color": border_color, "success_color": success_color, "warning_color": warning_color, "danger_color": danger_color, } for color_name, fallback in THEME_COLOR_DEFAULTS.items(): colors[color_name] = _safe_hex_color(submitted_colors.get(color_name, ""), fallback) general["colors"] = colors 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 remove_favicon == "on": old_favicon = general.get("favicon") if old_favicon: delete_asset_image(old_favicon) general["favicon"] = "" if favicon and favicon.filename: old_favicon = general.get("favicon") new_favicon = save_upload(favicon) if old_favicon and old_favicon != new_favicon: delete_asset_image(old_favicon) general["favicon"] = new_favicon 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", "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}) 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() 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() return templates.TemplateResponse("meshcentral_sync.html", { "request": request, "runs": runs, "config": public_config(), "linked": linked, "conflicts": conflicts, "missing": missing, "definitions": definitions, "mappings": mappings, "transform_options": [ "raw", "string", "integer", "decimal", "boolean", "json", "normalize_serial", "normalize_mac", "active_ipv4_address", "active_ipv4_mac", "sum_memory_gb", "format_memory_modules", "sum_storage_gb", "format_drives", "gpu_names", "active_antivirus", ], }) @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) # --------------------------------------------------------------------------- # v0.3.8.0: central field catalog and MeshCentral synchronization settings # --------------------------------------------------------------------------- @app.get('/settings/fields') def field_definitions_page(request: Request, db: Session = Depends(get_db)): _require_admin(request) definitions = db.query(FieldDefinition).order_by(FieldDefinition.sort_order, FieldDefinition.label).all() value_counts = dict(db.query(AssetFieldValue.field_definition_id, __import__('sqlalchemy').func.count(AssetFieldValue.id)).group_by(AssetFieldValue.field_definition_id).all()) return templates.TemplateResponse('field_definitions.html', {'request': request, 'definitions': definitions, 'data_types': FIELD_DATA_TYPES, 'value_counts': value_counts}) @app.get('/settings/fields/new') def field_definition_new(request: Request): _require_admin(request) return templates.TemplateResponse('field_definition_form.html', {'request': request, 'definition': None, 'data_types': FIELD_DATA_TYPES}) @app.post('/settings/fields/new') def field_definition_create(request: Request, field_name: str = Form(...), label: str = Form(...), description: str = Form(''), data_type: str = Form('short_text'), readonly: str | None = Form(None), searchable: str | None = Form(None), filterable: str | None = Form(None), chart_enabled: str | None = Form(None), excel_import: str | None = Form(None), excel_export: str | None = Form(None), bulk_edit: str | None = Form(None), is_default: str | None = Form(None), list_width: str | None = Form(None), db: Session = Depends(get_db)): _require_admin(request) name = re.sub(r'[^a-z0-9_]', '_', field_name.strip().lower()).strip('_') if not name or name in ASSET_FIELDS: raise HTTPException(400, 'The field name is invalid or already reserved.') if data_type not in FIELD_DATA_TYPES: raise HTTPException(400, 'Invalid data type.') definition = FieldDefinition(field_name=name, label=label.strip(), description=description.strip() or None, data_type=data_type, readonly=readonly == 'on', is_system=False, is_active=True, translation_key=f'field.{name}', searchable=searchable == 'on', filterable=filterable == 'on', chart_enabled=chart_enabled == 'on', excel_import=excel_import == 'on', excel_export=excel_export == 'on', bulk_edit=bulk_edit == 'on', is_default=is_default == 'on', list_width=_optional_form_int(list_width, field_name="list_width", minimum=40, maximum=800), sort_order=(db.query(FieldDefinition).count()+1)) db.add(definition) try: db.flush() for category in db.query(Category).all(): db.add(CategoryField(category_id=category.id, field_name=name, label=definition.label, field_definition_id=definition.id, active=False, required=False, show_in_list=False, readonly=definition.readonly, sort_order=definition.sort_order)) # Add English fallback translation; German can be maintained in translation editor. db.add(Translation(translation_key=definition.translation_key, language_code='en', text_value=definition.label)) db.commit(); clear_translation_cache() except IntegrityError as exc: db.rollback(); raise HTTPException(400, 'A field with this name already exists.') from exc return RedirectResponse('/settings/fields?toast_success=Field created', status_code=303) @app.get('/settings/fields/{definition_id}/edit') def field_definition_edit(definition_id: int, request: Request, db: Session = Depends(get_db)): _require_admin(request) definition = db.get(FieldDefinition, definition_id) if not definition: raise HTTPException(404, 'Field not found') return templates.TemplateResponse('field_definition_form.html', {'request': request, 'definition': definition, 'data_types': FIELD_DATA_TYPES}) @app.post('/settings/fields/{definition_id}/edit') def field_definition_update(definition_id: int, request: Request, label: str = Form(...), description: str = Form(''), data_type: str = Form('short_text'), readonly: str | None = Form(None), is_active: str | None = Form(None), searchable: str | None = Form(None), filterable: str | None = Form(None), chart_enabled: str | None = Form(None), excel_import: str | None = Form(None), excel_export: str | None = Form(None), bulk_edit: str | None = Form(None), is_default: str | None = Form(None), list_width: str | None = Form(None), db: Session = Depends(get_db)): _require_admin(request) definition = db.get(FieldDefinition, definition_id) if not definition: raise HTTPException(404, 'Field not found') if data_type not in FIELD_DATA_TYPES: raise HTTPException(400, 'Invalid data type') if not definition.is_system and definition.data_type != data_type and db.query(AssetFieldValue).filter(AssetFieldValue.field_definition_id == definition.id).count(): raise HTTPException(400, 'The data type can only be changed while the custom field has no stored values.') definition.label=label.strip(); definition.description=description.strip() or None; definition.data_type=data_type definition.readonly=True if definition.field_name in SYSTEM_READONLY_FIELDS else readonly == 'on' definition.is_active=True if definition.is_system else is_active == 'on' definition.searchable=searchable == 'on'; definition.filterable=filterable == 'on'; definition.chart_enabled=chart_enabled == 'on' definition.excel_import=False if definition.readonly else excel_import == 'on'; definition.excel_export=excel_export == 'on'; definition.bulk_edit=False if definition.readonly else bulk_edit == 'on'; definition.is_default=is_default == 'on'; definition.list_width=_optional_form_int(list_width, field_name="list_width", minimum=40, maximum=800) for link in definition.category_links: link.label=definition.label; link.readonly=definition.readonly db.commit(); clear_translation_cache() return RedirectResponse('/settings/fields?toast_success=Field saved', status_code=303) @app.post('/settings/fields/{definition_id}/delete') def field_definition_delete(definition_id: int, request: Request, confirm_values: str | None = Form(None), db: Session = Depends(get_db)): _require_admin(request) definition = db.get(FieldDefinition, definition_id) if not definition: raise HTTPException(404, 'Field not found') if definition.is_system: raise HTTPException(400, 'System fields cannot be deleted.') count = db.query(AssetFieldValue).filter(AssetFieldValue.field_definition_id == definition.id).count() if count and confirm_values != 'on': raise HTTPException(400, f'This field contains values for {count} assets. Confirm deletion of all values.') db.delete(definition); db.commit() return RedirectResponse('/settings/fields?toast_success=Field deleted', status_code=303) @app.post('/sync/meshcentral/settings') async def meshcentral_settings_save(request: Request, db: Session = Depends(get_db)): _require_admin(request) form = await request.form() current = load_config() mesh = dict(current.get('meshcentral', {})) try: timeout_seconds = max(10, min(int(form.get('timeout_seconds') or 600), 3600)) except (TypeError, ValueError): timeout_seconds = 600 mesh.update({ 'enabled': form.get('enabled') == 'on', 'url': (form.get('url') or '').strip(), 'username': (form.get('username') or '').strip(), 'password_env': (form.get('password_env') or 'MESHCENTRAL_PASSWORD').strip(), 'tenant': (form.get('tenant') or '').strip(), 'meshctrl_path': (form.get('meshctrl_path') or '/opt/meshcentral/node_modules/meshcentral/meshctrl.js').strip(), 'verify_tls': form.get('verify_tls') == 'on', 'include_details': form.get('include_details') == 'on', 'timeout_seconds': timeout_seconds, 'create_missing_assets': form.get('create_missing_assets') == 'on', 'mark_missing_devices': form.get('mark_missing_devices') == 'on', 'store_source_json': form.get('store_source_json') == 'on', }) mesh['match_order'] = form.getlist('match_order') or ['node_id', 'serial_number'] # Do not retain plaintext passwords from older versions. mesh.pop('password', None) mesh.pop('field_mappings', None) mesh.pop('field_rules', None) save_config({'meshcentral': mesh}) return RedirectResponse('/sync/meshcentral?toast_success=MeshCentral-Einstellungen gespeichert', status_code=303) @app.post('/sync/meshcentral/mappings/save') async def meshcentral_mappings_save(request: Request, db: Session = Depends(get_db)): _require_admin(request) form = await request.form() allowed_transforms = { 'raw','string','integer','decimal','boolean','json','normalize_serial','normalize_mac', 'active_ipv4_address','active_ipv4_mac','sum_memory_gb','format_memory_modules', 'sum_storage_gb','format_drives','gpu_names','active_antivirus' } allowed_multi = {'first','join','json','count'} allowed_rules = {'fill_empty','meshcentral_wins','local_wins'} for row in db.query(MeshFieldMapping).all(): prefix = f'mapping_{row.id}_' source = str(form.get(prefix + 'source_path') or '').strip() target = str(form.get(prefix + 'target_field_name') or '').strip() if not source or not target: continue row.source_path = source row.target_field_name = target transform = str(form.get(prefix + 'transform') or 'raw') row.transform = transform if transform in allowed_transforms else 'raw' multi = str(form.get(prefix + 'multi_value_mode') or 'first') row.multi_value_mode = multi if multi in allowed_multi else 'first' row.separator = str(form.get(prefix + 'separator') or '\\n').replace('\\n', '\n').replace('\\t', '\t') row.enabled = form.get(prefix + 'enabled') == 'on' try: row.priority = int(form.get(prefix + 'priority') or 0) except (TypeError, ValueError): row.priority = 0 rule = str(form.get(prefix + 'update_rule') or 'fill_empty') row.update_rule = rule if rule in allowed_rules else 'fill_empty' row.description = str(form.get(prefix + 'description') or '').strip() or None db.commit() return RedirectResponse('/sync/meshcentral?toast_success=Mapping-Regeln gespeichert', status_code=303) @app.post('/sync/meshcentral/mappings/new') async def meshcentral_mapping_new(request: Request, db: Session = Depends(get_db)): _require_admin(request) form = await request.form() source = str(form.get('source_path') or '').strip() target = str(form.get('target_field_name') or '').strip() if not source or not target: return RedirectResponse('/sync/meshcentral?toast_error=Quellpfad und Zielfeld sind erforderlich', status_code=303) definition = db.query(FieldDefinition).filter(FieldDefinition.field_name == target, FieldDefinition.is_active.is_(True)).first() if not definition: return RedirectResponse('/sync/meshcentral?toast_error=Das Zielfeld existiert nicht oder ist inaktiv', status_code=303) max_priority = db.query(MeshFieldMapping).order_by(MeshFieldMapping.priority.desc()).first() row = MeshFieldMapping( source_path=source, target_field_name=target, transform=str(form.get('transform') or 'raw'), multi_value_mode=str(form.get('multi_value_mode') or 'first'), separator=str(form.get('separator') or '\\n').replace('\\n', '\n').replace('\\t', '\t'), enabled=form.get('enabled') == 'on', priority=(max_priority.priority + 10) if max_priority else 10, description=str(form.get('description') or '').strip() or None, update_rule=str(form.get('update_rule') or 'fill_empty'), is_system=False, ) db.add(row) db.commit() return RedirectResponse('/sync/meshcentral?toast_success=Neue Mapping-Regel angelegt', status_code=303) @app.post('/sync/meshcentral/mappings/{mapping_id}/delete') def meshcentral_mapping_delete(mapping_id: int, request: Request, db: Session = Depends(get_db)): _require_admin(request) row = db.query(MeshFieldMapping).filter(MeshFieldMapping.id == mapping_id).first() if not row: raise HTTPException(status_code=404, detail='Mapping-Regel nicht gefunden') if row.is_system: return RedirectResponse('/sync/meshcentral?toast_error=System-Mappings können deaktiviert, aber nicht gelöscht werden', status_code=303) db.delete(row) db.commit() return RedirectResponse('/sync/meshcentral?toast_success=Mapping-Regel gelöscht', status_code=303)