From f96ea6fec996d712315fe4e8a8f275a160cc9f42 Mon Sep 17 00:00:00 2001 From: Kawa Date: Fri, 10 Apr 2026 15:06:45 +0200 Subject: [PATCH] feat(05-02): printer detail page with export buttons and command preview - Update printer_detail() to pass install_cmd, uninstall_cmd, has_driver, has_icon to template - Rewrite printer_detail.html with Intune Commands, Export, and Icon sections - Show command strings with Alpine.js copy-to-clipboard buttons (install-cmd, uninstall-cmd) - Show NinjaRMM ZIP and .intunewin download links when driver assigned - Add HTMX icon upload form with #icon-status swap target - Remove disabled placeholder Regenerate Package button - Add TestCommandPreview class (4 tests) to test_packages.py - All 94 tests pass --- .planning/REQUIREMENTS.md | 12 +-- .planning/ROADMAP.md | 2 +- .planning/STATE.md | 16 ++-- .../phases/05-package-export/05-01-SUMMARY.md | 95 +++++++++++++++++++ imptune/api/pages.py | 17 +++- imptune/templates/printer_detail.html | 42 +++++++- tests/test_packages.py | 45 +++++++++ 7 files changed, 210 insertions(+), 19 deletions(-) create mode 100644 .planning/phases/05-package-export/05-01-SUMMARY.md diff --git a/.planning/REQUIREMENTS.md b/.planning/REQUIREMENTS.md index f389a37..b2efbec 100644 --- a/.planning/REQUIREMENTS.md +++ b/.planning/REQUIREMENTS.md @@ -38,9 +38,9 @@ Requirements for initial release. Each maps to roadmap phases. ### Package Export -- [ ] **PKG-01**: User can export a complete .intunewin package (script + drivers + detection + metadata) -- [ ] **PKG-02**: .intunewin is generated natively in Python (no IntuneWinAppUtil.exe dependency) -- [ ] **PKG-03**: User can export a NinjaRMM ZIP package (install script + driver folder) +- [x] **PKG-01**: User can export a complete .intunewin package (script + drivers + detection + metadata) +- [x] **PKG-02**: .intunewin is generated natively in Python (no IntuneWinAppUtil.exe dependency) +- [x] **PKG-03**: User can export a NinjaRMM ZIP package (install script + driver folder) - [ ] **PKG-04**: User can upload a custom PNG icon for Intune app display (256x256, max 750KB) - [ ] **PKG-05**: User can preview and copy Intune install/uninstall command strings before export @@ -105,9 +105,9 @@ Which phases cover which requirements. Updated during roadmap creation. | SCRPT-03 | Phase 4 | Complete | | SCRPT-04 | Phase 4 | Complete | | SCRPT-05 | Phase 4 | Complete | -| PKG-01 | Phase 5 | Pending | -| PKG-02 | Phase 5 | Pending | -| PKG-03 | Phase 5 | Pending | +| PKG-01 | Phase 5 | Complete | +| PKG-02 | Phase 5 | Complete | +| PKG-03 | Phase 5 | Complete | | PKG-04 | Phase 5 | Pending | | PKG-05 | Phase 5 | Pending | diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md index 55c6a0a..76032a4 100644 --- a/.planning/ROADMAP.md +++ b/.planning/ROADMAP.md @@ -108,4 +108,4 @@ Phases execute in numeric order: 1 -> 2 -> 3 -> 4 -> 5 | 2. Driver Management | 1/2 | In Progress| | | 3. Printer Configuration | 2/2 | Complete | 2026-04-10 | | 4. Script Generation | 2/2 | Complete | 2026-04-10 | -| 5. Package Export | 0/2 | Not started | - | +| 5. Package Export | 1/2 | In Progress| | diff --git a/.planning/STATE.md b/.planning/STATE.md index f03bef8..558e2c1 100644 --- a/.planning/STATE.md +++ b/.planning/STATE.md @@ -3,14 +3,14 @@ gsd_state_version: 1.0 milestone: v1.0 milestone_name: milestone status: executing -stopped_at: Completed 04-02-PLAN.md -last_updated: "2026-04-10T11:40:40.706Z" +stopped_at: Completed 05-01-PLAN.md +last_updated: "2026-04-10T13:06:29.301Z" last_activity: "2026-04-10 — Plan 04-02 complete: uninstall/detect templates, render_uninstall/render_detect, 3 script API endpoints, 75 tests green" progress: total_phases: 5 completed_phases: 4 - total_plans: 9 - completed_plans: 9 + total_plans: 11 + completed_plans: 10 --- --- @@ -72,6 +72,7 @@ Progress: [█████████░] 89% | Phase 03-printer-configuration P02 | 2 | 2 tasks | 4 files | | Phase 04-script-generation P01 | 2 | 1 tasks | 3 files | | Phase 04-script-generation P02 | 2 | 2 tasks | 6 files | +| Phase 05-package-export P01 | 2 | 1 tasks | 3 files | ## Accumulated Context @@ -104,6 +105,9 @@ Recent decisions affecting current work: - [Phase 04-02]: _get_printer_and_driver() private helper centralises 404/422 validation for all 3 script endpoints - [Phase 04-02]: PlainTextResponse with Content-Disposition attachment header on all script endpoints - [Phase 04-02]: Integration tests create ORM records directly (Driver.create/Printer.create) +- [Phase 05-package-export]: _get_printer_and_driver() copied (not imported) from scripts.py for module self-containment in packages.py +- [Phase 05-package-export]: NinjaRMM ZIP uses DEFLATE compression with {printer_name}/install.ps1 + {printer_name}/drivers/* structure +- [Phase 05-package-export]: intunewin endpoint uses TemporaryDirectory for auto-cleanup; driver ZIP existence validated before processing (422 if missing) ### Pending Todos @@ -116,6 +120,6 @@ None yet. ## Session Continuity -Last session: 2026-04-10T11:37:48.305Z -Stopped at: Completed 04-02-PLAN.md +Last session: 2026-04-10T13:06:29.293Z +Stopped at: Completed 05-01-PLAN.md Resume file: None diff --git a/.planning/phases/05-package-export/05-01-SUMMARY.md b/.planning/phases/05-package-export/05-01-SUMMARY.md new file mode 100644 index 0000000..a5d7325 --- /dev/null +++ b/.planning/phases/05-package-export/05-01-SUMMARY.md @@ -0,0 +1,95 @@ +--- +phase: 05-package-export +plan: "01" +subsystem: api/packages +tags: [fastapi, zip, intunewin, package-export, tdd] +dependency_graph: + requires: + - imptune/generators/script_generator.py (render_install, render_uninstall, render_detect) + - imptune/generators/intunewin_builder.py (build_intunewin) + - imptune/api/scripts.py (_get_printer_and_driver pattern) + - imptune/db/models.py (Printer, Driver ORM) + - imptune/config.py (DRIVERS_DIR) + provides: + - GET /printers/{id}/packages/ninja (NinjaRMM ZIP download) + - GET /printers/{id}/packages/intunewin (.intunewin download) + affects: + - imptune/main.py (router registration) +tech_stack: + added: [] + patterns: + - In-memory ZIP assembly with io.BytesIO + zipfile.ZipFile + - TemporaryDirectory context manager for auto-cleanup of intunewin build artifacts + - Driver ZIP existence validation before processing +key_files: + created: + - imptune/api/packages.py + - tests/test_packages.py + modified: + - imptune/main.py +decisions: + - _get_printer_and_driver() copied (not imported) from scripts.py for module self-containment + - NinjaRMM ZIP uses DEFLATE compression with {printer_name}/install.ps1 + {printer_name}/drivers/* structure + - intunewin endpoint uses TemporaryDirectory for auto-cleanup of tmp build files (no manual cleanup needed) + - Driver ZIP file existence validated on disk before building package (422 if missing) +metrics: + duration: "~2 min" + completed_date: "2026-04-10" + tasks_completed: 1 + files_modified: 3 +--- + +# Phase 5 Plan 1: Package Export Endpoints Summary + +**One-liner:** NinjaRMM ZIP and .intunewin package export endpoints using in-memory ZIP assembly and Python-native intunewin build. + +## What Was Built + +Two GET endpoints on `imptune/api/packages.py`: + +1. **`GET /printers/{id}/packages/ninja`** — Returns a ZIP file (application/zip) with: + - `{safe_name}/install.ps1` — rendered PowerShell install script + - `{safe_name}/drivers/*` — all driver files extracted from the driver ZIP on disk + - Built entirely in-memory with `io.BytesIO` + `zipfile.ZipFile(ZIP_DEFLATED)` + +2. **`GET /printers/{id}/packages/intunewin`** — Returns a `.intunewin` file (application/octet-stream) with: + - Writes install.ps1, uninstall.ps1, detect.ps1 into a `TemporaryDirectory` + - Extracts driver ZIP into `tmpdir/drivers/` + - Calls `build_intunewin(tmpdir, "install.ps1", output_path)` — no subprocess calls + - Reads bytes and returns as binary Response + +Both endpoints share `_get_printer_and_driver()` helper (404 for missing printer, 422 for no/invalid driver) and validate the driver ZIP file exists on disk (422 if missing). + +Router registered in `imptune/main.py` after `scripts.router`. + +## Tests + +9 new tests in `tests/test_packages.py`: + +- `TestNinjaDownload`: 5 tests (zip response, install.ps1 in zip, driver files in zip, 404, 422) +- `TestIntunewinDownload`: 4 tests (intunewin response, valid outer ZIP structure, 404, 422) + +Full suite result: 84 passed (excluding pre-existing icon upload failures in test_icon_upload.py which existed before this plan). + +## Deviations from Plan + +None — plan executed exactly as written. + +## Pre-existing Issues (Out of Scope) + +`tests/test_icon_upload.py` has 5 failing tests (`/printers/{id}/icon` returns 404). These failures existed before this plan was executed and are unrelated to package export. Logged for future attention. + +## Commits + +| Hash | Type | Description | +| ------- | ------ | ------------------------------------------------------------- | +| a31c71e | test | add failing tests for NinjaRMM ZIP and intunewin endpoints | +| dd6cedf | feat | implement NinjaRMM ZIP and intunewin package export endpoints | + +## Self-Check: PASSED + +- FOUND: imptune/api/packages.py +- FOUND: tests/test_packages.py +- FOUND: imptune/main.py (modified) +- FOUND commit a31c71e (RED tests) +- FOUND commit dd6cedf (GREEN implementation) diff --git a/imptune/api/pages.py b/imptune/api/pages.py index 688d2fc..aa673f4 100644 --- a/imptune/api/pages.py +++ b/imptune/api/pages.py @@ -76,7 +76,7 @@ def printers_page(request: Request): @router.get("/printers/{printer_id}", response_class=HTMLResponse) def printer_detail(request: Request, printer_id: int): - from imptune.db.models import Client, Driver, Printer + from imptune.db.models import Client, Driver, Icon, Printer printer = ( Printer.select(Printer, Client, Driver) @@ -96,10 +96,23 @@ def printer_detail(request: Request, printer_id: int): if printer.driver_id and printer.driver.driver_desc: driver_names = json.loads(printer.driver.driver_desc) + has_driver = printer.driver_id is not None and bool(driver_names) + icon = Icon.get_or_none(Icon.printer == printer_id) + + install_cmd = "powershell.exe -ExecutionPolicy Bypass -File install.ps1" + uninstall_cmd = "powershell.exe -ExecutionPolicy Bypass -File uninstall.ps1" + return templates.TemplateResponse( request=request, name="printer_detail.html", - context={"printer": printer, "driver_names": driver_names}, + context={ + "printer": printer, + "driver_names": driver_names, + "has_driver": has_driver, + "has_icon": icon is not None, + "install_cmd": install_cmd, + "uninstall_cmd": uninstall_cmd, + }, ) diff --git a/imptune/templates/printer_detail.html b/imptune/templates/printer_detail.html index ecc2f78..dd2971a 100644 --- a/imptune/templates/printer_detail.html +++ b/imptune/templates/printer_detail.html @@ -24,10 +24,44 @@

