"""GE Aerospace image pages: the Overview tab and the OS and Packages tab. Every image page shares templates/_image_header.html (name, OS, tabs); the tab table and URL helpers are in services/image_pages.py. The Drivers tab lives in views/images_drivers.py, the Unattend tab is unattend_editor in app.py. OS and Packages edits three files with one Save: OperatingSystem.json, packages.json (Deploy/Control) and the HardwareModelSelection list of Tools/user_selections.json. The page posts the edited lists as JSON to /os/preview (diff only, nothing written), then the same body plus the previewed file hashes to /os/save. Writes go through services/control_files (format kept, .bak--webui backup, atomic, undo of the last save). """ import json from flask import Blueprint, flash, jsonify, redirect, render_template, request, url_for import config from services import control_files, image_pages from services.audit import audit bp = Blueprint("images", __name__, url_prefix="/images") UNDO_SCOPE = "os" def _unknown(): flash("Unknown image type.", "danger") return redirect(url_for("dashboard")) @bp.route("/") def overview(image_type): if image_type not in config.IMAGE_TYPES: return _unknown() return render_template( "image_overview.html", image_type=image_type, friendly_name=config.FRIENDLY_NAMES.get(image_type, image_type), active_tab="overview", ov=image_pages.overview(image_type), ) @bp.route("//os") def os_packages(image_type): if image_type not in config.IMAGE_TYPES: return _unknown() sel, _ = image_pages.user_selections(image_type) entries = image_pages.drivers(image_type) # Per driver family: how many map entries carry it, and how many of # those have their zip on disk (the old "On Disk" column, made exact). fam = {} for e in entries: f = fam.setdefault(str(e.get("family") or ""), {"entries": 0, "on_disk": 0}) f["entries"] += 1 if image_pages.zip_path(image_type, e): f["on_disk"] += 1 models = [dict(m=m, fam=fam.get(str(m.get("Id") or ""), {"entries": 0, "on_disk": 0})) for m in (sel.get("HardwareModelSelection") or []) if isinstance(m, dict)] oses = image_pages.operating_systems(image_type) pkgs = image_pages.packages(image_type) return render_template( "image_os.html", image_type=image_type, friendly_name=config.FRIENDLY_NAMES.get(image_type, image_type), active_tab="os", oses=oses, packages=pkgs, models=models, os_selection=sel.get("OperatingSystemSelection"), has_selections=bool(sel), last_save=control_files.last_save(image_type, UNDO_SCOPE), ) # --- save path: preview, save, undo --------------------------------------------------- def _clean_list(value, what): if not isinstance(value, list) or not all(isinstance(x, dict) for x in value): raise ValueError(f"{what}: expected a list of objects") # Keys starting with _ are page-side annotations, never file content. return [{k: v for k, v in x.items() if not str(k).startswith("_")} for x in value] def _check_parses(path, name): """Refuse to save over a file that exists but is not a JSON list: the page showed it as empty, so a save would replace content nobody saw (user_selections.json would lose every key but the model list).""" text = control_files.read_text(path) if not text.strip(): return try: data = json.loads(text.lstrip("\ufeff")) except ValueError: data = None if not isinstance(data, list) or (data and name == "user_selections.json" and not isinstance(data[0], dict)): raise ValueError(f"{name} on the share is not a JSON list this page can read " "(bad JSON or not UTF-8); fix or restore it by hand first") def _enabled_ok(value): """packages.json "enabled": the MCL files use 1/0, ours true/false; either is kept as it is (PESetup's type must not change under it).""" return isinstance(value, bool) or (isinstance(value, int) and value in (0, 1)) def _plans(image_type, body): """control_files plans for the sections present in the request body.""" plans = [] if "operating_systems" in body: data = _clean_list(body["operating_systems"], "operating systems") path = image_pages.control_file(image_type, "OperatingSystem.json") _check_parses(path, "OperatingSystem.json") plans.append(control_files.plan_json(path, data, "OperatingSystem.json")) if "packages" in body: data = _clean_list(body["packages"], "packages") for p in data: if "enabled" in p and not _enabled_ok(p["enabled"]): raise ValueError("packages: enabled must be true/false or 1/0") path = image_pages.control_file(image_type, "packages.json") _check_parses(path, "packages.json") plans.append(control_files.plan_json(path, data, "packages.json")) if "hardware_models" in body: models = _clean_list(body["hardware_models"], "hardware models") for m in models: m["Id"] = str(m.get("Id") or "").strip() m["Model"] = str(m.get("Model") or "").strip() if not m["Id"] or not m["Model"]: raise ValueError("hardware models: every row needs a Model and a Driver Family ID") _check_parses(image_pages.user_selections_path(image_type), "user_selections.json") sel, raw = image_pages.user_selections(image_type) first = dict(sel) # keeps key order; only the model list changes first["HardwareModelSelection"] = models new = [first] + list(raw[1:]) if raw else [first] plans.append(control_files.plan_json( image_pages.user_selections_path(image_type), new, "user_selections.json")) return plans def _body(): body = request.get_json(silent=True) if not isinstance(body, dict): raise ValueError("expected a JSON object") return body @bp.route("//os/preview", methods=["POST"]) def os_preview(image_type): if image_type not in config.IMAGE_TYPES: return jsonify({"error": "Unknown image type"}), 404 try: plans = _plans(image_type, _body()) except ValueError as ex: return jsonify({"error": str(ex)}), 400 files = [control_files.public(p) for p in plans] return jsonify({"files": files, "changed": sum(1 for f in files if f["changed"])}) @bp.route("//os/save", methods=["POST"]) def os_save(image_type): if image_type not in config.IMAGE_TYPES: return jsonify({"error": "Unknown image type"}), 404 try: body = _body() plans = _plans(image_type, body) expect = body.get("expect") if isinstance(body.get("expect"), dict) else {} changed = control_files.commit(image_type, UNDO_SCOPE, plans, action="OS and Packages save", expect=expect) except ValueError as ex: return jsonify({"error": str(ex)}), 400 except control_files.Conflict as ex: return jsonify({"error": str(ex)}), 409 except OSError as ex: return jsonify({"error": f"write failed: {ex}"}), 500 names = [p["name"] for p in changed] if names: audit("IMAGE_OS_SAVE", f"{image_type} {', '.join(names)}") flash(f"Saved {', '.join(names)}. A backup of each is next to it (.bak-...-webui).", "success") else: flash("Nothing changed, nothing written.", "info") return jsonify({"ok": True, "saved": names}) @bp.route("//os/undo", methods=["POST"]) def os_undo(image_type): if image_type not in config.IMAGE_TYPES: return _unknown() try: rec = control_files.undo(image_type, UNDO_SCOPE) names = ", ".join(f["name"] for f in rec["files"]) audit("IMAGE_OS_UNDO", f"{image_type} {names} (save of {rec.get('ts')})") flash(f"Undid the last save: {names} restored.", "success") except control_files.Conflict as ex: flash(f"Undo refused: {ex}", "danger") except OSError as ex: flash(f"Undo failed: {ex}", "danger") return redirect(image_pages.tab_url(image_type, "os"))