Add photo attachments to parts

A bag falling apart after forty years still has the part number printed on it,
and the picture carries more than any field you could retype it into. Photos
attach from the part form; on a phone the picker opens the camera directly.

Files live beside the database in the same volume, with only metadata in SQLite
— blobs there bloat the database and complicate the VACUUM INTO backup. The
browser downscales to 2000px before uploading, honouring EXIF orientation via
createImageBitmap, which keeps an image library and its native dependencies out
of the server entirely.

Uploads are sniffed by content rather than trusted by their declared type, so a
file that merely claims to be a JPEG cannot be stored and served back from this
origin as something a browser will execute. SVG is refused for the same reason.
Reads are capped rather than trusting Content-Length, at most 12 photos per
part, and a row that fails to insert takes its file with it.

Deleting a photo or a part removes the files, not just the rows, and
`app.admin prune-images` sweeps anything a crash stranded. The README's backup
procedure now covers both halves; capturing only the database would have
silently lost every photo.

python-multipart returns for the upload, pinned at 0.0.32 — the version removed
earlier was 0.0.20, which carried advisories. Audit is clean.

Two frontend bugs surfaced while testing this in the browser: a photo count
changing left the list row stale, and the part form opened from the list's
cached copy rather than fetching current data.

Checks go from 197 to 271.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jay
2026-08-24 17:03:36 -04:00
parent 59949e91ca
commit 96d2a1a087
9 changed files with 742 additions and 26 deletions
+95 -1
View File
@@ -13,6 +13,8 @@ Uses Playwright's own Chromium, which runs happily alongside a desktop Chrome
import argparse
import json
import struct
import zlib
import os
import subprocess
import sys
@@ -35,6 +37,20 @@ problems = []
expected_http = []
def make_png(w=900, h=700, rgb=(190, 60, 45)):
"""A real, decodable PNG — the browser has to actually draw this one."""
raw = b"".join(b"\x00" + bytes(rgb) * w for _ in range(h))
def chunk(tag, data):
body = tag + data
return struct.pack(">I", len(data)) + body + struct.pack(">I", zlib.crc32(body))
return (b"\x89PNG\r\n\x1a\n"
+ chunk(b"IHDR", struct.pack(">IIBBBBB", w, h, 8, 2, 0, 0, 0))
+ chunk(b"IDAT", zlib.compress(raw))
+ chunk(b"IEND", b""))
def wait_until(fn, timeout=10.0):
"""Poll in Python rather than in the page.
@@ -143,6 +159,9 @@ def main():
# --- desktop ---
page = browser.new_page(viewport={"width": 1280, "height": 950})
watch(page)
# confirm() defaults to dismissed in Playwright, which would silently
# cancel every delete the suite performs.
page.on("dialog", lambda d: d.accept())
page.goto(BASE + "/", wait_until="networkidle")
check("login gate is shown first", page.is_visible("#login"))
@@ -180,6 +199,7 @@ def main():
page.fill("#search", "")
wait_until(lambda: page.locator(".part").count() == seeded)
# --- quick adjust ---
row = page.locator(".part", has_text="608ZZ bearing")
before = row.locator(".qty .value").inner_text()
@@ -205,6 +225,77 @@ def main():
page.screenshot(path=os.path.join(shots, "03-add-part.png"))
page.keyboard.press("Escape")
# --- photos on an existing part ---
page.locator(".part", has_text="ESP32-WROOM-32 devboard").locator(".body").click()
page.wait_for_selector("#f-photos", timeout=5000)
check("the part form offers a photo picker", page.is_visible("#f-photos"))
check("the picker asks for the camera on a phone",
page.get_attribute("#f-photos", "capture") == "environment")
page.set_input_files("#f-photos", {
"name": "radioshack-bag.png", "mimeType": "image/png", "buffer": make_png()})
got_shot = wait_until(lambda: page.locator("#shots .shot").count() == 1, timeout=20)
check("the uploaded photo appears as a thumbnail", got_shot,
f"{page.locator('#shots .shot').count()} shown")
drawn = page.evaluate(
"() => { const i = document.querySelector('#shots .shot img');"
" return i && i.complete ? i.naturalWidth : 0; }")
check("the thumbnail really decodes in the browser", drawn > 0, f"naturalWidth={drawn}")
check("a 900px-wide photo was not needlessly enlarged", drawn <= 2000, str(drawn))
page.fill("#shots .shot .cap", "Front of the bag")
page.locator("#shots .shot .cap").dispatch_event("change")
time.sleep(0.5)
page.click("#shots .shot img")
page.wait_for_selector(".lightbox", timeout=5000)
check("clicking a thumbnail opens the full photo", page.is_visible(".lightbox"))
page.keyboard.press("Escape")
time.sleep(0.3)
check("escape closes the photo but keeps the form open",
not page.is_visible(".lightbox") and page.is_visible("#f-name"))
if shots:
page.screenshot(path=os.path.join(shots, "07-part-with-photo.png"))
page.keyboard.press("Escape")
wait_until(lambda: page.locator(".modal").count() == 0)
camera = page.locator(".part", has_text="ESP32-WROOM-32 devboard").locator(".chip")
check("the list row shows a photo count",
any("\U0001F4F7" in camera.nth(i).inner_text() for i in range(camera.count())),
[camera.nth(i).inner_text() for i in range(camera.count())])
# the caption survived a round trip
page.locator(".part", has_text="ESP32-WROOM-32 devboard").locator(".body").click()
page.wait_for_selector("#shots .shot", timeout=5000)
check("the caption was saved",
page.input_value("#shots .shot .cap") == "Front of the bag",
page.input_value("#shots .shot .cap"))
page.click("#shots .shot .rm")
removed = wait_until(lambda: page.locator("#shots .shot").count() == 0)
check("a photo can be removed", removed)
page.keyboard.press("Escape")
wait_until(lambda: page.locator(".modal").count() == 0)
# --- photos queued on a part that does not exist yet ---
page.click("#add-btn")
page.wait_for_selector("#f-name", timeout=5000)
page.fill("#f-name", "MC1458 dual op-amp")
page.fill("#f-quantity", "6")
page.set_input_files("#f-photos", {
"name": "chip.png", "mimeType": "image/png", "buffer": make_png(600, 480, (40, 120, 70))})
queued = wait_until(lambda: page.locator("#shots .shot.pending").count() == 1, timeout=20)
check("a photo on a new part is queued, not uploaded", queued)
page.click("#save-btn")
saved = wait_until(lambda: page.locator(".part", has_text="MC1458").count() == 1, timeout=20)
check("the new part is created", saved)
page.locator(".part", has_text="MC1458").locator(".body").click()
page.wait_for_selector("#f-photos", timeout=5000)
attached = wait_until(lambda: page.locator("#shots .shot:not(.pending)").count() == 1)
check("the queued photo was uploaded once the part existed", attached,
f"{page.locator('#shots .shot').count()} shown")
page.keyboard.press("Escape")
wait_until(lambda: page.locator(".modal").count() == 0)
# --- settings modal, including the password section ---
page.click("#manage-btn")
page.wait_for_selector(".modal", timeout=5000)
@@ -243,7 +334,10 @@ def main():
phone.fill("#password", "a-long-enough-password")
phone.click("#login-form button[type=submit]")
phone.wait_for_selector(".part", timeout=10000)
check("phone: results are visible immediately", phone.locator(".part").count() == seeded)
# seeded + the one added through the form above
check("phone: results are visible immediately",
phone.locator(".part").count() == seeded + 1,
str(phone.locator(".part").count()))
check("phone: the filter sidebar starts collapsed", not phone.is_visible("#sidebar"))
check("phone: a Filters button is offered", phone.is_visible("#filters-btn"))
widths = phone.evaluate(