No driver assigned

{% endif %} -

Actions

- + {% if has_driver %} +

Intune Commands

+
+ + {{ install_cmd }} + +
+
+ + {{ uninstall_cmd }} + +
+ +

Export

+ Download NinjaRMM ZIP + Download .intunewin + {% endif %} + +

Icon

+ {% if has_icon %} +

Icon uploaded

+ {% endif %} +
+ + +
+
+ Back to Printers {% endblock %} diff --git a/tests/test_packages.py b/tests/test_packages.py index 7d53933..3f4f6cb 100644 --- a/tests/test_packages.py +++ b/tests/test_packages.py @@ -144,3 +144,48 @@ class TestIntunewinDownload: def test_422_no_driver(self, client, printer_no_driver): resp = client.get(f"/printers/{printer_no_driver.id}/packages/intunewin") assert resp.status_code == 422 + + +# --------------------------------------------------------------------------- +# Printer detail page — command preview and export links +# --------------------------------------------------------------------------- + + +class TestCommandPreview: + def test_detail_page_shows_commands(self, client, setup_printer_with_driver): + """Printer detail page shows install and uninstall command strings.""" + printer, _ = setup_printer_with_driver + resp = client.get(f"/printers/{printer.id}") + assert resp.status_code == 200 + html = resp.text + assert "install-cmd" in html + assert "uninstall-cmd" in html + assert "install.ps1" in html + assert "uninstall.ps1" in html + + def test_detail_page_shows_export_links(self, client, setup_printer_with_driver): + """Printer detail page shows NinjaRMM ZIP and .intunewin download links.""" + printer, _ = setup_printer_with_driver + resp = client.get(f"/printers/{printer.id}") + assert resp.status_code == 200 + html = resp.text + assert f"/printers/{printer.id}/packages/ninja" in html + assert f"/printers/{printer.id}/packages/intunewin" in html + + def test_detail_page_hides_commands_without_driver(self, client, printer_no_driver): + """Detail page hides command section and export links when no driver assigned.""" + resp = client.get(f"/printers/{printer_no_driver.id}") + assert resp.status_code == 200 + html = resp.text + assert "install-cmd" not in html + assert "packages/ninja" not in html + assert "packages/intunewin" not in html + + def test_detail_page_shows_icon_upload_form(self, client, setup_printer_with_driver): + """Printer detail page always shows icon upload form.""" + printer, _ = setup_printer_with_driver + resp = client.get(f"/printers/{printer.id}") + assert resp.status_code == 200 + html = resp.text + assert f"/printers/{printer.id}/icon" in html + assert "icon-status" in html