fix table with handling, icons implemented
This commit is contained in:
+136
-20
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user