Files
ImpTune/.planning/phases/05-package-export/05-02-PLAN.md
T
2026-04-10 13:53:31 +02:00

293 lines
14 KiB
Markdown

---
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>