--- 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" --- 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. @C:/Users/SebastienQUEROL/.claude/get-shit-done/workflows/execute-plan.md @C:/Users/SebastienQUEROL/.claude/get-shit-done/templates/summary.md @.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 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) ... ``` Task 1: Package export endpoints with TDD imptune/api/packages.py, imptune/main.py, tests/test_packages.py - 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 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. pytest tests/test_packages.py -x - 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) pytest tests/test_packages.py -x && pytest tests/ -x - 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 After completion, create `.planning/phases/05-package-export/05-01-SUMMARY.md`