Skip to content
Merged
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
3 changes: 3 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,9 @@ logs/
*.sqlite3
*.sql.gz

# Uploaded print files (never bake into image)
/data/

# Docs (not needed inside the image)
README.md
LICENSE
Expand Down
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -43,3 +43,6 @@ logs/
*.db
*.sqlite3
*.sql.gz

# Uploaded print files (local dev, persisted via Docker volume in prod)
/data/
1 change: 1 addition & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ RUN playwright install --with-deps --only-shell chromium \

COPY --chown=appuser:appuser . .
RUN sed -i 's/\r$//' entrypoint.sh && chmod +x entrypoint.sh
RUN mkdir -p /app/data/uploads/print_files && chown -R appuser:appuser /app/data

USER appuser

Expand Down
106 changes: 58 additions & 48 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,24 +18,28 @@ Track spools, purchases and shop prices — get notified when a filament hits yo

## Screenshots

| Dashboard | Inventory |
|---|---|
| Dashboard | Inventory |
|----------------------------------------------|---------------------------------------------------|
| ![Dashboard](docs/screenshots/dashboard.png) | ![Inventory](docs/screenshots/inventory_list.png) |

| Filament Detail | Spools |
|---|---|
| Filament Detail | Spools |
|-----------------------------------------------------------|---------------------------------------------------------|
| ![Filament Detail](docs/screenshots/inventory_detail.png) | ![Spools](docs/screenshots/inventory_detail_spools.png) |

| Print Log | Log Print Form |
|---|---|
| ![Print Log](docs/screenshots/prints_list.png) | ![Log Print](docs/screenshots/prints_new.png) |
| Print Board | New Print Job |
|---------------------------------------------------|---------------------------------------------------|
| ![Print Board](docs/screenshots/prints_board.png) | ![New Print Job](docs/screenshots/prints_new.png) |

| Shop Rules | Manufacturers |
|---|---|
| 3D Preview |
|------------------------------------------------------|
| ![3D Preview](docs/screenshots/prints_3d_viewer.png) |

| Shop Rules | Manufacturers |
|------------------------------------------------|------------------------------------------------------|
| ![Shop Rules](docs/screenshots/shop_rules.png) | ![Manufacturers](docs/screenshots/manufacturers.png) |

| API Keys |
|---|
| API Keys |
|--------------------------------------------|
| ![API Keys](docs/screenshots/api_keys.png) |

---
Expand Down Expand Up @@ -188,14 +192,14 @@ python seed_shops.py # ShopRules only, no inventory data

All runtime settings are managed on the Settings page (`/settings`, admin only) — not via `.env`.

| Section | What you can configure |
|---|---|
| Scheduler | Enable automatic price checks, set interval in minutes |
| Fetch engine | `playwright` (default, JS-capable) or `httpx` (faster, no JS) |
| Discord | Webhook URL, enable/disable, test message |
| Email (SMTP) | Host, port, credentials, TLS, from/to address, test email |
| Spool code template | Pattern for auto-generated spool codes |
| Backup & Restore | Export full inventory as JSON, import additively (admin only) |
| Section | What you can configure |
|---------------------|---------------------------------------------------------------|
| Scheduler | Enable automatic price checks, set interval in minutes |
| Fetch engine | `playwright` (default, JS-capable) or `httpx` (faster, no JS) |
| Discord | Webhook URL, enable/disable, test message |
| Email (SMTP) | Host, port, credentials, TLS, from/to address, test email |
| Spool code template | Pattern for auto-generated spool codes |
| Backup & Restore | Export full inventory as JSON, import additively (admin only) |

---

Expand All @@ -205,15 +209,15 @@ All runtime settings are managed on the Settings page (`/settings`, admin only)

These shops work out of the box without any manual configuration:

| Shop | Method |
|---|---|
| `3djake.de` | CSS selector |
| `prusa3d.com` | JSON-LD |
| `anycubic.com` | Shopify JSON-LD |
| `eu.store.bambulab.com` | JSON-LD (cloudscraper) |
| `esun3dstore.com` | Shopify JSON-LD (cloudscraper) |
| `esun3dstoreeu.com` | Shopify JSON-LD (cloudscraper) |
| `elegoo.com` | Shopify og:price:amount |
| Shop | Method |
|-------------------------|--------------------------------|
| `3djake.de` | CSS selector |
| `prusa3d.com` | JSON-LD |
| `anycubic.com` | Shopify JSON-LD |
| `eu.store.bambulab.com` | JSON-LD (cloudscraper) |
| `esun3dstore.com` | Shopify JSON-LD (cloudscraper) |
| `esun3dstoreeu.com` | Shopify JSON-LD (cloudscraper) |
| `elegoo.com` | Shopify og:price:amount |

To add a new adapter: subclass `BaseAdapter` in `app/shop_adapters/`, implement `extract(html, url) -> AdapterResult`, register in `registry.py` via `_reg(YourAdapter())`.

