Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 28 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,11 @@ inventory_summary()

| | |
|---|---|
| **Tools** | `inventory_summary` · `inventory_list` · `inventory_get` · `inventory_upsert_lot` · `inventory_upsert_item` · `inventory_delete_item` · `inventory_set_price` · `inventory_mark_sold` · `inventory_listing` · `inventory_import_csv` · `inventory_export_csv` · `inventory_reprice_plan` · `inventory_stale` |
| **View** | a rail panel: the item grid (double-click to edit name/category/condition/qty; status select; click a price for the Price dialog), Price / Sold / Listing / Edit / Delete per row, an optional **game system** per item (filter, ordering, inline edit with suggestions), multi-select with **Copy list** (also ⌘C) — a for-sale post per game system: the system as a header, then `Name NoS — $20` per line (condition inline, whole-dollar prices), lots with their P&L, sales, an activity log, CSV import (file or paste, with the mapping report) and export |
| **API** | bearer-gated JSON under `/api/plugins/inventory` — `summary`, `lots`, `items`, `items/{id}/price`, `items/{id}/sold`, `items/{id}/listings`, `listings/{id}/end`, `sales`, `stale`, `audit`, `import`, `export` |
| **Tools** | `inventory_summary` · `inventory_list` · `inventory_get` · `inventory_upsert_lot` · `inventory_upsert_item` · `inventory_delete_item` · `inventory_set_price` · `inventory_mark_sold` · `inventory_listing` · `inventory_import_csv` · `inventory_export_csv` · `inventory_reprice_plan` · `inventory_add_photo` · `inventory_publish_preview` · `inventory_stale` — there is deliberately **no publish tool** |
| **View** | a rail panel: the item grid (double-click to edit name/category/condition/qty; status select; click a price for the Price dialog), Price / Sold / Listing / Edit / Delete per row, an optional **game system** per item (filter, ordering, inline edit with suggestions), multi-select with **Copy list** (also ⌘C) — a for-sale post per game system: the system as a header, then `Name NoS — $20` per line (condition inline, whole-dollar prices), lots with their P&L, sales, an activity log, CSV import (file or paste, with the mapping report) and export; per item a **Show on the public site** flag, a public **blurb** and **photos** (upload from the Edit dialog, alt text, make cover, delete); a **Publish** button that previews the site catalog diff and publishes it |
| **API** | bearer-gated JSON under `/api/plugins/inventory` — `summary`, `lots`, `items`, `items/{id}/price`, `items/{id}/sold`, `items/{id}/listings`, `listings/{id}/end`, `sales`, `stale`, `audit`, `import`, `export`, `items/{id}/photos` (raw-body upload, list, bytes, PATCH alt/position, DELETE), `publish/preview`, `publish` |
| **Automations** | `weekly_review: true` arms a plugin-owned recurring turn (`weekly_review_cron`, default Monday 09:00 in `review_timezone`) that re-prices stale evidence from eBay sold comps through the same tools, reports stale listings with a recommendation (it never changes a listing itself), and posts the per-lot P&L. Cancelled when the plugin is disabled. |
| **Events** | `inventory.item.changed`, `inventory.lot.changed`, `inventory.sale.recorded`, `inventory.imported` |
| **Events** | `inventory.item.changed`, `inventory.lot.changed`, `inventory.sale.recorded`, `inventory.imported`, `inventory.published` |
| **Skill** | `inventory-ops` — the rules (a target needs a basis; never set sold by hand; sold ≠ active ≠ retail) and the re-price / weekly-review routines |

## The model
Expand All @@ -33,6 +33,30 @@ inventory_summary()

Money is stored as integer cents and exposed as dollars.

## Photos and the public site

