docs(05): create phase 5 package export plans

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-10 13:53:31 +02:00
co-authored by Claude Opus 4.6
parent cf7adecb06
commit f2d12e53d9
3 changed files with 490 additions and 6 deletions
+5 -6
View File
@@ -91,17 +91,16 @@ Plans:
2. User can download a NinjaRMM ZIP containing the install script and driver folder
3. User can upload a custom PNG icon (256x256, max 750KB) and it is embedded in the .intunewin package
4. User can preview and copy the Intune install command string and uninstall command string before exporting
**Plans**: TBD
**Plans**: 2 plans
Plans:
- [ ] 05-01: NinjaRMM ZIP builder (script + driver folder, StreamingResponse download)
- [ ] 05-02: .intunewin builder (reuse Phase 1 spike, wire to printer config + drivers + detection script)
- [ ] 05-03: Icon upload and Intune command preview UI
- [ ] 05-01-PLAN.md — NinjaRMM ZIP + .intunewin export endpoints (packages.py router, integration tests)
- [ ] 05-02-PLAN.md — Icon upload with Pillow validation, command preview UI, export buttons on printer detail page
## Progress
**Execution Order:**
Phases execute in numeric order: 1 2 3 4 5
Phases execute in numeric order: 1 -> 2 -> 3 -> 4 -> 5
| Phase | Plans Complete | Status | Completed |
|-------|----------------|--------|-----------|
@@ -109,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/3 | Not started | - |
| 5. Package Export | 0/2 | Not started | - |
@@ -0,0 +1,193 @@
---
phase: 05-package-export
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
- imptune/api/packages.py
- imptune/main.py
- tests/test_packages.py
autonomous: true
requirements: [PKG-01, PKG-02, PKG-03]
must_haves:
truths:
- "GET /printers/{id}/packages/ninja returns a ZIP containing install.ps1 and drivers/ subfolder"
- "GET /printers/{id}/packages/intunewin returns a valid .intunewin file with correct Content-Disposition"
- "Both endpoints return 404 for missing printer, 422 for missing/invalid driver"
- "NinjaRMM ZIP uses DEFLATE compression and has printer-name-based folder structure"
- ".intunewin is built using Python-native build_intunewin() with no subprocess calls"
artifacts:
- path: "imptune/api/packages.py"
provides: "Package download endpoints for NinjaRMM ZIP and .intunewin"
exports: ["router"]
- path: "tests/test_packages.py"
provides: "Integration tests for both export endpoints"
contains: "TestNinjaDownload"
key_links:
- from: "imptune/api/packages.py"
to: "imptune/generators/script_generator.py"
via: "render_install, render_uninstall, render_detect"
pattern: "from imptune\\.generators\\.script_generator import"
- from: "imptune/api/packages.py"
to: "imptune/generators/intunewin_builder.py"
via: "build_intunewin(source_dir, setup_file, output_path)"
pattern: "from imptune\\.generators\\.intunewin_builder import build_intunewin"
- from: "imptune/main.py"
to: "imptune/api/packages.py"
via: "app.include_router(packages.router)"
pattern: "include_router.*packages"
---
<objective>
Create the two package export API endpoints: NinjaRMM ZIP download and .intunewin download. Both serve binary file responses for a given printer configuration.
Purpose: PKG-01/PKG-02/PKG-03 -- Users can download deployment-ready packages in either format with one click.
Output: `imptune/api/packages.py` with two GET endpoints, registered in main.py, with integration tests.
</objective>
<execution_context>
@C:/Users/SebastienQUEROL/.claude/get-shit-done/workflows/execute-plan.md
@C:/Users/SebastienQUEROL/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/05-package-export/05-RESEARCH.md
@imptune/api/scripts.py
@imptune/generators/intunewin_builder.py
@imptune/generators/script_generator.py
@imptune/config.py
@imptune/db/models.py
@imptune/main.py
@tests/conftest.py
<interfaces>
<!-- Key types and contracts the executor needs. -->
From imptune/api/scripts.py:
```python
def _get_printer_and_driver(printer_id: int):
"""Returns (printer, driver, driver_name), None on success
or None, PlainTextResponse on error (404/422)."""
```
From imptune/generators/script_generator.py:
```python
def render_install(printer_name, ip_address, port_name, driver_name,
inf_filename, duplex_mode, color_mode, paper_size, collate) -> str: ...
def render_uninstall(printer_name, driver_name, port_name) -> str: ...
def render_detect(printer_name) -> str: ...
```
From imptune/generators/intunewin_builder.py:
```python
def build_intunewin(source_dir: str, setup_file: str, output_path: str) -> None:
"""Build a .intunewin file from source_dir, with setup_file as entry point."""
```
From imptune/config.py:
```python
DATA_DIR = os.environ.get("DATA_DIR", "/data")
DRIVERS_DIR = str(Path(DATA_DIR) / "drivers")
```
From imptune/db/models.py:
```python
class Driver(BaseModel):
sha256 = CharField(unique=True, index=True)
original_filename = CharField()
driver_desc = CharField(null=True) # JSON list of driver names
inf_filename = CharField(null=True)
...
class Printer(BaseModel):
name = CharField()
ip_address = CharField()
port_name = CharField()
driver = ForeignKeyField(Driver, null=True, backref="printers")
duplex_mode = CharField(default="OneSided")
color_mode = BooleanField(default=True)
paper_size = CharField(default="A4")
collate = BooleanField(default=True)
...
```
</interfaces>
</context>
<tasks>
<task type="auto" tdd="true">
<name>Task 1: Package export endpoints with TDD</name>
<files>imptune/api/packages.py, imptune/main.py, tests/test_packages.py</files>
<behavior>
- TestNinjaDownload::test_returns_zip: GET /printers/{id}/packages/ninja returns 200, media_type application/zip, Content-Disposition with filename
- TestNinjaDownload::test_zip_contains_install_script: Response ZIP contains {safe_name}/install.ps1
- TestNinjaDownload::test_zip_contains_driver_files: Response ZIP contains {safe_name}/drivers/ with files from driver ZIP
- TestNinjaDownload::test_404_missing_printer: Returns 404 for nonexistent printer_id
- TestNinjaDownload::test_422_no_driver: Returns 422 for printer with no assigned driver
- TestIntunewinDownload::test_returns_intunewin: GET /printers/{id}/packages/intunewin returns 200, media_type application/octet-stream, Content-Disposition with .intunewin extension
- TestIntunewinDownload::test_intunewin_is_valid_zip: Response content is a valid outer ZIP with IntuneWinPackage/ structure
- TestIntunewinDownload::test_404_missing_printer: Returns 404 for nonexistent printer_id
- TestIntunewinDownload::test_422_no_driver: Returns 422 for printer with no assigned driver
</behavior>
<action>
1. Create `tests/test_packages.py` with RED tests first. Test fixtures: create a Driver record with a real small ZIP file on disk (use tmp_data_dir from conftest), create a Printer record linked to it. Use the `client` fixture from conftest.py.
2. Create `imptune/api/packages.py` with `router = APIRouter(prefix="/printers")`:
**NinjaRMM endpoint** `GET /{printer_id}/packages/ninja`:
- Reuse `_get_printer_and_driver()` pattern from scripts.py (copy the helper into packages.py or import — prefer copy since it's small and keeps the module self-contained)
- Call `render_install(...)` with all printer/driver params
- Build ZIP in-memory with `io.BytesIO` + `zipfile.ZipFile`:
- `{safe_name}/install.ps1` with rendered script
- `{safe_name}/drivers/{member}` for each file in the driver ZIP on disk
- `safe_name = printer.name.replace(" ", "_")`
- Return `Response(content=buf.getvalue(), media_type="application/zip", headers={"Content-Disposition": f'attachment; filename="{safe_name}_ninja.zip"'})`
**Intunewin endpoint** `GET /{printer_id}/packages/intunewin`:
- Same printer/driver validation via `_get_printer_and_driver()`
- Use `tempfile.TemporaryDirectory(prefix="imptune_")` as context manager (auto-cleanup, per RESEARCH pitfall 1)
- Write `install.ps1`, `uninstall.ps1`, `detect.ps1` into tmpdir
- Extract driver ZIP contents into `tmpdir/drivers/`
- Call `build_intunewin(tmpdir, "install.ps1", os.path.join(tmpdir, "out.intunewin"))`
- Read output file bytes and return as `Response(content=..., media_type="application/octet-stream", headers={"Content-Disposition": ...})`
- Check driver file exists on disk before proceeding (per RESEARCH pitfall 3), return 422 if missing
3. Register router in `imptune/main.py`:
- Add `from imptune.api import packages` to imports
- Add `app.include_router(packages.router)` after scripts router
4. Run tests GREEN.
</action>
<verify>
<automated>pytest tests/test_packages.py -x</automated>
</verify>
<done>
- NinjaRMM ZIP endpoint returns valid ZIP with install.ps1 and driver files inside a named subfolder
- .intunewin endpoint returns valid .intunewin (outer ZIP with IntuneWinPackage/ structure)
- Both endpoints handle 404/422 for missing printer or driver
- Router registered in main.py
- All tests pass, full suite still green (pytest tests/ -x)
</done>
</task>
</tasks>
<verification>
pytest tests/test_packages.py -x && pytest tests/ -x
</verification>
<success_criteria>
- GET /printers/{id}/packages/ninja returns downloadable ZIP with install.ps1 + driver files
- GET /printers/{id}/packages/intunewin returns downloadable .intunewin package
- Both endpoints return proper error codes for invalid requests
- Full test suite green
</success_criteria>
<output>
After completion, create `.planning/phases/05-package-export/05-01-SUMMARY.md`
</output>
@@ -0,0 +1,292 @@
---
phase: 05-package-export
plan: 02
type: execute
wave: 1
depends_on: []
files_modified:
- imptune/api/icons.py
- imptune/config.py
- imptune/main.py
- imptune/api/pages.py
- imptune/templates/printer_detail.html
- requirements.txt
- tests/test_icon_upload.py
autonomous: true
requirements: [PKG-04, PKG-05]
must_haves:
truths:
- "User can upload a PNG icon for a printer and it is stored on disk"
- "Icon upload rejects non-PNG files, files over 750KB, and wrong dimensions (not 256x256)"
- "Re-uploading an icon for the same printer replaces the previous one"
- "Printer detail page shows Intune install and uninstall command strings"
- "User can copy the command strings (text displayed prominently for copy)"
- "Printer detail page has download links for NinjaRMM ZIP and .intunewin"
artifacts:
- path: "imptune/api/icons.py"
provides: "Icon upload endpoint"
exports: ["router"]
- path: "imptune/templates/printer_detail.html"
provides: "Export buttons, command preview, icon upload form"
contains: "install-cmd"
- path: "tests/test_icon_upload.py"
provides: "Integration tests for icon upload validation"
contains: "test_upload_valid_png"
key_links:
- from: "imptune/api/icons.py"
to: "imptune/db/models.py"
via: "Icon model CRUD"
pattern: "from imptune\\.db\\.models import.*Icon"
- from: "imptune/api/icons.py"
to: "imptune/config.py"
via: "cfg.DATA_DIR for icon storage path"
pattern: "import imptune\\.config as cfg"
- from: "imptune/main.py"
to: "imptune/api/icons.py"
via: "app.include_router(icons.router)"
pattern: "include_router.*icons"
- from: "imptune/templates/printer_detail.html"
to: "/printers/{id}/packages/*"
via: "href download links"
pattern: "packages/ninja|packages/intunewin"
---
<objective>
Add icon upload for Intune packages and update the printer detail page with export download buttons and Intune command preview strings.
Purpose: PKG-04 (custom icon upload) and PKG-05 (command preview) -- completing the export UI that makes deployment packages accessible to technicians.
Output: `imptune/api/icons.py` with upload endpoint, updated `printer_detail.html` with export section, command preview, and icon upload form.
</objective>
<execution_context>
@C:/Users/SebastienQUEROL/.claude/get-shit-done/workflows/execute-plan.md
@C:/Users/SebastienQUEROL/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/05-package-export/05-RESEARCH.md
@imptune/api/drivers.py
@imptune/api/pages.py
@imptune/config.py
@imptune/db/models.py
@imptune/main.py
@imptune/templates/printer_detail.html
@imptune/templates/base.html
@tests/conftest.py
@requirements.txt
<interfaces>
<!-- Key types and contracts the executor needs. -->
From imptune/db/models.py:
```python
class Icon(BaseModel):
printer = ForeignKeyField(Printer, unique=True, backref="icons")
sha256 = CharField()
original_filename = CharField()
size_bytes = IntegerField()
uploaded_at = DateTimeField(default=datetime.utcnow)
class Meta:
table_name = "icon"
```
From imptune/api/drivers.py (UploadFile pattern):
```python
from fastapi import UploadFile
# file: UploadFile parameter, file.file.read() for bytes, file.filename for name
```
From imptune/config.py:
```python
DATA_DIR = os.environ.get("DATA_DIR", "/data")
DRIVERS_DIR = str(Path(DATA_DIR) / "drivers")
# Add ICONS_DIR = str(Path(DATA_DIR) / "icons") following same pattern
```
From imptune/templates/printer_detail.html (current state):
```html
{% extends "base.html" %}
{% block content %}
<!-- Has Configuration, Driver, and Actions sections -->
<!-- Actions section has disabled "Regenerate Package" button placeholder -->
{% endblock %}
```
From imptune/api/pages.py printer_detail():
```python
@router.get("/printers/{printer_id}", response_class=HTMLResponse)
def printer_detail(request: Request, printer_id: int):
# Returns context: {"printer": printer, "driver_names": driver_names}
# Need to add install_cmd, uninstall_cmd, has_icon to context
```
</interfaces>
</context>
<tasks>
<task type="auto" tdd="true">
<name>Task 1: Icon upload endpoint with validation</name>
<files>imptune/api/icons.py, imptune/config.py, imptune/main.py, requirements.txt, tests/test_icon_upload.py</files>
<behavior>
- test_upload_valid_png: POST /printers/{id}/icon with valid 256x256 PNG returns 200, Icon record created in DB, file stored on disk
- test_reject_non_png: POST with a JPEG file returns 422 with "PNG format" error message
- test_reject_oversized: POST with PNG > 750KB returns 422 with "750 KB" error message
- test_reject_wrong_dimensions: POST with 128x128 PNG returns 422 with "256x256" error message
- test_replace_existing_icon: Second upload for same printer replaces the Icon record (unique FK constraint)
- test_404_missing_printer: POST to nonexistent printer_id returns 404
</behavior>
<action>
1. Add `Pillow>=10.0` to `requirements.txt` (per RESEARCH recommendation -- needed for dimension validation).
2. Add `ICONS_DIR` to `imptune/config.py`:
```python
ICONS_DIR = str(Path(DATA_DIR) / "icons")
```
3. Create `tests/test_icon_upload.py` with RED tests. Generate a valid 256x256 PNG in the fixture using Pillow (`Image.new("RGBA", (256, 256), color="red")` saved to BytesIO). Use `client` fixture from conftest.py. Create Printer record in fixture.
4. Create `imptune/api/icons.py` with `router = APIRouter(prefix="/printers")`:
**POST /{printer_id}/icon** (accepts `file: UploadFile`):
- Validate printer exists (Printer.get_or_none), return 404 if not
- Read file bytes: `data = file.file.read(MAX_ICON_BYTES + 1)` where `MAX_ICON_BYTES = 750 * 1024`
- If `len(data) > MAX_ICON_BYTES`, return 422 "Icon exceeds 750 KB limit"
- Validate with Pillow: `img = Image.open(io.BytesIO(data))`
- If `img.format != "PNG"`, return 422 "Icon must be PNG format"
- If `img.size != (256, 256)`, return 422 "Icon must be 256x256 pixels, got {img.size}"
- Store SHA256-addressed: `sha256 = hashlib.sha256(data).hexdigest()`, write to `Path(cfg.DATA_DIR) / "icons" / sha256` (read cfg.DATA_DIR at call time, not import time -- monkeypatch pattern)
- Create icons dir if not exists: `Path(cfg.DATA_DIR, "icons").mkdir(parents=True, exist_ok=True)`
- Delete existing Icon for this printer if any: `Icon.delete().where(Icon.printer == printer_id).execute()`
- Create new Icon record: `Icon.create(printer=printer_id, sha256=sha256, original_filename=file.filename, size_bytes=len(data))`
- Return `HTMLResponse("<p>Icon uploaded successfully</p>")` (HTMX-friendly)
5. Register router in `imptune/main.py`:
- Add `from imptune.api import icons` to imports
- Add `app.include_router(icons.router)` after packages router
6. Also create `ICONS_DIR` in lifespan startup (same as DRIVERS_DIR pattern):
- Add `os.makedirs(cfg.ICONS_DIR, exist_ok=True)` in lifespan -- but use dynamic `cfg.ICONS_DIR` to avoid import-time evaluation. Actually, follow the existing pattern: import ICONS_DIR from config at top of main.py and makedirs in lifespan. But note: the test monkepatches cfg module, so use `import imptune.config as cfg` in lifespan OR just use the string directly. The simplest correct pattern: add `from imptune.config import ICONS_DIR` alongside the existing imports and `os.makedirs(ICONS_DIR, exist_ok=True)` in lifespan. This works because the lifespan runs AFTER monkeypatch has been applied in tests (TestClient context manager triggers lifespan).
Wait -- looking at the existing code more carefully: main.py imports `DATA_DIR, DRIVERS_DIR` at top level and uses them directly in lifespan. This works for tests because conftest patches `cfg.DRIVERS_DIR` before TestClient enters context. But the top-level import captures the original value. Let me check... Actually the conftest patches the cfg module attributes, but main.py imported the values at module load time. The lifespan still uses the stale import-time values. This is fine because the tests use `tmp_data_dir` which patches cfg, and the test client triggers lifespan which uses the already-imported constants -- wait, this is a potential issue.
Actually: looking at the conftest, it patches `cfg.DATA_DIR`, `cfg.DB_PATH`, `cfg.DRIVERS_DIR` on the module. The main.py does `from imptune.config import DATA_DIR, DRIVERS_DIR` which binds to the original values. BUT init_db() calls `db.init(cfg.DB_PATH)` dynamically, and the lifespan makedirs uses the imported constant. Since tests have their own tmp_data_dir and the test client is created AFTER monkeypatch, the lifespan runs with stale DATA_DIR/DRIVERS_DIR values. But this seems to work because the test fixtures create those dirs themselves via `data_dir.mkdir()`.
Simplest approach: Add ICONS_DIR to the import in main.py alongside the others. The conftest already creates `data_dir` and the tests will create `data_dir / "icons"` as needed. In the icons.py endpoint, use `import imptune.config as cfg` and read `cfg.DATA_DIR` at call time (consistent with RESEARCH anti-pattern guidance).
7. Run tests GREEN.
</action>
<verify>
<automated>pytest tests/test_icon_upload.py -x</automated>
</verify>
<done>
- Pillow added to requirements.txt
- ICONS_DIR added to config.py
- Icon upload validates format (PNG), size (<=750KB), dimensions (256x256)
- Icon stored SHA256-addressed on disk, Icon ORM record created
- Re-upload replaces previous icon
- All tests pass
</done>
</task>
<task type="auto">
<name>Task 2: Printer detail page with export buttons and command preview</name>
<files>imptune/api/pages.py, imptune/templates/printer_detail.html, tests/test_packages.py</files>
<action>
1. Update `imptune/api/pages.py` `printer_detail()` to add command strings and icon status to template context:
```python
install_cmd = "powershell.exe -ExecutionPolicy Bypass -File install.ps1"
uninstall_cmd = "powershell.exe -ExecutionPolicy Bypass -File uninstall.ps1"
has_driver = printer.driver_id is not None and bool(driver_names)
# Check if icon exists
from imptune.db.models import Icon
icon = Icon.get_or_none(Icon.printer == printer_id)
```
Pass `install_cmd`, `uninstall_cmd`, `has_driver`, `has_icon=(icon is not None)` to template context.
2. Rewrite `imptune/templates/printer_detail.html` to add three new sections after the existing Driver section:
**Command Preview section** (PKG-05):
```html
<h2>Intune Commands</h2>
```
Show install_cmd and uninstall_cmd in `<code>` blocks. Add Alpine.js copy button for each:
```html
<div x-data="{ copied: false }">
<label>Install command</label>
<code id="install-cmd">{{ install_cmd }}</code>
<button @click="
const text = document.getElementById('install-cmd').innerText;
navigator.clipboard.writeText(text).then(() => { copied = true; setTimeout(() => copied = false, 2000) })
.catch(() => { /* fallback: text is visible for manual copy */ })
" x-text="copied ? 'Copied!' : 'Copy'" class="secondary outline"></button>
</div>
```
Repeat for uninstall command with id="uninstall-cmd". Only show this section if `has_driver` is true.
**Export Downloads section**:
Show download buttons only when `has_driver` is true:
```html
<h2>Export</h2>
<a href="/printers/{{ printer.id }}/packages/ninja" role="button">Download NinjaRMM ZIP</a>
<a href="/printers/{{ printer.id }}/packages/intunewin" role="button">Download .intunewin</a>
```
**Icon Upload section** (PKG-04):
```html
<h2>Icon</h2>
{% if has_icon %}
<p>Icon uploaded</p>
{% endif %}
<form hx-post="/printers/{{ printer.id }}/icon"
hx-target="#icon-status" hx-swap="innerHTML"
enctype="multipart/form-data">
<input type="file" name="file" accept="image/png" required>
<button type="submit">Upload Icon</button>
</form>
<div id="icon-status"></div>
```
Replace the old disabled "Regenerate Package" button with the real export buttons.
3. Add a test in `tests/test_packages.py` class `TestCommandPreview`:
- test_detail_page_shows_commands: GET /printers/{id} returns HTML containing "install-cmd" and "uninstall-cmd" ids and the command strings
- test_detail_page_shows_export_links: GET /printers/{id} returns HTML containing "/packages/ninja" and "/packages/intunewin" hrefs
These are simple integration tests using the `client` fixture -- create a Printer+Driver, GET the detail page, assert the command text and download links appear in the response HTML.
4. Run all tests green.
</action>
<verify>
<automated>pytest tests/test_packages.py -x && pytest tests/ -x</automated>
</verify>
<done>
- Printer detail page shows install and uninstall command strings with copy buttons
- Printer detail page has NinjaRMM ZIP and .intunewin download links (visible when driver assigned)
- Printer detail page has icon upload form with HTMX submission
- Disabled placeholder button removed, replaced with real export actions
- All tests pass including full suite
</done>
</task>
</tasks>
<verification>
pytest tests/test_icon_upload.py tests/test_packages.py -x && pytest tests/ -x
</verification>
<success_criteria>
- Icon upload validates PNG format, 256x256 dimensions, and 750KB size limit
- Icon stored on disk and tracked in Icon ORM model
- Printer detail page shows Intune command strings with copy-to-clipboard
- Printer detail page has working download links for both package formats
- Full test suite green
</success_criteria>
<output>
After completion, create `.planning/phases/05-package-export/05-02-SUMMARY.md`
</output>