Expand All @@ -223,15 +227,18 @@ For any other shop: create a rule at `/shop-rules` with domain, CSS price select

---

## 🖨️ Print Log
## 🖨️ Print Board

Navigate to **Prints** in the nav bar (or `/prints/`) to see all logged print jobs.
Navigate to **Prints** in the nav bar (or `/prints/`) for a Kanban-style board of your print jobs, grouped into **Planned → Printing → Done** columns.

Hit **+ Log Print** to record a new job:
Hit **+ New Print Job** to plan a new job:

1. Enter an optional print name and notes.
2. Add one or more filament lines — select a spool from the grouped dropdown, enter grams used. The live preview shows how much is left on that spool.
3. Hit **Save Print Log** — remaining weight is deducted from each spool immediately and spool status updates automatically (new → opened → almost empty → empty).
2. Add one or more filament lines — select a spool from the grouped dropdown, enter planned grams. The live preview shows how much is left on that spool.
3. Optionally attach print files: a link to Printables/MakerWorld/Thingiverse/etc., or upload an STL/3MF directly — uploaded files get an in-browser 3D preview (three.js, no external service).
4. Hit **Add to Board** — the job lands in **Planned** with a generated job code (e.g. `PJ-20260803-002`, template configurable under Settings).

Move a card forward with **Start** / **Mark Done**, or back with the reopen button. **Filament is only deducted from the spool once a job is marked Done** — moving it back out of Done restores the weight. Planned/printing jobs can be edited (name, notes, filament lines, files) via the pencil icon; completed jobs are read-only history (reopen first to edit).

Supports multi-filament prints (e.g. dual-extrusion or colour changes mid-print) by adding multiple lines.

Expand All @@ -253,17 +260,18 @@ Authorization: Bearer <your-token>

### Endpoints

| Method | Path | Description |
|--------|-------------------------|--------------------------------------------------------------------------------|
| GET | `/api/v1/health` | Health check (no auth required) |
| GET | `/api/v1/manufacturers` | List all manufacturers |
| GET | `/api/v1/products` | List all filament products |
| GET | `/api/v1/products/{id}` | Product detail including all spools |
| GET | `/api/v1/spools` | List spools — filter with `?status=new\|opened\|almost_empty\|empty\|archived` |
| GET | `/api/v1/spools/{id}` | Single spool |
| PATCH | `/api/v1/spools/{id}` | Update remaining weight `{"remaining_g": 450}` — status auto-updates |
| GET | `/api/v1/prints` | Paginated print jobs (`?page=`, `?per_page=` max 100) |
| POST | `/api/v1/prints` | Log a print job (deducts weight from spools) |
| Method | Path | Description |
|--------|------------------------------|--------------------------------------------------------------------------------|
| GET | `/api/v1/health` | Health check (no auth required) |
| GET | `/api/v1/manufacturers` | List all manufacturers |
| GET | `/api/v1/products` | List all filament products |
| GET | `/api/v1/products/{id}` | Product detail including all spools |
| GET | `/api/v1/spools` | List spools — filter with `?status=new\|opened\|almost_empty\|empty\|archived` |
| GET | `/api/v1/spools/{id}` | Single spool |
| PATCH | `/api/v1/spools/{id}` | Update remaining weight `{"remaining_g": 450}` — status auto-updates |
| GET | `/api/v1/prints` | Paginated print jobs (`?page=`, `?per_page=` max 100) |
| POST | `/api/v1/prints` | Plan a new print job (status `planned`, no weight deducted yet) |
| PATCH | `/api/v1/prints/{id}/status` | Move a job between `planned`/`printing`/`done` |

### POST /api/v1/prints body

