Webapp: Updates pages, image page tabs with review-before-save, BPRT sync
Updates group: - Dell drivers and BIOS: check Dell's catalogs against all three driver maps and models.txt, download with a shared speed cap, repack, preview, apply with backups and rollback. - iPXE boot files: check GitHub for the signed Secure Boot set, verify the sha256 and each file's signer, install atomically, roll back. Includes the by-hand procedure. - Blancco: upload a release, stage it, test-boot it from the menu, activate, roll back. - BPRT sync: validate a batch uploaded from the MediaCreator Lite cache (scripts/windows/Sync-BPRT.ps1 or a zip), show the diff, swap with the site overlay files kept. Images: one sidebar entry per image with Overview, Drivers, Unattend and OS and Packages tabs. Every save shows a diff first, keeps the file's format, makes a backup and can be undone. Driver lint runs inline. Enrollment: upload standard/engineer MCL packages, one per tenant/purpose, with archive and restore. Hardware coverage counts engineer as Win11.
This commit is contained in:
360
webapp/app.py
360
webapp/app.py
@@ -10,6 +10,10 @@ This file is the route surface; most logic lives in ``services/``:
|
||||
services.deploy - import_deploy + merge_tree + symlink dance
|
||||
services.unattend - parse + build + form-extract for unattend.xml
|
||||
services.wim - boot.wim startnet.cmd extract/update via wimtools
|
||||
services.image_pages - per-image page tabs, header + overview data
|
||||
services.control_files - preview/backup/atomic write/undo of control files
|
||||
|
||||
The Updates pages are blueprints in ``views/`` (registered below).
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
@@ -35,7 +39,6 @@ from flask import (
|
||||
stream_with_context,
|
||||
url_for,
|
||||
)
|
||||
from lxml import etree
|
||||
from werkzeug.utils import secure_filename
|
||||
|
||||
import config
|
||||
@@ -43,17 +46,29 @@ from services import (
|
||||
blancco_evidence,
|
||||
hardware_coverage,
|
||||
blancco_report,
|
||||
dell_updates,
|
||||
deploy,
|
||||
driver_map,
|
||||
fs,
|
||||
control_files,
|
||||
image_pages,
|
||||
image_registry,
|
||||
images,
|
||||
imaging_status,
|
||||
mcl_ppkg,
|
||||
system,
|
||||
unattend,
|
||||
wim,
|
||||
)
|
||||
from services.audit import audit
|
||||
from services.csrf import init_csrf
|
||||
from views import blancco_update as blancco_update_views
|
||||
from views import bprt_sync as bprt_sync_views
|
||||
from views import dell_updates as dell_updates_views
|
||||
from views import images as images_views
|
||||
from views import images_drivers as images_drivers_views
|
||||
from views import images_unattend as images_unattend_views
|
||||
from views import ipxe as ipxe_views
|
||||
|
||||
app = Flask(__name__)
|
||||
app.secret_key = config.FLASK_SECRET_KEY
|
||||
@@ -65,6 +80,21 @@ init_csrf(app)
|
||||
# baked-in config.py defaults on first run). Mutates config.* in place.
|
||||
image_registry.reload()
|
||||
|
||||
# Updates pages (Dell drivers/BIOS, iPXE, Blancco, BPRT sync): one blueprint
|
||||
# each in views/, logic in services/; all routes live under /updates/.
|
||||
app.register_blueprint(dell_updates_views.bp)
|
||||
app.register_blueprint(ipxe_views.bp)
|
||||
app.register_blueprint(blancco_update_views.bp)
|
||||
app.register_blueprint(bprt_sync_views.bp)
|
||||
|
||||
# GE Aerospace image pages: one page per image with tabs (Overview, Drivers,
|
||||
# Unattend, OS and Packages) under templates/_image_header.html. The tab ->
|
||||
# endpoint table is services/image_pages.IMAGE_TABS; unattend_editor below
|
||||
# is the Unattend tab, the others are blueprints.
|
||||
app.register_blueprint(images_views.bp)
|
||||
app.register_blueprint(images_drivers_views.bp)
|
||||
app.register_blueprint(images_unattend_views.bp) # Unattend review/save/undo
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Routes - pages
|
||||
@@ -186,38 +216,15 @@ def unattend_editor(image_type):
|
||||
xml_file = fs.unattend_path(image_type)
|
||||
|
||||
if request.method == "POST":
|
||||
# Plain form post (the page itself saves through Review changes,
|
||||
# views/images_unattend.py). Same path: the form is applied to the
|
||||
# current text, backup, undo point, lint gate.
|
||||
save_mode = request.form.get("save_mode", "form")
|
||||
|
||||
if save_mode == "raw":
|
||||
raw_xml = request.form.get("raw_xml", "")
|
||||
try:
|
||||
etree.fromstring(raw_xml.encode("utf-8"))
|
||||
except etree.XMLSyntaxError as exc:
|
||||
flash(f"Invalid XML: {exc}", "danger")
|
||||
data = unattend.parse_unattend(xml_file)
|
||||
data["raw_xml"] = raw_xml
|
||||
return render_template(
|
||||
"unattend_editor.html",
|
||||
image_type=image_type,
|
||||
friendly_name=config.FRIENDLY_NAMES.get(image_type, image_type),
|
||||
data=data,
|
||||
image_types=config.IMAGE_TYPES,
|
||||
friendly_names=config.FRIENDLY_NAMES,
|
||||
)
|
||||
xml_content = raw_xml
|
||||
body = {"mode": "raw", "raw_xml": request.form.get("raw_xml", "")}
|
||||
else:
|
||||
form_data = unattend.extract_form_data(request.form)
|
||||
xml_content = unattend.build_unattend_xml(form_data)
|
||||
|
||||
try:
|
||||
os.makedirs(os.path.dirname(xml_file), exist_ok=True)
|
||||
with open(xml_file, "w", encoding="utf-8") as fh:
|
||||
fh.write(xml_content)
|
||||
audit("UNATTEND_SAVE", f"{image_type} ({save_mode})")
|
||||
flash("unattend.xml saved successfully.", "success")
|
||||
except Exception as exc:
|
||||
flash(f"Failed to save: {exc}", "danger")
|
||||
|
||||
body = {"mode": "form", "form": unattend.extract_form_data(request.form)}
|
||||
images_unattend_views.save_and_flash(image_type, body, f"Unattend save ({save_mode}, form post)")
|
||||
return redirect(url_for("unattend_editor", image_type=image_type))
|
||||
|
||||
data = unattend.parse_unattend(xml_file)
|
||||
@@ -225,77 +232,72 @@ def unattend_editor(image_type):
|
||||
"unattend_editor.html",
|
||||
image_type=image_type,
|
||||
friendly_name=config.FRIENDLY_NAMES.get(image_type, image_type),
|
||||
active_tab="unattend",
|
||||
data=data,
|
||||
image_types=config.IMAGE_TYPES,
|
||||
friendly_names=config.FRIENDLY_NAMES,
|
||||
**images_unattend_views.page_context(image_type),
|
||||
)
|
||||
|
||||
|
||||
@app.route("/images/<image_type>/config")
|
||||
def image_config(image_type):
|
||||
"""Old single Configuration page, now split into tabs. Bookmarks land on
|
||||
the Overview, or on one tab with ?tab=drivers|os|unattend."""
|
||||
if image_type not in config.IMAGE_TYPES:
|
||||
flash("Unknown image type.", "danger")
|
||||
return redirect(url_for("dashboard"))
|
||||
tab = request.args.get("tab", "overview")
|
||||
if tab not in image_pages.TAB_IDS:
|
||||
tab = "overview"
|
||||
return redirect(image_pages.tab_url(image_type, tab))
|
||||
|
||||
cfg = images.load_image_config(image_type)
|
||||
return render_template(
|
||||
"image_config.html",
|
||||
image_type=image_type,
|
||||
friendly_name=config.FRIENDLY_NAMES.get(image_type, image_type),
|
||||
config=cfg,
|
||||
)
|
||||
|
||||
# Legacy /config/save sections and the tab each one now lives on.
|
||||
_CONFIG_SECTIONS = {
|
||||
"hardware_models": "os",
|
||||
"drivers": "drivers",
|
||||
"operating_systems": "os",
|
||||
"packages": "os",
|
||||
}
|
||||
|
||||
|
||||
@app.route("/images/<image_type>/config/save", methods=["POST"])
|
||||
def image_config_save(image_type):
|
||||
"""Old one-section form save. No page posts here any more, and it must
|
||||
not write: it would skip the diff preview, the stale-file check and the
|
||||
Drivers tab's lint gate. It sends the user to the tab that saves now."""
|
||||
if image_type not in config.IMAGE_TYPES:
|
||||
flash("Unknown image type.", "danger")
|
||||
return redirect(url_for("dashboard"))
|
||||
tab = _CONFIG_SECTIONS.get(request.form.get("section", ""), "overview")
|
||||
flash("This form no longer saves. Make the change on this tab and use Review and save; "
|
||||
"nothing was written.", "warning")
|
||||
return redirect(image_pages.tab_url(image_type, tab))
|
||||
|
||||
section = request.form.get("section", "")
|
||||
payload = request.form.get("payload", "[]")
|
||||
try:
|
||||
data = json.loads(payload)
|
||||
except json.JSONDecodeError:
|
||||
flash("Invalid JSON payload.", "danger")
|
||||
return redirect(url_for("image_config", image_type=image_type))
|
||||
|
||||
ctrl = fs.control_path(image_type)
|
||||
tools = fs.tools_path(image_type)
|
||||
|
||||
try:
|
||||
if section == "hardware_models":
|
||||
us_file = os.path.join(tools, "user_selections.json")
|
||||
us_raw = fs.load_json(us_file)
|
||||
us_data = us_raw[0] if us_raw and isinstance(us_raw, list) else {}
|
||||
us_data["HardwareModelSelection"] = data
|
||||
fs.save_json(us_file, [us_data])
|
||||
audit("CONFIG_SAVE", f"{image_type}/hardware_models")
|
||||
|
||||
elif section == "drivers":
|
||||
clean = [{k: v for k, v in d.items() if not k.startswith("_")} for d in data]
|
||||
fs.save_json(os.path.join(ctrl, "HardwareDriver.json"), clean)
|
||||
audit("CONFIG_SAVE", f"{image_type}/drivers")
|
||||
|
||||
elif section == "operating_systems":
|
||||
clean = [{k: v for k, v in d.items() if not k.startswith("_")} for d in data]
|
||||
fs.save_json(os.path.join(ctrl, "OperatingSystem.json"), clean)
|
||||
audit("CONFIG_SAVE", f"{image_type}/operating_systems")
|
||||
|
||||
elif section == "packages":
|
||||
clean = [{k: v for k, v in d.items() if not k.startswith("_")} for d in data]
|
||||
fs.save_json(os.path.join(ctrl, "packages.json"), clean)
|
||||
audit("CONFIG_SAVE", f"{image_type}/packages")
|
||||
|
||||
else:
|
||||
flash(f"Unknown section: {section}", "danger")
|
||||
return redirect(url_for("image_config", image_type=image_type))
|
||||
|
||||
flash(f"Saved {section.replace('_', ' ')} successfully.", "success")
|
||||
except Exception as exc:
|
||||
flash(f"Failed to save {section}: {exc}", "danger")
|
||||
|
||||
return redirect(url_for("image_config", image_type=image_type))
|
||||
def _register_package(image_type, fname, dest):
|
||||
"""Append {fileName, destinationDir} to packages.json through the same
|
||||
path as the OS and Packages tab (format kept, .bak-<ts>-webui backup,
|
||||
atomic, the tab's undo point). False when it is already listed."""
|
||||
path = image_pages.control_file(image_type, "packages.json")
|
||||
text = control_files.read_text(path)
|
||||
entries = []
|
||||
if text.strip():
|
||||
try:
|
||||
entries = json.loads(text.lstrip("\ufeff"))
|
||||
except ValueError:
|
||||
entries = None
|
||||
if not isinstance(entries, list):
|
||||
raise ValueError("packages.json is not a JSON list, so the file was not registered")
|
||||
if any(str(e.get("fileName") or e.get("FileName") or "").lower() == fname.lower()
|
||||
for e in entries if isinstance(e, dict)):
|
||||
return False
|
||||
entries.append({"fileName": fname, "destinationDir": dest})
|
||||
plan = control_files.plan_json(path, entries, "packages.json")
|
||||
control_files.commit(image_type, images_views.UNDO_SCOPE, [plan],
|
||||
action=f"Package upload: {fname} registered")
|
||||
return True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -304,71 +306,65 @@ def image_config_save(image_type):
|
||||
|
||||
@app.route("/images/<image_type>/drivers/upload", methods=["POST"])
|
||||
def images_drivers_upload(image_type):
|
||||
"""Store a driver .zip under Out-of-box Drivers (services/driver_map.
|
||||
save_upload: temp file + rename, in-use zips never overwritten). It is
|
||||
NOT added to HardwareDriver.json here: the Drivers tab's Add to map does
|
||||
that through the review step, with a backup and Undo. family and
|
||||
destination_dir from old forms are ignored for that reason."""
|
||||
if image_type not in config.IMAGE_TYPES:
|
||||
flash(f"Unknown image type: {image_type}", "danger")
|
||||
return redirect(url_for("dashboard"))
|
||||
back = redirect(image_pages.tab_url(image_type, "drivers"))
|
||||
f = request.files.get("driver_file")
|
||||
if not f or not f.filename:
|
||||
flash("No file selected.", "danger")
|
||||
return redirect(url_for("image_config", image_type=image_type))
|
||||
family = (request.form.get("family") or "").strip()
|
||||
dest = (request.form.get("destination_dir") or "").strip()
|
||||
return back
|
||||
overwrite = request.form.get("overwrite") == "1"
|
||||
try:
|
||||
rec = images.upload_driver(image_type, f, family=family,
|
||||
destination_dir=dest, overwrite=overwrite)
|
||||
audit("DRIVER_UPLOAD", f"{image_type}/{rec['filename']} registered={rec['registered']}")
|
||||
msg = f"Uploaded {rec['filename']}."
|
||||
msg += " Registered to family in HardwareDriver.json." if rec["registered"] else " Now in orphans list until adopted."
|
||||
flash(msg, "success")
|
||||
except (ValueError, FileExistsError, FileNotFoundError) as ex:
|
||||
rec = driver_map.save_upload(image_type, f, overwrite=overwrite)
|
||||
audit("DRIVER_UPLOAD", f"{image_type}/{rec['filename']} replaced={rec['replaced']}")
|
||||
flash(f"Uploaded {rec['filename']}. It is listed under Unregistered zips; "
|
||||
"use Add to map there to give it an entry.", "success")
|
||||
except (ValueError, FileExistsError) as ex:
|
||||
flash(str(ex), "danger")
|
||||
except Exception as ex:
|
||||
flash(f"Upload failed: {ex}", "danger")
|
||||
return redirect(url_for("image_config", image_type=image_type))
|
||||
return back
|
||||
|
||||
|
||||
@app.route("/images/<image_type>/drivers/adopt", methods=["POST"])
|
||||
def images_drivers_adopt(image_type):
|
||||
"""Old one-click adopt. It rewrote HardwareDriver.json with indent 2, no
|
||||
backup and an entry with no modelswminame (PESetup throws on that), so
|
||||
it now writes nothing and points at Add to map on the Drivers tab."""
|
||||
if image_type not in config.IMAGE_TYPES:
|
||||
flash(f"Unknown image type: {image_type}", "danger")
|
||||
return redirect(url_for("dashboard"))
|
||||
filename = (request.form.get("filename") or "").strip()
|
||||
family = (request.form.get("family") or "").strip()
|
||||
dest = (request.form.get("destination_dir") or "").strip()
|
||||
try:
|
||||
rec = images.adopt_orphan(image_type, filename, family, dest)
|
||||
audit("DRIVER_ADOPT", f"{image_type}/{rec['filename']} family={family}")
|
||||
if rec.get("already_registered"):
|
||||
flash(f"{rec['filename']} was already registered.", "info")
|
||||
else:
|
||||
flash(f"Adopted {rec['filename']} into HardwareDriver.json.", "success")
|
||||
except (ValueError, FileNotFoundError) as ex:
|
||||
flash(str(ex), "danger")
|
||||
except Exception as ex:
|
||||
flash(f"Adopt failed: {ex}", "danger")
|
||||
return redirect(url_for("image_config", image_type=image_type))
|
||||
flash("Use Add to map under Unregistered zips: the new entry needs model tokens and is "
|
||||
"saved through Review and save. Nothing was written.", "info")
|
||||
return redirect(image_pages.tab_url(image_type, "drivers"))
|
||||
|
||||
|
||||
@app.route("/images/<image_type>/drivers/orphans/delete", methods=["POST"])
|
||||
def images_drivers_orphans_delete(image_type):
|
||||
"""Old orphan delete (form field "filename"), now the same safe delete
|
||||
as the Drivers tab: zips any image, undo or rollback uses are kept."""
|
||||
if image_type not in config.IMAGE_TYPES:
|
||||
flash(f"Unknown image type: {image_type}", "danger")
|
||||
return redirect(url_for("dashboard"))
|
||||
# Filenames come as repeated form fields (one per checkbox).
|
||||
back = redirect(image_pages.tab_url(image_type, "drivers"))
|
||||
filenames = request.form.getlist("filename")
|
||||
if not filenames:
|
||||
flash("No files selected for removal.", "warning")
|
||||
return redirect(url_for("image_config", image_type=image_type))
|
||||
rec = images.remove_orphans(image_type, filenames)
|
||||
audit("DRIVER_ORPHAN_REMOVE", f"{image_type} removed={len(rec['removed'])} missing={len(rec['missing'])}")
|
||||
parts = []
|
||||
return back
|
||||
rec = driver_map.delete_orphans(image_type, filenames)
|
||||
audit("DRIVER_ORPHAN_REMOVE", f"{image_type} removed={len(rec['removed'])} "
|
||||
f"refused={len(rec['refused'])} {', '.join(rec['removed'])}")
|
||||
if rec["removed"]:
|
||||
parts.append(f"Removed {len(rec['removed'])} orphan(s).")
|
||||
if rec["missing"]:
|
||||
parts.append(f"{len(rec['missing'])} not found / errored.")
|
||||
flash(" ".join(parts) or "No-op.", "success" if rec["removed"] else "warning")
|
||||
return redirect(url_for("image_config", image_type=image_type))
|
||||
flash(f"Deleted {len(rec['removed'])} unregistered zip(s).", "success")
|
||||
for rel, why in rec["refused"]:
|
||||
flash(f"Not deleted: {rel} ({why}).", "warning")
|
||||
return back
|
||||
|
||||
|
||||
@app.route("/images/<image_type>/packages/upload", methods=["POST"])
|
||||
@@ -379,20 +375,31 @@ def images_packages_upload(image_type):
|
||||
f = request.files.get("package_file")
|
||||
if not f or not f.filename:
|
||||
flash("No file selected.", "danger")
|
||||
return redirect(url_for("image_config", image_type=image_type))
|
||||
return redirect(image_pages.tab_url(image_type, "os"))
|
||||
dest = (request.form.get("destination_dir") or "").strip()
|
||||
overwrite = request.form.get("overwrite") == "1"
|
||||
try:
|
||||
rec = images.upload_package(image_type, f, destination_dir=dest, overwrite=overwrite)
|
||||
audit("PACKAGE_UPLOAD", f"{image_type}/{rec['filename']} registered={rec['registered']}")
|
||||
# The file only; packages.json is edited here through control_files
|
||||
# (services.images.upload_package would rewrite it without a backup).
|
||||
rec = images.upload_package(image_type, f, destination_dir="", overwrite=overwrite)
|
||||
msg = f"Uploaded {rec['filename']}."
|
||||
msg += " Registered in packages.json." if rec["registered"] else " Not registered (no destination_dir provided)."
|
||||
registered = False
|
||||
if dest:
|
||||
try:
|
||||
registered = _register_package(image_type, rec["filename"], dest)
|
||||
msg += (" Registered in packages.json (backup made; Undo last save puts it back)."
|
||||
if registered else " Already listed in packages.json.")
|
||||
except (ValueError, OSError) as ex:
|
||||
flash(f"Not registered: {ex}", "warning")
|
||||
else:
|
||||
msg += " Not registered (no destination_dir provided)."
|
||||
audit("PACKAGE_UPLOAD", f"{image_type}/{rec['filename']} registered={registered}")
|
||||
flash(msg, "success")
|
||||
except (ValueError, FileExistsError) as ex:
|
||||
flash(str(ex), "danger")
|
||||
except Exception as ex:
|
||||
flash(f"Upload failed: {ex}", "danger")
|
||||
return redirect(url_for("image_config", image_type=image_type))
|
||||
return redirect(image_pages.tab_url(image_type, "os"))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -761,8 +768,13 @@ def hardware_seen():
|
||||
@app.route("/hardware")
|
||||
def hardware_page():
|
||||
"""Every model WinPE has seen, with driver-pack and BIOS coverage."""
|
||||
try:
|
||||
# Files with a newer Dell pack/BIOS in the last Updates > Dell scan.
|
||||
hints = dell_updates.update_hints()
|
||||
except Exception:
|
||||
hints = {"drivers": {}, "bios": {}}
|
||||
return render_template("hardware.html", rows=hardware_coverage.coverage(),
|
||||
images=hardware_coverage.IMAGES)
|
||||
images=hardware_coverage.IMAGES, hints=hints)
|
||||
|
||||
|
||||
@app.route("/hardware/dismiss", methods=["POST"])
|
||||
@@ -1048,6 +1060,9 @@ def enrollment():
|
||||
return render_template(
|
||||
"enrollment.html",
|
||||
packages=packages,
|
||||
mcl_packages=mcl_ppkg.packages(),
|
||||
mcl_archive=mcl_ppkg.archived(),
|
||||
mcl_links=mcl_ppkg.image_links(),
|
||||
active_ppkg=read_ppkg_conf(),
|
||||
image_types=config.IMAGE_TYPES,
|
||||
friendly_names=config.FRIENDLY_NAMES,
|
||||
@@ -1069,6 +1084,13 @@ def enrollment_upload():
|
||||
if not filename.lower().endswith(".ppkg"):
|
||||
flash("Only .ppkg files are accepted.", "danger")
|
||||
return redirect(url_for("enrollment"))
|
||||
# An MCL name would also match the ppkg.conf regex below and become the
|
||||
# shopfloor package; those go to BPRT through the MCL section instead.
|
||||
# Check the sanitised name too: secure_filename turns spaces into
|
||||
# underscores, which can make an MCL name out of one that was not.
|
||||
if mcl_ppkg.is_mcl_name(f.filename) or mcl_ppkg.is_mcl_name(filename):
|
||||
flash(f"{filename} is a standard/engineer MCL package; upload it in the MCL section.", "danger")
|
||||
return redirect(url_for("enrollment") + "#mcl")
|
||||
|
||||
os.makedirs(config.ENROLLMENT_PPKG_DIR, exist_ok=True)
|
||||
dest = os.path.join(config.ENROLLMENT_PPKG_DIR, filename)
|
||||
@@ -1093,6 +1115,56 @@ def enrollment_upload():
|
||||
return redirect(url_for("enrollment"))
|
||||
|
||||
|
||||
@app.route("/enrollment/mcl/upload", methods=["POST"])
|
||||
def mcl_ppkg_upload():
|
||||
"""Standard/engineer MCL package: replaces the one with the same
|
||||
tenant/environment/purpose, archiving the old (services.mcl_ppkg)."""
|
||||
f = request.files.get("ppkg_file")
|
||||
if not f or not f.filename:
|
||||
flash("No file selected.", "danger")
|
||||
return redirect(url_for("enrollment") + "#mcl")
|
||||
try:
|
||||
info, moved = mcl_ppkg.install_upload(f, f.filename)
|
||||
except (ValueError, OSError) as exc:
|
||||
flash(f"Upload rejected: {exc}", "danger")
|
||||
return redirect(url_for("enrollment") + "#mcl")
|
||||
name = info["filename"]
|
||||
audit("MCL_PPKG_UPLOAD", f"{name} replaces {', '.join(moved) or 'nothing'}")
|
||||
flash(f"Installed {name} for {info['prefix']}"
|
||||
+ (f"; archived {', '.join(moved)}." if moved else "."), "success")
|
||||
for warning in info["warnings"]:
|
||||
flash(warning + ".", "warning")
|
||||
return redirect(url_for("enrollment") + "#mcl")
|
||||
|
||||
|
||||
@app.route("/enrollment/mcl/archive", methods=["POST"])
|
||||
def mcl_ppkg_archive():
|
||||
"""Move one package out of BPRT (clears a duplicate made by hand)."""
|
||||
try:
|
||||
name = mcl_ppkg.archive(request.form.get("filename", ""))
|
||||
except (ValueError, OSError) as exc:
|
||||
flash(f"Archive failed: {exc}", "danger")
|
||||
return redirect(url_for("enrollment") + "#mcl")
|
||||
audit("MCL_PPKG_ARCHIVE", name)
|
||||
flash(f"Archived {name}.", "success")
|
||||
return redirect(url_for("enrollment") + "#mcl")
|
||||
|
||||
|
||||
@app.route("/enrollment/mcl/restore", methods=["POST"])
|
||||
def mcl_ppkg_restore():
|
||||
batch = request.form.get("batch", "")
|
||||
name = request.form.get("filename", "")
|
||||
try:
|
||||
info, moved = mcl_ppkg.restore(batch, name)
|
||||
except (ValueError, OSError) as exc:
|
||||
flash(f"Restore failed: {exc}", "danger")
|
||||
return redirect(url_for("enrollment") + "#mcl")
|
||||
name = info["filename"]
|
||||
audit("MCL_PPKG_RESTORE", f"{batch}/{name} replaces {', '.join(moved) or 'nothing'}")
|
||||
flash(f"Restored {name}" + (f"; archived {', '.join(moved)}." if moved else "."), "success")
|
||||
return redirect(url_for("enrollment") + "#mcl")
|
||||
|
||||
|
||||
@app.route("/enrollment/download/<filename>")
|
||||
def enrollment_download(filename):
|
||||
filename = secure_filename(filename)
|
||||
@@ -1492,31 +1564,24 @@ def api_save_unattend(image_type):
|
||||
xml_file = fs.unattend_path(image_type)
|
||||
payload = request.get_json(silent=True)
|
||||
|
||||
if not payload:
|
||||
if not payload or not isinstance(payload, dict):
|
||||
return jsonify({"error": "No JSON body provided"}), 400
|
||||
|
||||
# {"raw_xml": ...} writes the text as is; any other body is form data
|
||||
# (keys left out stay as they are in the file). Backup, undo point and
|
||||
# lint gate as on the Unattend tab.
|
||||
if "raw_xml" in payload:
|
||||
raw_xml = payload["raw_xml"]
|
||||
try:
|
||||
etree.fromstring(raw_xml.encode("utf-8"))
|
||||
except etree.XMLSyntaxError as exc:
|
||||
return jsonify({"error": f"Invalid XML: {exc}"}), 400
|
||||
xml_content = raw_xml
|
||||
body = {"mode": "raw", "raw_xml": payload["raw_xml"]}
|
||||
else:
|
||||
try:
|
||||
xml_content = unattend.build_unattend_xml(payload)
|
||||
except Exception as exc:
|
||||
return jsonify({"error": f"Failed to build XML: {exc}"}), 400
|
||||
|
||||
body = {"mode": "form", "form": payload}
|
||||
try:
|
||||
os.makedirs(os.path.dirname(xml_file), exist_ok=True)
|
||||
with open(xml_file, "w", encoding="utf-8") as fh:
|
||||
fh.write(xml_content)
|
||||
except Exception as exc:
|
||||
return jsonify({"error": f"Failed to write file: {exc}"}), 500
|
||||
plan = images_unattend_views.save(image_type, body, action="Unattend save (API)")
|
||||
except images_unattend_views.Refused as exc:
|
||||
return jsonify({"error": str(exc)}), exc.status
|
||||
|
||||
audit("UNATTEND_SAVE_API", image_type)
|
||||
return jsonify({"status": "ok", "path": xml_file})
|
||||
if plan["changed"]:
|
||||
audit("UNATTEND_SAVE_API", image_type)
|
||||
return jsonify({"status": "ok", "path": xml_file, "changed": plan["changed"]})
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -1559,11 +1624,24 @@ def timestamp_fmt(ts):
|
||||
return local_dt(ts)
|
||||
|
||||
|
||||
@app.template_filter("tojson_ordered")
|
||||
def tojson_ordered(value):
|
||||
"""JSON in the data's own key order (Flask's tojson sorts keys, which
|
||||
turns a save of an unchanged row into a reordered-keys diff). Output is
|
||||
a plain string, so it is HTML-escaped like any other value."""
|
||||
return json.dumps(value, ensure_ascii=False)
|
||||
|
||||
|
||||
@app.context_processor
|
||||
def inject_globals():
|
||||
return {
|
||||
"all_image_types": config.IMAGE_TYPES,
|
||||
"all_friendly_names": config.FRIENDLY_NAMES,
|
||||
# image pages: tab table, header data, tab URLs (see _image_header.html)
|
||||
"image_tabs": image_pages.IMAGE_TABS,
|
||||
"image_header_info": image_pages.header_info,
|
||||
"image_tab_url": image_pages.tab_url,
|
||||
"image_tab_for_endpoint": image_pages.tab_for_endpoint,
|
||||
"blancco_todo": dict(blancco_evidence.todo_counts(),
|
||||
hardware=hardware_coverage.gap_count(),
|
||||
total=blancco_evidence.todo_counts()["total"] + hardware_coverage.gap_count()),
|
||||
|
||||
Reference in New Issue
Block a user