fix table with handling, icons implemented

This commit is contained in:
root
2026-08-09 08:50:13 +02:00
parent 5e32be7d0c
commit 3ff0794cfc
43 changed files with 1027 additions and 95 deletions
+136 -20
View File
@@ -61,6 +61,9 @@ 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)
STANDARD_IMAGE_DIR = Path(os.getenv("STANDARD_IMAGE_DIR", str(BASE_DIR / "static" / "uploads" / "library")))
STANDARD_IMAGE_DIR.mkdir(parents=True, exist_ok=True)
STANDARD_IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".webp", ".gif"}
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"))
@@ -1308,7 +1311,7 @@ def _seed_field_definitions(db: Session) -> None:
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"),
data_type=("boolean" if field_name == "mesh_online" else "integer" if field_name in {"mesh_mtype", "mesh_icon"} 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,
@@ -1360,7 +1363,8 @@ DEFAULT_MESH_FIELD_MAPPINGS = [
("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)"),
("node.mtype|mtype", "mesh_mtype", "integer", "first", "\n", True, 200, "MeshCentral Gruppentyp (mtype)"),
("node.icon|icon", "mesh_icon", "integer", "first", "\n", True, 205, "MeshCentral Geräte-Icon (platformType)"),
]
@@ -1371,6 +1375,14 @@ def _seed_mesh_field_mappings(db: Session) -> None:
for row in rows:
by_target.setdefault(row.target_field_name, []).append(row)
# Repair the historical system mapping that mixed group mtype and device icon.
# Existing user-created mappings are left untouched.
for row in by_target.get("mesh_mtype", []):
if row.is_system and row.source_path == "node.mtype|node.icon|mtype|icon":
row.source_path = "node.mtype|mtype"
row.description = "MeshCentral Gruppentyp (mtype)"
db.flush()
# 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 []
@@ -1798,12 +1810,20 @@ def delete_asset_image(image_path: str | None) -> None:
pass
def save_upload(upload: UploadFile | None) -> str | None:
def _validate_image_upload(upload: UploadFile | None) -> tuple[str, 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"}:
if suffix not in STANDARD_IMAGE_EXTENSIONS:
raise HTTPException(400, "Nur PNG, JPG, WEBP oder GIF sind erlaubt.")
return Path(upload.filename).stem, suffix
def save_upload(upload: UploadFile | None) -> str | None:
validated = _validate_image_upload(upload)
if not validated:
return None
_, suffix = validated
filename = f"{uuid.uuid4().hex}{suffix}"
target = UPLOAD_DIR / filename
with target.open("wb") as buffer:
@@ -1811,6 +1831,68 @@ def save_upload(upload: UploadFile | None) -> str | None:
return f"/static/uploads/{filename}"
def _standard_image_url(filename: str) -> str:
return "/static/uploads/library/" + urllib.parse.quote(filename)
def list_standard_images() -> list[dict[str, str]]:
"""Return reusable images stored in the persistent standard image library."""
images: list[dict[str, str]] = []
try:
candidates = sorted(STANDARD_IMAGE_DIR.iterdir(), key=lambda item: item.name.casefold())
except OSError:
return images
for path in candidates:
if not path.is_file() or path.suffix.lower() not in STANDARD_IMAGE_EXTENSIONS:
continue
images.append({"name": path.name, "path": _standard_image_url(path.name)})
return images
def resolve_standard_image(value: Any) -> str | None:
"""Accept only image paths currently present in the standard image library."""
requested = str(value or "").strip()
if not requested:
return None
allowed = {item["path"] for item in list_standard_images()}
if requested not in allowed:
raise HTTPException(400, "Ungültiges Standardbild ausgewählt.")
return requested
def save_standard_image(upload: UploadFile | None) -> str | None:
"""Store an image once in the reusable standard image library.
Existing files are never overwritten. If the original file name already
exists, a numeric suffix is appended (for example switch-1.png).
"""
validated = _validate_image_upload(upload)
if not validated:
return None
stem, suffix = validated
safe_stem = re.sub(r"[^A-Za-z0-9._-]+", "-", stem).strip("-._") or "image"
filename = f"{safe_stem}{suffix}"
counter = 1
while (STANDARD_IMAGE_DIR / filename).exists():
filename = f"{safe_stem}-{counter}{suffix}"
counter += 1
target = STANDARD_IMAGE_DIR / filename
with target.open("wb") as buffer:
shutil.copyfileobj(upload.file, buffer)
return _standard_image_url(filename)
def choose_form_image(form: Any, upload: UploadFile | None) -> str | None:
"""Resolve image input precedence: direct upload, new library upload, library selection."""
direct = save_upload(upload)
if direct:
return direct
library_upload = form.get("library_upload")
if library_upload is not None and getattr(library_upload, "filename", None):
return save_standard_image(library_upload)
return resolve_standard_image(form.get("library_image"))
def _display_value(value) -> str:
if value is None:
return ""
@@ -2860,7 +2942,7 @@ def asset_new(request: Request, category_id: int | None = None, db: Session = De
_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": {}})
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": {}, "standard_images": list_standard_images()})
@app.get("/assets/{asset_id}/duplicate")
@@ -2878,9 +2960,10 @@ def asset_duplicate(asset_id: int, request: Request, db: Session = Depends(get_d
duplicate.asset_tag = None
duplicate.mesh_node_id = None
duplicate.name = f"{original.name} Kopie"
duplicate.visual_source = getattr(original, "visual_source", "auto") or "auto"
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": {},
"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": {}, "standard_images": list_standard_images(),
})
@@ -2905,7 +2988,10 @@ async def asset_create(request: Request, category_id: int = Form(...), image: Up
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)
visual_source = str(form.get("visual_source") or "auto").strip().lower()
if visual_source not in {"auto", "asset_image", "category_image", "mesh_icon"}:
visual_source = "auto"
asset = Asset(category_id=category_id, image_path=choose_form_image(form, image), visual_source=visual_source, **data)
db.add(asset)
try:
db.flush()
@@ -3732,7 +3818,7 @@ def asset_edit(asset_id: int, request: Request, category_id: int | None = None,
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}})
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}, "standard_images": list_standard_images()})
@app.post("/assets/{asset_id}/edit")
@@ -3834,11 +3920,33 @@ async def asset_update(asset_id: int, request: Request, category_id: int = Form(
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)
visual_source = str(form.get("visual_source") or "auto").strip().lower()
if visual_source not in {"auto", "asset_image", "category_image", "mesh_icon"}:
visual_source = "auto"
old_visual_source = getattr(asset, "visual_source", "auto") or "auto"
if old_visual_source != visual_source:
changes[_translate_request(request, "assets.visual_source", "Display source")] = {
"old": _translate_request(request, f"assets.visual_source.{old_visual_source}", old_visual_source),
"new": _translate_request(request, f"assets.visual_source.{visual_source}", visual_source),
}
asset.visual_source = visual_source
remove_image_requested = str(form.get("remove_image") or "").strip().lower() in {"1", "true", "on", "yes"}
new_image = choose_form_image(form, image)
image_label = _translate_request(request, "assets.image", "Image")
if new_image:
if asset.image_path != new_image:
changes["Bild"] = {"old": asset.image_path or "", "new": new_image}
old_image = asset.image_path
if old_image != new_image:
changes[image_label] = {"old": old_image or "", "new": new_image}
asset.image_path = new_image
# Do not delete the previous upload here. Image files can be referenced
# by more than one record (for example after duplication). Orphaned
# uploads are intentionally left for a later maintenance/cleanup task.
elif remove_image_requested and asset.image_path:
old_image = asset.image_path
changes[image_label] = {"old": old_image, "new": ""}
asset.image_path = None
# Only remove the association. The physical file may still be referenced
# elsewhere and is cleaned up separately by maintenance tooling.
try:
_record_asset_history(db, asset, changes, source="manual-update", changed_by=_changed_by(request))
db.commit()
@@ -4238,12 +4346,11 @@ def assets_bulk_delete(request: Request, asset_ids: list[str] = Form([]), catego
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)
# Uploaded image files are intentionally retained. They may be shared by
# duplicated records and can be removed later by an orphan cleanup task.
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)
@@ -4253,10 +4360,10 @@ 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)
# Keep the uploaded image file. It may still be referenced by another
# record; orphan cleanup is handled separately.
return RedirectResponse("/assets", status_code=303)
@@ -4614,14 +4721,14 @@ def categories_activate_default_fields(
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": {}})
return templates.TemplateResponse("category_form.html", {"request": request, "category": None, "definitions": definitions, "selected_fields": {}, "standard_images": list_standard_images()})
@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))
category = Category(name=name.strip(), description=description or None, image_path=choose_form_image(form, image))
db.add(category)
db.flush()
definitions = db.query(FieldDefinition).filter(FieldDefinition.is_active.is_(True)).order_by(FieldDefinition.sort_order).all()
@@ -4644,7 +4751,7 @@ def category_edit(category_id: int, request: Request, db: Session = Depends(get_
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})
return templates.TemplateResponse("category_form.html", {"request": request, "category": category, "definitions": definitions, "selected_fields": selected_fields, "standard_images": list_standard_images()})
@app.post("/categories/{category_id}/edit")
@@ -4656,9 +4763,18 @@ async def category_update(category_id: int, request: Request, name: str = Form(.
form = await request.form()
category.name = name.strip()
category.description = description.strip() or None
new_image = save_upload(image)
remove_image_requested = str(form.get("remove_image") or "").strip().lower() in {"1", "true", "on", "yes"}
new_image = choose_form_image(form, image)
if new_image:
old_image = category.image_path
category.image_path = new_image
# Category images can be shared after duplicating a category. Replacing
# the association must therefore never delete the old physical file.
elif remove_image_requested and category.image_path:
old_image = category.image_path
category.image_path = None
# Only remove the category association. Shared/orphaned uploads are
# deliberately retained for a later maintenance cleanup.
# 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)