Expand All @@ -284,7 +292,9 @@ Authorization: Bearer <your-token>
}
```

Spool weight is deducted immediately and spool status auto-updates (new → opened → almost empty → empty).
The job is created with status `planned`. Spool weight is only deducted once the job is moved to `done` via
`PATCH /api/v1/prints/{id}/status` (`{"status": "done"}`), and spool status auto-updates (new → opened → almost empty →
empty). Moving a job back out of `done` restores the deducted weight.

### Quick example

Expand Down Expand Up @@ -354,9 +364,9 @@ The shop's HTML structure changed. Use the Test button on `/shop-rules` and upda

Pre-built images are published to [Docker Hub](https://hub.docker.com/r/sytxlabs/spoolbeacon) on every push to `master` and on version tags:

| Tag | Description |
|---|-----------------------------------|
| `sytxlabs/spoolbeacon:latest` | Latest stable build from `master` |
| Tag | Description |
|----------------------------------|-----------------------------------|
| `sytxlabs/spoolbeacon:latest` | Latest stable build from `master` |
| `sytxlabs/spoolbeacon:<version>` | Pinned release (e.g. `1.0.0`) |

```bash
Expand Down
13 changes: 8 additions & 5 deletions app/spool_code.py → app/code_template.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
"""
Spool code template engine.
Template stored in AppSetting key 'spool.code_template'.
Default: SB-{product_id}-{line_id}-{timestamp}-{seq:02d}
Generic code template engine, used for both spool codes (AppSetting
'spool.code_template', default SB-{product_id}-{line_id}-{timestamp}-{seq:02d})
and print job codes (AppSetting 'print.code_template', default PJ-{date}-{seq:03d}).
{product_id}/{line_id} are only meaningful for spool codes — they resolve to
the given value (or 0) regardless of which template they're used in.

Supported variables:
{product_id} FilamentProduct ID
Expand All @@ -22,7 +24,8 @@

_VAR_RE = re.compile(r'\{(\w+)(?::([^}]*))?\}')

DEFAULT_TEMPLATE = "SB-{product_id}-{line_id}-{timestamp}-{seq:02d}"
SPOOL_DEFAULT_TEMPLATE = "SB-{product_id}-{line_id}-{timestamp}-{seq:02d}"
PRINT_DEFAULT_TEMPLATE = "PJ-{date}-{seq:03d}"

AVAILABLE_VARS = [
("{product_id}", "FilamentProduct ID"),
Expand All @@ -39,7 +42,7 @@
]


def generate_spool_code(
def generate_code(
template: str,
product_id: int,
line_id: int | None,
Expand Down
3 changes: 3 additions & 0 deletions app/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,3 +21,6 @@ class Config:
DEBUG = os.getenv("DEBUG", "false").lower() == "true"
QUART_AUTH_COOKIE_SECURE = os.getenv("QUART_AUTH_COOKIE_SECURE", "false").lower() == "true"
QUART_AUTH_COOKIE_SAMESITE = "Lax"
UPLOAD_DIR = os.getenv("UPLOAD_DIR", "/app/data/uploads/print_files")
MAX_UPLOAD_MB = 50
MAX_CONTENT_LENGTH = (MAX_UPLOAD_MB + 5) * 1024 * 1024
4 changes: 2 additions & 2 deletions app/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
from .filament import Manufacturer, FilamentProduct
from .price_alert_event import PriceAlertEvent
from .price_snapshot import PriceSnapshot
from .print_job import PrintJob, PrintJobLine
from .print_job import PrintJob, PrintJobLine, PrintJobFile, PrintFileKind, PrintJobStatus
from .purchase import Purchase, PurchaseLine
from .shop_rule import ShopRule
from .shoplink import ShopLink
Expand All @@ -20,6 +20,6 @@
"ShopRule",
"PriceAlertEvent",
"AppSetting",
"PrintJob", "PrintJobLine",
"PrintJob", "PrintJobLine", "PrintJobFile", "PrintFileKind", "PrintJobStatus",
"ApiKey",
]
38 changes: 37 additions & 1 deletion app/models/print_job.py
Original file line number Diff line number Diff line change
@@ -1,21 +1,37 @@
import enum
from datetime import datetime

from sqlalchemy import Column, Integer, Float, String, Text, DateTime, ForeignKey
from sqlalchemy import Column, Integer, Float, String, Text, DateTime, ForeignKey, Enum as SAEnum
from sqlalchemy.orm import relationship

from app.database import Base


class PrintFileKind(enum.Enum):
link = "link"
upload = "upload"


class PrintJobStatus(enum.Enum):
planned = "planned"
printing = "printing"
done = "done"


class PrintJob(Base):
__tablename__ = "print_jobs"

id = Column(Integer, primary_key=True)
job_code = Column(String(64), unique=True, nullable=False, index=True)
print_name = Column(String(200), nullable=True)
notes = Column(Text, nullable=True)
status = Column(SAEnum(PrintJobStatus), nullable=False, default=PrintJobStatus.planned, index=True)
printed_at = Column(DateTime, default=datetime.utcnow, nullable=False, index=True)
completed_at = Column(DateTime, nullable=True)
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)

lines = relationship("PrintJobLine", back_populates="print_job", cascade="all, delete-orphan")
files = relationship("PrintJobFile", back_populates="print_job", cascade="all, delete-orphan")

@property
def total_used_g(self) -> float:
Expand All @@ -40,3 +56,23 @@ class PrintJobLine(Base):

def __repr__(self) -> str:
return f"<PrintJobLine spool={self.spool_code} used={self.used_g}g>"


class PrintJobFile(Base):
__tablename__ = "print_job_files"

id = Column(Integer, primary_key=True)
print_job_id = Column(Integer, ForeignKey("print_jobs.id", ondelete="CASCADE"), nullable=False, index=True)
kind = Column(SAEnum(PrintFileKind), nullable=False)
provider = Column(String(50), nullable=True)
url = Column(String(500), nullable=True)
stored_filename = Column(String(255), nullable=True)
original_filename = Column(String(255), nullable=True)
file_ext = Column(String(10), nullable=True)
file_size_bytes = Column(Integer, nullable=True)
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)

print_job = relationship("PrintJob", back_populates="files")

def __repr__(self) -> str:
return f"<PrintJobFile kind={self.kind.value if self.kind else None} provider={self.provider!r}>"
Loading
Loading