Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
14 KiB
phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, must_haves
| phase | plan | type | wave | depends_on | files_modified | autonomous | requirements | must_haves | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 05-package-export | 02 | execute | 1 |
|
true |
|
|
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.
<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>
@.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
From imptune/db/models.py:
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):
from fastapi import UploadFile
# file: UploadFile parameter, file.file.read() for bytes, file.filename for name
From imptune/config.py:
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):
{% 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():
@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
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.
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.
<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>