- **Photos** are sniffed by their bytes (JPEG, PNG, WebP; HEIC/HEIF is converted to JPEG with
macOS `sips`), capped at 20 MB, and **stripped of metadata on upload** — EXIF (GPS, camera,
serial), XMP, IPTC, comments, and anything after a JPEG's end-of-image marker. Only the
EXIF Orientation survives, so phone photos still display upright. Pure Python, no Pillow.
Stored next to the database as `photos/<item_id>/<photo_id>.<ext>`; position 0 is the cover.
- **Public** is opt-in per item. **Publish** (the view's button — the agent can only preview)
builds `src/data/catalog.json` in the `site_dir` checkout from an allowlist of fields —
`id, name, system, category, condition, price_cents, quantity, status, blurb, photos, links,
updated` — for items that are public, available or listed, priced, and in stock. Cost,
lot, notes, the low/high band, retail, price basis, sales and the audit log never leave.
- The preview carries a hash of exactly what it showed; Publish refuses (409) if the
inventory moved since. It mirrors the photos into `src/assets/catalog/` (only inside that
folder), commits the two paths (`git commit --only`, so nothing else you staged rides
along) and pushes. A failed push is reported; the files and the commit stay.

```yaml
inventory:
site_dir: /path/to/nerdsville-site # the site checkout; blank = preview only
publish_git: true # commit catalog + photos after a publish
publish_push: true # push so the site's CI deploys
```

## Setup

```bash
Expand Down
92 changes: 91 additions & 1 deletion api.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@
import contextlib
import logging

from fastapi import Request # module level: FastAPI resolves the (postponed) annotation from these globals

from .store import InventoryError, InventoryStore, normalize_status

#: Target fields never travel through the generic item write — they need a basis (POST /price).
Expand All @@ -18,7 +20,10 @@

def build_data_router(store: InventoryStore, cfg: dict, *, emit=lambda topic, data: None):
from fastapi import APIRouter, HTTPException, Query
from fastapi.responses import PlainTextResponse
from fastapi.responses import FileResponse, PlainTextResponse
from starlette.concurrency import run_in_threadpool

from .photos import MAX_BYTES

r = APIRouter()

Expand Down Expand Up @@ -141,6 +146,91 @@ async def _delete_item(item_id: str) -> dict:
emit("item.changed", {"id": item_id, "action": "delete"})
return {"ok": True}

# ── photos ── the page uploads the File itself as the body (kit.apiFetch passes a Blob
# through untouched), with its type in Content-Type and the alt text in ?alt=.
def _need_item(item_id: str) -> None:
if store.get_item_row(item_id) is None:
raise HTTPException(status_code=404, detail=f"no item {item_id!r}")

@r.get("/items/{item_id}/photos")
async def _photos(item_id: str) -> dict:
_need_item(item_id)
return {"photos": store.list_photos(item_id)}

@r.post("/items/{item_id}/photos")
async def _add_photo(item_id: str, request: Request, alt: str = "") -> dict:
_need_item(item_id)
too_big = f"photos are capped at {MAX_BYTES // (1024 * 1024)} MB"
declared = request.headers.get("content-length") or ""
if declared.isdigit() and int(declared) > MAX_BYTES:
raise HTTPException(status_code=413, detail=too_big)
buf = bytearray()
async for chunk in request.stream():
buf += chunk
if len(buf) > MAX_BYTES:
raise HTTPException(status_code=413, detail=too_big)
try: # sniff + sanitize (+ a HEIC conversion) off the event loop
photo = await run_in_threadpool(store.add_photo, item_id, bytes(buf), alt=alt, actor=ACTOR)
except InventoryError as exc:
_raise(exc)
emit("item.changed", {"id": item_id, "action": "photo_added"})
return {"photo": photo}

@r.get("/items/{item_id}/photos/{photo_id}")
async def _photo_bytes(item_id: str, photo_id: str):
found = store.photo_file(item_id, photo_id)
if found is None:
raise HTTPException(status_code=404, detail=f"no photo {photo_id!r} on item {item_id!r}")
path, content_type = found
return FileResponse(
path,
media_type=content_type,
headers={"Cache-Control": "private, max-age=86400", "X-Content-Type-Options": "nosniff"},
)

@r.patch("/items/{item_id}/photos/{photo_id}")
async def _patch_photo(item_id: str, photo_id: str, body: dict) -> dict:
try:
photo = store.update_photo(
item_id, photo_id, alt=body.get("alt"), position=body.get("position"), actor=ACTOR
)
except InventoryError as exc:
if str(exc).startswith("no photo"):
raise HTTPException(status_code=404, detail=str(exc)) from exc
_raise(exc)
emit("item.changed", {"id": item_id, "action": "photo_updated"})
return {"photo": photo}

@r.delete("/items/{item_id}/photos/{photo_id}")
async def _delete_photo(item_id: str, photo_id: str) -> dict:
if not store.delete_photo(item_id, photo_id, actor=ACTOR):
raise HTTPException(status_code=404, detail=f"no photo {photo_id!r} on item {item_id!r}")
emit("item.changed", {"id": item_id, "action": "photo_deleted"})
return {"ok": True}

# ── the public site ── sync handlers: they read and write files (and run git), so
# FastAPI runs them in its threadpool instead of on the event loop.
@r.get("/publish/preview")
def _publish_preview() -> dict:
from .publish import preview

return preview(store, cfg)

@r.post("/publish")
def _publish(body: dict) -> dict:
from .publish import PublishConflict, publish

try:
out = publish(store, cfg, str(body.get("hash") or ""), actor=ACTOR)
except PublishConflict as exc:
raise HTTPException(status_code=409, detail=str(exc)) from exc
except InventoryError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
except OSError as exc: # a disk or permission problem in the site checkout
raise HTTPException(status_code=500, detail=f"could not write the site files: {exc}") from exc
emit("published", {"count": out["count"], "commit": out["commit"], "pushed": out["pushed"]})
return out

@r.post("/items/{item_id}/price")
async def _price(item_id: str, body: dict) -> dict:
obs = body.get("observation") or None
Expand Down
4 changes: 4 additions & 0 deletions csvio.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@
"system": ("system", "game_system", "game"),
"name": ("item", "name", "title", "item_name"),
"condition": ("condition", "cond"),
"public": ("public", "on_site", "show_on_site"),
"blurb": ("blurb", "public_blurb", "site_blurb"),
"quantity": ("unit_quantity", "quantity", "qty"),
"model_count": ("model_count", "models"),
"notes": ("notes", "note", "description"),
Expand Down Expand Up @@ -98,13 +100,15 @@
"quantity",
"model_count",
"status",
"public",
"target_low",
"target",
"target_high",
"retail",
"cost_basis",
"price_basis",
"price_updated_on",
"blurb",
"notes",
"updated_at",
)
Expand Down
Loading
Loading