Compare commits

...

35 Commits

Author SHA1 Message Date
beb1ad2bfa docs: Update CLAUDE.md with completed issue #19
Some checks failed
CI / style (push) Has been cancelled
CI / build-client (push) Has been cancelled
CI / build-tester (push) Has been cancelled
CI / build-amd64 (push) Has been cancelled
CI / test (cockroachdb) (push) Has been cancelled
CI / test (mariadb) (push) Has been cancelled
CI / test (postgres) (push) Has been cancelled
CI / test (sqlite) (push) Has been cancelled
CI / build-arm64 (push) Has been cancelled
CI / build-armv7 (push) Has been cancelled
CI / publish-images (push) Has been cancelled
CI / publish-release (push) Has been cancelled
Issue Manager / issue-manager (push) Failing after 44s
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 00:40:18 -06:00
23ef41270c feat(#19): Add filamentcolors.xyz color lookup integration
Some checks failed
CI / style (push) Has been cancelled
CI / build-client (push) Has been cancelled
CI / build-amd64 (push) Has been cancelled
CI / build-tester (push) Has been cancelled
CI / test (cockroachdb) (push) Has been cancelled
CI / test (mariadb) (push) Has been cancelled
CI / test (postgres) (push) Has been cancelled
CI / test (sqlite) (push) Has been cancelled
CI / build-arm64 (push) Has been cancelled
CI / build-armv7 (push) Has been cancelled
CI / publish-images (push) Has been cancelled
CI / publish-release (push) Has been cancelled
- Backend proxy endpoints: GET /filamentcolors/manufacturers, GET /filamentcolors/search
- Frontend: FilamentColorLookup modal with manufacturer filter + color search
- Color grid shows swatches with hex codes, click to apply
- Added to both filament create and edit forms next to ColorPicker

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 00:21:13 -06:00
c321d916b5 fix: Show alerts below stats normally, fix usage cost calculation
Some checks failed
CI / style (push) Has been cancelled
CI / build-client (push) Has been cancelled
CI / build-amd64 (push) Has been cancelled
CI / build-tester (push) Has been cancelled
CI / test (cockroachdb) (push) Has been cancelled
CI / test (mariadb) (push) Has been cancelled
CI / test (postgres) (push) Has been cancelled
CI / test (sqlite) (push) Has been cancelled
CI / build-arm64 (push) Has been cancelled
CI / build-armv7 (push) Has been cancelled
CI / publish-images (push) Has been cancelled
CI / publish-release (push) Has been cancelled
- Alerts shown below QuickStats without hover reveal
- Alerts also listed after usage analytics section
- Usage cost now shows cost of material consumed (unit cost * usage)
  instead of all-time purchase total which was misleading

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 00:05:07 -06:00
67eaed32d5 feat: Move alerts below stats, reveal on hover
Some checks failed
CI / style (push) Has been cancelled
CI / build-client (push) Has been cancelled
CI / build-amd64 (push) Has been cancelled
CI / build-tester (push) Has been cancelled
CI / test (cockroachdb) (push) Has been cancelled
CI / test (mariadb) (push) Has been cancelled
CI / test (postgres) (push) Has been cancelled
CI / test (sqlite) (push) Has been cancelled
CI / build-arm64 (push) Has been cancelled
CI / build-armv7 (push) Has been cancelled
CI / publish-images (push) Has been cancelled
CI / publish-release (push) Has been cancelled
Alerts now appear below the quick stats badges and are collapsed by
default, showing just a subtle "N alerts" indicator that expands
on mouseover.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-22 23:37:04 -06:00
f38baa985e feat: Rearrange dashboard - pie chart + stats on top, spools middle, analytics bottom
Some checks failed
CI / style (push) Has been cancelled
CI / build-client (push) Has been cancelled
CI / build-amd64 (push) Has been cancelled
CI / build-tester (push) Has been cancelled
CI / test (cockroachdb) (push) Has been cancelled
CI / test (mariadb) (push) Has been cancelled
CI / test (postgres) (push) Has been cancelled
CI / test (sqlite) (push) Has been cancelled
CI / build-arm64 (push) Has been cancelled
CI / build-armv7 (push) Has been cancelled
CI / publish-images (push) Has been cancelled
CI / publish-release (push) Has been cancelled
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-22 23:32:03 -06:00
ad1df035e9 docs: Update CLAUDE.md with completed issues #20, #25, #35, #36, #39, #42, #43
Some checks failed
CI / style (push) Has been cancelled
CI / build-client (push) Has been cancelled
CI / build-amd64 (push) Has been cancelled
CI / build-tester (push) Has been cancelled
CI / test (cockroachdb) (push) Has been cancelled
CI / test (mariadb) (push) Has been cancelled
CI / test (postgres) (push) Has been cancelled
CI / test (sqlite) (push) Has been cancelled
CI / build-arm64 (push) Has been cancelled
CI / build-armv7 (push) Has been cancelled
CI / publish-images (push) Has been cancelled
CI / publish-release (push) Has been cancelled
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-22 23:17:43 -06:00
801d3da02a feat: Add quick wins batch (#42, #43, #25, #35, #20, #36, #39)
Some checks failed
CI / style (push) Has been cancelled
CI / build-client (push) Has been cancelled
CI / build-amd64 (push) Has been cancelled
CI / build-tester (push) Has been cancelled
CI / test (cockroachdb) (push) Has been cancelled
CI / test (mariadb) (push) Has been cancelled
CI / test (postgres) (push) Has been cancelled
CI / test (sqlite) (push) Has been cancelled
CI / build-arm64 (push) Has been cancelled
CI / build-armv7 (push) Has been cancelled
CI / publish-images (push) Has been cancelled
CI / publish-release (push) Has been cancelled
- #42: Allow spools heavier than theoretical max (remove weight clamps)
- #43: Show filament custom fields on spool detail page
- #25: Sort spools by custom fields and remaining weight
- #35: Global search box for spool list
- #20: Gallery view for spools (color grid with progress bars)
- #36: Spool-level color override with ColorPicker
- #39: Cost analytics endpoint, total spent & avg cost/kg stats
- Fix: Filament select refresh after inline creation
- Fix: Parse temperatures from filament comments

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-22 23:09:42 -06:00
5f66302e73 docs: Update CLAUDE.md with full Gitea issue list (#1-#47)
Some checks failed
CI / style (push) Has been cancelled
CI / build-client (push) Has been cancelled
CI / build-amd64 (push) Has been cancelled
CI / build-tester (push) Has been cancelled
CI / test (cockroachdb) (push) Has been cancelled
CI / test (mariadb) (push) Has been cancelled
CI / test (postgres) (push) Has been cancelled
CI / test (sqlite) (push) Has been cancelled
CI / build-arm64 (push) Has been cancelled
CI / build-armv7 (push) Has been cancelled
CI / publish-images (push) Has been cancelled
CI / publish-release (push) Has been cancelled
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-22 21:04:17 -06:00
8681f7aee7 fix: Remove FK constraint from location migration for SQLite compatibility
Some checks failed
CI / style (push) Has been cancelled
CI / build-client (push) Has been cancelled
CI / build-amd64 (push) Has been cancelled
CI / build-tester (push) Has been cancelled
CI / test (cockroachdb) (push) Has been cancelled
CI / test (mariadb) (push) Has been cancelled
CI / test (postgres) (push) Has been cancelled
CI / test (sqlite) (push) Has been cancelled
CI / build-arm64 (push) Has been cancelled
CI / build-armv7 (push) Has been cancelled
CI / publish-images (push) Has been cancelled
CI / publish-release (push) Has been cancelled
Issue Manager / issue-manager (push) Has been cancelled
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 22:47:58 -06:00
3aa633df0a docs: Update CLAUDE.md with completed features #14, #15, and new features
Some checks failed
CI / style (push) Has been cancelled
CI / build-client (push) Has been cancelled
CI / build-amd64 (push) Has been cancelled
CI / build-tester (push) Has been cancelled
CI / test (cockroachdb) (push) Has been cancelled
CI / test (mariadb) (push) Has been cancelled
CI / test (postgres) (push) Has been cancelled
CI / test (sqlite) (push) Has been cancelled
CI / build-arm64 (push) Has been cancelled
CI / build-armv7 (push) Has been cancelled
CI / publish-images (push) Has been cancelled
CI / publish-release (push) Has been cancelled
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 22:41:17 -06:00
18cafc4361 feat: Add extra weight, price tracking, print history, usage analytics
Some checks failed
CI / style (push) Has been cancelled
CI / build-client (push) Has been cancelled
CI / build-amd64 (push) Has been cancelled
CI / build-tester (push) Has been cancelled
CI / test (cockroachdb) (push) Has been cancelled
CI / test (mariadb) (push) Has been cancelled
CI / test (postgres) (push) Has been cancelled
CI / test (sqlite) (push) Has been cancelled
CI / build-arm64 (push) Has been cancelled
CI / build-armv7 (push) Has been cancelled
CI / publish-images (push) Has been cancelled
CI / publish-release (push) Has been cancelled
- Extra Weight Field (#14): Track DryPods, custom holders in spool weight
  calculations. New extra_weight field on spool with DB migration.

- Price/Cost Tracking: Compute remaining_value based on remaining weight
  and price. Added column to spool list, inventory value on dashboard.

- Print History: Show print job history on spool detail page with
  collapsible table showing filename, filament used, status, dates.

- Usage Analytics: New dashboard component with time-series chart
  showing daily consumption, period selector (7/30/90 days), and
  material breakdown. New API endpoints for analytics data.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 22:38:14 -06:00
0556be9e3b docs: Update CLAUDE.md with completed features #9, #12, #13
Some checks failed
CI / style (push) Has been cancelled
CI / build-client (push) Has been cancelled
CI / build-amd64 (push) Has been cancelled
CI / build-tester (push) Has been cancelled
CI / test (cockroachdb) (push) Has been cancelled
CI / test (mariadb) (push) Has been cancelled
CI / test (postgres) (push) Has been cancelled
CI / test (sqlite) (push) Has been cancelled
CI / build-arm64 (push) Has been cancelled
CI / build-armv7 (push) Has been cancelled
CI / publish-images (push) Has been cancelled
CI / publish-release (push) Has been cancelled
Mark issues 9, 12, 13 as done and add implementation details.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 21:40:58 -06:00
02da984b6e feat: Add batch operations, dashboard redesign, hierarchical locations
Some checks failed
CI / style (push) Has been cancelled
CI / build-client (push) Has been cancelled
CI / build-amd64 (push) Has been cancelled
CI / build-tester (push) Has been cancelled
CI / test (cockroachdb) (push) Has been cancelled
CI / test (mariadb) (push) Has been cancelled
CI / test (postgres) (push) Has been cancelled
CI / test (sqlite) (push) Has been cancelled
CI / build-arm64 (push) Has been cancelled
CI / build-armv7 (push) Has been cancelled
CI / publish-images (push) Has been cancelled
CI / publish-release (push) Has been cancelled
## Batch Operations (#13)
- Add POST /spool/bulk/archive, /bulk/delete, /bulk/update endpoints
- Add row selection with checkboxes to spool list
- Add floating BatchActionBar with archive/unarchive/delete/move actions
- Add confirmation modals for all batch operations

## Dashboard Redesign (#12)
- Install recharts for pie charts
- Add AlertCards (low stock, pending jobs, needs weighing)
- Add MaterialDistributionChart (pie chart by material)
- Add QuickStats row (total spools, filaments, vendors, weight)
- Refactor home page layout with new components

## Hierarchical Locations (#9)
- Add Location model with self-referential parent relationship
- Add migration for location table and spool.location_id FK
- Add location database operations (CRUD, tree, full path)
- Add location API endpoints (list, tree, CRUD)
- Add LocationTreeSelect component with nested tree display
- Add LocationCreateModal for inline location creation
- Keep legacy location string field for backward compatibility

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 21:40:01 -06:00
27757bb949 Add About section with attribution to Settings page
Credits Donkie for original Spoolman and Claude for UX enhancements.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 21:17:25 -06:00
9bfc322f32 feat: Add color column with filter to spool list
Some checks failed
CI / style (push) Has been cancelled
CI / build-client (push) Has been cancelled
CI / build-amd64 (push) Has been cancelled
CI / build-tester (push) Has been cancelled
CI / test (cockroachdb) (push) Has been cancelled
CI / test (mariadb) (push) Has been cancelled
CI / test (postgres) (push) Has been cancelled
CI / test (sqlite) (push) Has been cancelled
CI / build-arm64 (push) Has been cancelled
CI / build-armv7 (push) Has been cancelled
CI / publish-images (push) Has been cancelled
CI / publish-release (push) Has been cancelled
Add optional color column to spool list showing filament color swatches
with multi-select filtering capability.

Backend:
- GET /api/v1/color endpoint to list distinct filament colors
- filament.color_hex query param on spool list for filtering

Frontend:
- ColorFilterColumn component renders color swatches
- useSpoolmanColors() hook fetches available colors
- Filter dropdown shows swatches with hex codes
- Column hidden by default (enable via column selector)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 21:11:18 -06:00
8fc06cdb18 Add light border to color swatches in dark mode (#34)
- Set data-theme attribute on body for CSS dark mode detection
- Add white border (30% opacity) to spool icons in dark mode
- Add --swatch-border-color CSS variable for consistent theming
- Dark/black filament colors now visible against dark backgrounds

Fixes upstream issue: https://github.com/Donkie/Spoolman/issues/597

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-16 01:05:58 -06:00
f63f70ce86 Make auth toggle a settings option instead of env var
Some checks failed
CI / style (push) Has been cancelled
CI / build-client (push) Has been cancelled
CI / build-tester (push) Has been cancelled
CI / build-amd64 (push) Has been cancelled
CI / test (cockroachdb) (push) Has been cancelled
CI / test (mariadb) (push) Has been cancelled
CI / test (postgres) (push) Has been cancelled
CI / test (sqlite) (push) Has been cancelled
CI / build-arm64 (push) Has been cancelled
CI / build-armv7 (push) Has been cancelled
CI / publish-images (push) Has been cancelled
CI / publish-release (push) Has been cancelled
Issue Manager / issue-manager (push) Has been cancelled
- Auth enabled state now stored in database setting (auth_enabled)
- Add POST /auth/toggle endpoint to enable/disable auth from UI
- Add auth toggle section to Settings > General page
- Enabling auth without users prompts to create first admin
- Disabling auth requires admin password confirmation
- Auth setting cached in memory and loaded on startup

Removes need for SPOOLMAN_AUTH_ENABLED env var

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-16 01:01:14 -06:00
e782b89929 Bump version to 0.23C.4
Some checks failed
CI / style (push) Has been cancelled
CI / build-client (push) Has been cancelled
CI / build-amd64 (push) Has been cancelled
CI / build-tester (push) Has been cancelled
CI / test (cockroachdb) (push) Has been cancelled
CI / test (mariadb) (push) Has been cancelled
CI / test (postgres) (push) Has been cancelled
CI / test (sqlite) (push) Has been cancelled
CI / build-arm64 (push) Has been cancelled
CI / build-armv7 (push) Has been cancelled
CI / publish-images (push) Has been cancelled
CI / publish-release (push) Has been cancelled
Includes:
- Issue #22: Authentication system
- Issue #38: Sticky Save/Cancel buttons

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-16 00:53:02 -06:00
a730dd1ec9 fix(auth): Fix passlib/bcrypt dependency resolution
Some checks failed
CI / style (push) Has been cancelled
CI / build-client (push) Has been cancelled
CI / build-amd64 (push) Has been cancelled
CI / build-tester (push) Has been cancelled
CI / test (cockroachdb) (push) Has been cancelled
CI / test (mariadb) (push) Has been cancelled
CI / test (postgres) (push) Has been cancelled
CI / test (sqlite) (push) Has been cancelled
CI / build-arm64 (push) Has been cancelled
CI / build-armv7 (push) Has been cancelled
CI / publish-images (push) Has been cancelled
CI / publish-release (push) Has been cancelled
2026-01-16 00:49:47 -06:00
3613e7739a feat(auth): Add optional authentication system (#22)
Some checks failed
CI / style (push) Has been cancelled
CI / build-client (push) Has been cancelled
CI / build-amd64 (push) Has been cancelled
CI / build-tester (push) Has been cancelled
CI / test (cockroachdb) (push) Has been cancelled
CI / test (mariadb) (push) Has been cancelled
CI / test (postgres) (push) Has been cancelled
CI / test (sqlite) (push) Has been cancelled
CI / build-arm64 (push) Has been cancelled
CI / build-armv7 (push) Has been cancelled
CI / publish-images (push) Has been cancelled
CI / publish-release (push) Has been cancelled
Backend:
- Add User model with username, hashed password, role, is_active
- Add passlib[bcrypt] and python-jose[cryptography] dependencies
- Create auth.py with JWT utilities, password hashing, role checking
- Add /auth endpoints: status, setup, login, me, users CRUD
- Add role-based permission system (viewer/operator/editor/admin)
- Environment: SPOOLMAN_AUTH_ENABLED, SPOOLMAN_AUTH_SECRET_KEY
- Migration for user table

Frontend:
- Add AuthProvider context with login/logout/setup
- Add LoginPage component with setup mode for first user
- Add ProtectedRoute wrapper for auth-required pages
- Add axios interceptor to attach JWT token to requests
- Add User Management tab in Settings (admin only)
- Add translation keys for auth UI

When SPOOLMAN_AUTH_ENABLED=true:
- Users must login to access the app
- First visit shows setup page to create admin account
- Role-based access control for future endpoint protection

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-16 00:15:36 -06:00
94612a6dd3 feat(ux): Sticky Save/Cancel buttons on forms (#38)
- Add sticky positioning to form action buttons (always visible at bottom)
- Add Ctrl+S / Cmd+S keyboard shortcut to save forms
- Create reusable useFormShortcuts hook
- Apply to all create forms: spool, filament, vendor

Fixes: https://github.com/Donkie/Spoolman/issues/274

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-16 00:09:06 -06:00
88747307e5 fix(spool): Add remaining_weight filter for low stock detection
Some checks failed
CI / style (push) Has been cancelled
CI / build-client (push) Has been cancelled
CI / build-amd64 (push) Has been cancelled
CI / build-tester (push) Has been cancelled
CI / test (cockroachdb) (push) Has been cancelled
CI / test (mariadb) (push) Has been cancelled
CI / test (postgres) (push) Has been cancelled
CI / test (sqlite) (push) Has been cancelled
CI / build-arm64 (push) Has been cancelled
CI / build-armv7 (push) Has been cancelled
CI / publish-images (push) Has been cancelled
CI / publish-release (push) Has been cancelled
- Add remaining_weight_lt and remaining_weight_gt filter params to spool API
- Filter uses computed formula: initial_weight - used_weight (falls back to filament.weight)
- Update home page to use queryParams instead of Refine filters
- Fixes low stock badge count on dashboard

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-15 23:44:20 -06:00
41d07f069b fix(home): correct pagination property and type annotations
Some checks failed
CI / style (push) Has been cancelled
CI / build-client (push) Has been cancelled
CI / build-amd64 (push) Has been cancelled
CI / build-tester (push) Has been cancelled
CI / test (cockroachdb) (push) Has been cancelled
CI / test (mariadb) (push) Has been cancelled
CI / test (postgres) (push) Has been cancelled
CI / test (sqlite) (push) Has been cancelled
CI / build-arm64 (push) Has been cancelled
CI / build-armv7 (push) Has been cancelled
CI / publish-images (push) Has been cancelled
CI / publish-release (push) Has been cancelled
- Use 'currentPage' instead of 'current' for pagination
- Remove explicit ISpool type on filter callback (inferred)
- Add type assertion for Table dataSource

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-15 23:35:56 -06:00
f2fecede35 fix(home): use correct refine useList API pattern
Some checks failed
CI / style (push) Has been cancelled
CI / build-client (push) Has been cancelled
CI / build-amd64 (push) Has been cancelled
CI / build-tester (push) Has been cancelled
CI / test (cockroachdb) (push) Has been cancelled
CI / test (mariadb) (push) Has been cancelled
CI / test (postgres) (push) Has been cancelled
CI / test (sqlite) (push) Has been cancelled
CI / build-arm64 (push) Has been cancelled
CI / build-armv7 (push) Has been cancelled
CI / publish-images (push) Has been cancelled
CI / publish-release (push) Has been cancelled
Fix TypeScript errors by using { result, query } destructuring:
- result.data and result.total for list data
- query.isLoading for loading state

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-15 23:34:54 -06:00
267fadfc54 feat(color): add hex code display and click-to-copy functionality
Some checks failed
CI / style (push) Has been cancelled
CI / build-client (push) Has been cancelled
CI / build-amd64 (push) Has been cancelled
CI / build-tester (push) Has been cancelled
CI / test (cockroachdb) (push) Has been cancelled
CI / test (mariadb) (push) Has been cancelled
CI / test (postgres) (push) Has been cancelled
CI / test (sqlite) (push) Has been cancelled
CI / build-arm64 (push) Has been cancelled
CI / build-armv7 (push) Has been cancelled
CI / publish-images (push) Has been cancelled
CI / publish-release (push) Has been cancelled
Enhances SpoolIcon component with optional showHex prop:
- Displays hex color code next to swatch when enabled
- Click to copy hex code to clipboard
- Shows tooltip with copy hint
- Supports both single and multi-color filaments

Enabled on detail pages:
- Filament show page
- Spool show page

Added translation keys for copy feedback messages.

Closes #33

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-15 23:33:21 -06:00
48bd516c0f feat(filament): add spool count and total remaining weight columns
Adds computed spool statistics to filament list:
- spool_count: Number of non-archived spools of this filament
- total_remaining_weight: Sum of remaining weight across all spools

Backend changes:
- Modified database/filament.py find() to compute stats via subquery
- Added fields to Filament pydantic model in api/v1/models.py
- Updated filament API endpoint to include stats in response

Frontend changes:
- Added fields to IFilament interface
- Added columns to filament list table
- Added translation keys

Closes #15

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-15 23:31:47 -06:00
0a741c9712 feat(home): redesign dashboard with spool list and low stock alerts
Replaces stat card layout with functional dashboard:
- Prominent spool count with "Add Spool" button
- Paginated list of all spools sorted by last used
- Low stock alert badge (< 100g threshold)
- Color swatch, name, material, and remaining weight columns
- Clickable rows navigate to spool detail
- Secondary stats for filaments and vendors

Closes #12

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-15 23:27:14 -06:00
2dd7c1198c Remove 3dfilamentprofiles integration (Issue #7)
Some checks failed
CI / style (push) Has been cancelled
CI / build-client (push) Has been cancelled
CI / build-amd64 (push) Has been cancelled
CI / build-tester (push) Has been cancelled
CI / test (cockroachdb) (push) Has been cancelled
CI / test (mariadb) (push) Has been cancelled
CI / test (postgres) (push) Has been cancelled
CI / test (sqlite) (push) Has been cancelled
CI / build-arm64 (push) Has been cancelled
CI / build-armv7 (push) Has been cancelled
CI / publish-images (push) Has been cancelled
CI / publish-release (push) Has been cancelled
3dfilamentprofiles.com has Vercel bot protection blocking automated requests.
Their GitHub repo only has sample data (~3 filaments).
SpoolmanDB already has 6,957+ filaments, making this unnecessary.

Removed:
- spoolman/externaldb.py: ExternalSource enum, Filament3DFP model, 3dfp sync
- client/src/utils/queryExternalDB.ts: ExternalSource enum, source field
- client/src/components/filamentImportModal.tsx: Source filter tabs

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-15 22:00:35 -06:00
0401dd680c Update Issue #7 status - 3dfilamentprofiles only has sample data
Some checks failed
CI / style (push) Has been cancelled
CI / build-client (push) Has been cancelled
CI / build-amd64 (push) Has been cancelled
CI / build-tester (push) Has been cancelled
CI / test (cockroachdb) (push) Has been cancelled
CI / test (mariadb) (push) Has been cancelled
CI / test (postgres) (push) Has been cancelled
CI / test (sqlite) (push) Has been cancelled
CI / build-arm64 (push) Has been cancelled
CI / build-armv7 (push) Has been cancelled
CI / publish-images (push) Has been cancelled
CI / publish-release (push) Has been cancelled
The integration is partial: backend/frontend exist but only sample-filaments.json
(~3 filaments) is available. Full dataset requires their parser.py or website API.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-15 21:42:57 -06:00
9365e399be Add Print Queue UI and slicer post-processing script
Some checks failed
CI / style (push) Has been cancelled
CI / build-client (push) Has been cancelled
CI / build-amd64 (push) Has been cancelled
CI / build-tester (push) Has been cancelled
CI / test (cockroachdb) (push) Has been cancelled
CI / test (mariadb) (push) Has been cancelled
CI / test (postgres) (push) Has been cancelled
CI / test (sqlite) (push) Has been cancelled
CI / build-arm64 (push) Has been cancelled
CI / build-armv7 (push) Has been cancelled
CI / publish-images (push) Has been cancelled
CI / publish-release (push) Has been cancelled
- Create Print Queue page showing pending/completed/cancelled jobs
- Add needs_weighing filter to spool API for flagged spools
- Add IPrintJob interface and needs_weighing to ISpool model
- Add slicer_post_process.py for Elegoo/Orca Slicer integration
- Add translations for print queue feature
- Bump version to 0.23C.3

The workflow:
1. Slicer post-processing script creates pending print job
2. After print, user confirms (deducts filament) or cancels (flags for weighing)
3. Spools needing weigh-in are shown in Print Queue UI

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-15 21:40:22 -06:00
4cd58ebae9 Add Print Job tracking for slicer integration
Backend for tracking print jobs from Elegoo/Orca Slicer:

- PrintJob model: tracks pending/completed/cancelled jobs
- API endpoints: create, list, complete, cancel jobs
- needs_weighing flag on Spool: for cancelled print recalibration
- Database migration for new table and column

Workflow:
1. Slicer post-processing script creates pending job
2. User completes job → auto-deducts filament
3. User cancels job → flags spool for manual weigh-in

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-15 21:10:56 -06:00
695f1adeed Update CLAUDE.md with Issue #11 (Orca Slicer) and recent features
Some checks failed
CI / style (push) Has been cancelled
CI / build-client (push) Has been cancelled
CI / build-amd64 (push) Has been cancelled
CI / build-tester (push) Has been cancelled
CI / test (cockroachdb) (push) Has been cancelled
CI / test (mariadb) (push) Has been cancelled
CI / test (postgres) (push) Has been cancelled
CI / test (sqlite) (push) Has been cancelled
CI / build-arm64 (push) Has been cancelled
CI / build-armv7 (push) Has been cancelled
CI / publish-images (push) Has been cancelled
CI / publish-release (push) Has been cancelled
Issue Manager / issue-manager (push) Has been cancelled
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-15 00:42:38 -06:00
75ddebb963 Bump version to 0.23C.2
Some checks failed
CI / style (push) Has been cancelled
CI / build-client (push) Has been cancelled
CI / build-amd64 (push) Has been cancelled
CI / build-tester (push) Has been cancelled
CI / test (cockroachdb) (push) Has been cancelled
CI / test (mariadb) (push) Has been cancelled
CI / test (postgres) (push) Has been cancelled
CI / test (sqlite) (push) Has been cancelled
CI / build-arm64 (push) Has been cancelled
CI / build-armv7 (push) Has been cancelled
CI / publish-images (push) Has been cancelled
CI / publish-release (push) Has been cancelled
- Fixed temp range display in show page and label templates
- Added prev/next navigation buttons to edit pages

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-15 00:13:00 -06:00
db8bf59b15 Fix TypeScript errors in prev/next navigation
Some checks failed
CI / style (push) Has been cancelled
CI / build-client (push) Has been cancelled
CI / build-amd64 (push) Has been cancelled
CI / build-tester (push) Has been cancelled
CI / test (cockroachdb) (push) Has been cancelled
CI / test (mariadb) (push) Has been cancelled
CI / test (postgres) (push) Has been cancelled
CI / test (sqlite) (push) Has been cancelled
CI / build-arm64 (push) Has been cancelled
CI / build-armv7 (push) Has been cancelled
CI / publish-images (push) Has been cancelled
CI / publish-release (push) Has been cancelled
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-15 00:11:01 -06:00
5c29936231 Add prev/next navigation buttons to edit pages
Some checks failed
CI / style (push) Has been cancelled
CI / build-client (push) Has been cancelled
CI / build-amd64 (push) Has been cancelled
CI / build-tester (push) Has been cancelled
CI / test (cockroachdb) (push) Has been cancelled
CI / test (mariadb) (push) Has been cancelled
CI / test (postgres) (push) Has been cancelled
CI / test (sqlite) (push) Has been cancelled
CI / build-arm64 (push) Has been cancelled
CI / build-armv7 (push) Has been cancelled
CI / publish-images (push) Has been cancelled
CI / publish-release (push) Has been cancelled
- Filament, spool, and vendor edit pages now have Prev/Next buttons
- Navigate between items without returning to the list
- Buttons are disabled at the beginning/end of the list

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-15 00:10:04 -06:00
70 changed files with 7081 additions and 520 deletions

351
CLAUDE.md
View File

@@ -122,7 +122,7 @@ Environment variables (see `.env.example`):
This is a fork with UX improvements. Issues are tracked on the Gitea instance above.
## UX Improvement Issues
## UX Improvement Issues (Gitea)
| # | Title | Status |
|---|-------|--------|
@@ -132,14 +132,55 @@ This is a fork with UX improvements. Issues are tracked on the Gitea instance ab
| 4 | Layout Max-Width | ✅ DONE |
| 5 | Temperature Ranges | ✅ DONE |
| 6 | Spool Adjustment History | ✅ DONE |
| 7 | 3dfilamentprofiles Integration | ✅ DONE |
| 8 | (see Gitea #8) | ✅ DONE |
| 9 | Hierarchical Locations (Room/Bin) | Open |
| 7 | 3dfilamentprofiles Integration | ❌ REMOVED |
| 8 | (duplicate of #7) | ✅ DONE |
| 9 | Hierarchical Locations (Room/Bin) | ✅ DONE |
| 10 | Database Import/Export | ✅ DONE |
| 11 | Theme Colors | ✅ DONE |
| 12 | Clickable Dashboard Cards | ✅ DONE |
| 13 | QR Code Size Setting | ✅ DONE |
| 14 | Parse Temps from Comments | Open |
| 11 | Slicer Integration / Print Jobs | ✅ DONE |
| 12 | Redesign Home Page Dashboard | ✅ DONE |
| 13 | Batch Operations (Multi-Select Edit) | ✅ DONE |
| 14 | Extra Weight Field (DryPods, etc.) | ✅ DONE |
| 15 | Spool Count per Filament in List | ✅ DONE |
| 16 | Filter Spools by Color | ✅ DONE |
| 17 | Image Attachments for Filaments | **Open** |
| 18 | QR Code Labels for Filaments | **Open** |
| 19 | Integration with filamentcolors.xyz | ✅ DONE |
| 20 | Gallery View for Spools (Color Grid) | ✅ DONE |
| 21 | QR Code Version/Complexity Config | **Open** |
| 23 | Reporting & Data Export | **Open** |
| 24 | NFC/RFID Tag Support | **Open** |
| 25 | Sort Spools by Custom Fields | ✅ DONE |
| 26 | Label Template Enhancements | **Open** |
| 27 | QR Scanner Camera Device Selector | **Open** |
| 28 | Mobile-Friendly UI / Responsive | **Open** |
| 29 | Table Column Customization | **Open** |
| 30 | Printer Management & Parts Inventory | **Open** |
| 31 | Special Filament Properties | **Open** |
| 32 | SpoolType Model (Cardboard/Plastic/Metal) | **Open** |
| 34 | Color Swatch Border for Dark Mode | ✅ DONE |
| 35 | Global Search Box | ✅ DONE |
| 36 | Spool-Level Overrides (Color, Price) | ✅ DONE |
| 37 | Plugin System for Extensions | **Open** |
| 39 | Usage Statistics & Cost Tracking | ✅ DONE |
| 40 | Storage vs Active Spool Status | **Open** |
| 41 | Load Cell / Scale Integration | **Open** |
| 42 | Allow Spools Heavier Than Max | ✅ DONE |
| 43 | Show Filament Fields on Spool View | ✅ DONE |
| 44 | HomeAssistant Add-On Packaging | **Open** |
| 45 | Filament Swatch Card Generator | **Open** |
| 46 | Project-Based Spool Grouping | **Open** |
| 47 | G-code Filament Usage Estimation | **Open** |
### Other Features (not tracked in Gitea)
- ✅ Theme Colors (5 options)
- ✅ Clickable Dashboard Cards
- ✅ QR Code Size Setting
- ✅ Parse Temps from Comments
- ✅ Prev/Next Navigation on Edit Pages
- ✅ Price/Cost Tracking (remaining value, inventory value)
- ✅ Print History on Spool Detail
- ✅ Usage Analytics Dashboard
- ✅ Color Column in Spool List (with filter)
### Issue #1 Complete - Inline Creation Modals
@@ -221,30 +262,10 @@ Frontend:
- `client/src/pages/spools/show.tsx` - Collapsible adjustment history table
- `client/public/locales/en/common.json` - Translation keys for history table
### Issue #7 Complete - 3dfilamentprofiles Integration
### Issue #7 REMOVED - 3dfilamentprofiles Integration
Added 3dfilamentprofiles.com as a second external database source alongside SpoolmanDB.
Backend (`spoolman/externaldb.py`):
- Added `ExternalSource` enum (SPOOLMANDB, FILAMENT_PROFILES_3D)
- Added `Filament3DFP` model for raw 3dfp data
- Added `_transform_3dfp_to_external()` to convert 3dfp format to ExternalFilament
- Updated `_sync()` to fetch from both sources and merge results
- Environment variables: `EXTERNAL_DB_3DFP_URL`, `EXTERNAL_DB_3DFP_ENABLED`
Frontend:
- `client/src/utils/queryExternalDB.ts` - Added `ExternalSource` enum, `source` field, temp range fields
- `client/src/components/filamentImportModal.tsx` - Added tabs to filter by source (All/SpoolmanDB/3DFP)
- `client/src/pages/filaments/create.tsx` - Updated import handler for temp range fields
Data mapping (3dfp → Spoolman):
- `brand_name``manufacturer`
- `color` + `material_type``name`
- `rgb``color_hex` (strip # prefix)
- `properties.temp_min/max``extruder_temp/extruder_temp_max`
- `properties.bed_temp_min/max``bed_temp/bed_temp_max`
- Diameter defaults to 1.75mm (3dfp doesn't provide)
- Density from material lookup table
**Removed** - 3dfilamentprofiles.com has Vercel bot protection blocking automated requests.
Their GitHub repo only has sample data (~3 filaments). SpoolmanDB has 6,957+ filaments, so this was unnecessary.
### Issue #10 Complete - Database Import/Export
@@ -312,6 +333,177 @@ Added explicit QR code size control:
- `client/src/pages/printing/printing.tsx` - Added `qrCodeSize` to interface
- `client/src/pages/printing/qrCodePrintingDialog.tsx` - Slider for size in mm (0=auto)
### Issue #15 Complete - Print Job Tracking / Slicer Integration
Track pending print jobs from slicer and manage filament deduction after prints complete.
**Workflow:**
1. Slicer post-processing script parses G-code for filament usage
2. Script creates pending print job in Spoolman via API
3. After print completes, user visits Print Queue UI
4. Complete: Deducts filament from spool, records adjustment history
5. Cancel: Flags spool for manual weigh-in (no automatic deduction)
**Backend:**
- `spoolman/database/models.py` - `PrintJob` model, `needs_weighing` field on Spool
- `spoolman/database/print_job.py` - CRUD operations for print jobs
- `spoolman/api/v1/print_job.py` - REST API endpoints
- `POST /print-job` - Create pending job
- `POST /print-job/{id}/complete` - Complete job, deduct filament
- `POST /print-job/{id}/cancel` - Cancel job, flag spool for weighing
- `GET /print-job` - List jobs with status filter
- `migrations/versions/2025_01_15_0400-c3d4e5f6g7h8_add_print_jobs.py`
**Frontend:**
- `client/src/pages/print-queue/index.tsx` - Print Queue UI page
- `client/src/pages/spools/model.tsx` - `IPrintJob` interface, `needs_weighing` on ISpool
**Slicer Script:**
- `scripts/slicer_post_process.py` - Elegoo/Orca Slicer post-processing script
- Parses G-code comments for filament usage (weight in grams)
- Creates pending print job via API
- Configure via env vars: `SPOOLMAN_URL`, `SPOOLMAN_SPOOL_ID`
- Or config file: `~/.config/spoolman/slicer.conf`
### Color Column in Spool List
Added optional color column to the spool list with filtering capability.
**Backend:**
- `spoolman/database/filament.py` - `find_colors()` function to get distinct color hex codes
- `spoolman/api/v1/other.py` - `GET /api/v1/color` endpoint to list all colors
- `spoolman/api/v1/spool.py` - Added `filament.color_hex` query parameter for filtering
- `spoolman/database/spool.py` - Filter implementation (supports comma-separated values)
**Frontend:**
- `client/src/components/column.tsx` - `ColorFilterColumn` component renders color swatches
- `client/src/components/otherModels.tsx` - `useSpoolmanColors()` hook to fetch colors
- `client/src/pages/spools/list.tsx` - Added color column (hidden by default)
- `client/public/locales/en/common.json` - Added "Color" translation key
**Features:**
- Color displayed as small swatch with hex tooltip
- Filter dropdown shows swatches with hex codes
- Multi-select filtering (can filter by multiple colors)
- Column hidden by default (enable via column selector)
### Issue #9 Complete - Hierarchical Locations
Storage locations can now be organized in a tree structure (Room > Shelf > Bin).
**Backend:**
- `spoolman/database/models.py` - Added `Location` model with self-referential parent
- `spoolman/database/location.py` - CRUD operations, tree building, full path resolution
- `spoolman/api/v1/location.py` - REST API for locations
- `migrations/versions/2025_01_21_0100-*.py` - Migration for location table
**Frontend:**
- `client/src/components/LocationTreeSelect.tsx` - Tree-based location picker
- `client/src/components/LocationCreateModal.tsx` - Inline location creation
**Features:**
- Hierarchical structure with parent/child relationships
- Location types: room, shelf, bin, drawer, box, other
- Legacy flat location string preserved for backward compatibility
- API endpoints: GET /location, GET /location/tree, POST/PATCH/DELETE
### Issue #12 Complete - Dashboard Redesign
Redesigned home page with better visualizations and quick stats.
**Backend:** No changes needed - uses existing endpoints.
**Frontend:**
- `client/src/pages/home/index.tsx` - Refactored layout
- `client/src/pages/home/components/AlertCards.tsx` - Low stock, pending jobs, needs weighing alerts
- `client/src/pages/home/components/MaterialDistributionChart.tsx` - Pie chart by material (recharts)
- `client/src/pages/home/components/QuickStats.tsx` - Total spools, filaments, vendors, weight
**Dependencies:**
- Added `recharts` package for charts
### Issue #13 Complete - Batch Operations
Multi-select spools with bulk archive/delete/move actions.
**Backend:**
- `spoolman/api/v1/spool.py` - Added bulk endpoints:
- POST /spool/bulk/archive
- POST /spool/bulk/delete
- POST /spool/bulk/update
**Frontend:**
- `client/src/pages/spools/list.tsx` - Added row selection
- `client/src/pages/spools/components/BatchActionBar.tsx` - Floating action bar
- `client/src/pages/spools/functions.tsx` - Bulk helper functions
**Features:**
- Checkbox selection with select-all
- Floating action bar shows when items selected
- Archive, unarchive, delete, move to location
- Confirmation modals for all destructive actions
### Issue #14 Complete - Extra Weight Field
Track extra weight from DryPods, custom spool holders, or other accessories.
**Backend:**
- `spoolman/database/models.py` - Added `extra_weight` column to Spool model
- `spoolman/database/spool.py` - Updated `measure()` to account for extra weight
- `spoolman/api/v1/spool.py` - Added `extra_weight` to create/update parameters
- `migrations/versions/2025_01_21_0200-*.py` - Migration for extra_weight column
**Frontend:**
- `client/src/pages/spools/create.tsx` - Added extra_weight form field
- `client/src/pages/spools/edit.tsx` - Added extra_weight form field
- Weight calculations updated to include extra_weight in gross weight
**Features:**
- Optional field for tracking additional weight on spool
- Automatically subtracted when measuring spool weight
- Useful for DryPods, custom spool holders, reel adapters
### Issue #15 Complete - Spool Count per Filament
Already implemented - filament list shows `spool_count` and `total_remaining_weight` columns.
### Price & Cost Tracking
Track spool purchase price and calculate remaining value.
**Backend:**
- `spoolman/api/v1/models.py` - Added `remaining_value` computed field
- Formula: `remaining_value = (remaining_weight / initial_weight) * price`
- Price falls back to filament price if not set on spool
**Frontend:**
- `client/src/pages/spools/list.tsx` - Added remaining_value column (hidden by default)
- `client/src/pages/home/components/QuickStats.tsx` - Added "Inventory Value" stat
### Print History on Spool Detail
Show print job history on the spool detail page.
**Frontend:**
- `client/src/pages/spools/show.tsx` - Added collapsible print history table
- Fetches print jobs filtered by spool_id
- Shows: Created date, Filename, Filament Used, Status, Finished date
### Usage Analytics Dashboard
Time-series charts showing filament consumption trends.
**Backend:**
- `spoolman/api/v1/other.py` - New analytics endpoints:
- `GET /analytics/usage?days=N` - Daily usage from SpoolAdjustments
- `GET /analytics/by-material?days=N` - Usage breakdown by material
**Frontend:**
- `client/src/pages/home/components/UsageAnalytics.tsx` - New component
- Area chart showing daily consumption (recharts)
- Period selector: 7, 30, or 90 days
- Material breakdown cards showing usage by filament type
---
## Deployment
@@ -331,16 +523,91 @@ ssh root@192.168.0.5 "/mnt/user/appdata/spoolman2-build/update.sh"
---
### Issue #42 Complete - Allow Spools Heavier Than Max
Removed weight validation cap that prevented spools from exceeding their theoretical initial weight.
**Backend:**
- `spoolman/database/spool.py` - Removed `max(..., 0)` clamps from create/update; when remaining > initial, adjusts initial upward
- `spoolman/api/v1/models.py` - Removed `max(..., 0)` from remaining_weight computation
### Issue #43 Complete - Show Filament Fields on Spool View
Display filament custom fields on the spool detail page.
**Frontend:**
- `client/src/pages/spools/show.tsx` - Added `filamentExtraFields` section after spool extra fields
### Issue #25 Complete - Sort by Custom Fields & Remaining Weight
**Backend:**
- `spoolman/database/spool.py` - Added `extra.{key}` sort support via outerjoin on SpoolField table
**Frontend:**
- `client/src/pages/spools/list.tsx` - Added `sorter: true` to weight/length NumberColumns
- `client/src/components/column.tsx` - Changed CustomFieldColumn to sortable with `dataId: extra.{key}`
### Issue #35 Complete - Global Search Box
**Backend:**
- `spoolman/api/v1/spool.py` - Added `q` parameter to find endpoint
- `spoolman/database/spool.py` - OR-based search across filament name, material, vendor name, location, lot_nr, comment
**Frontend:**
- `client/src/pages/spools/list.tsx` - Added `Input.Search` component, passes `q` via meta.queryParams
### Issue #20 Complete - Gallery View for Spools
Color grid view for spools with progress bars and material labels.
**Frontend:**
- `client/src/pages/spools/components/SpoolGallery.tsx` - Grid of color cards with progress bars
- `client/src/pages/spools/list.tsx` - View mode toggle (table/gallery)
### Issue #36 Complete - Spool-Level Overrides
Per-spool color override with ColorPicker (price and initial_weight already existed).
**Backend:**
- `spoolman/database/models.py` - Added `color_hex` field to Spool model
- `spoolman/api/v1/spool.py` - Added `color_hex` to create/update params
- `migrations/versions/2025_01_22_0100-g7h8i9j0k1l2_add_spool_color_hex.py`
**Frontend:**
- `client/src/pages/spools/create.tsx`, `edit.tsx` - ColorPicker form field
- All display locations updated to prefer `spool.color_hex` over `filament.color_hex`
### Issue #39 Complete - Usage Statistics & Cost Tracking
Added cost analytics endpoint and total spent / avg cost per kg stats.
**Backend:**
- `spoolman/api/v1/other.py` - Added `GET /analytics/cost` endpoint returning total_spent, avg_cost_per_kg, cost_by_material
**Frontend:**
- `client/src/pages/home/components/QuickStats.tsx` - Added "Total Spent" and "Avg Cost/kg" stat cards
- `client/src/pages/home/components/UsageAnalytics.tsx` - Added cost per material in breakdown cards
### Issue #19 Complete - filamentcolors.xyz Integration
Color lookup from filamentcolors.xyz database (2134 swatches, 317 manufacturers).
**Backend:**
- `spoolman/api/v1/other.py` - Proxy endpoints:
- `GET /filamentcolors/manufacturers` - List all manufacturers
- `GET /filamentcolors/search` - Search swatches by manufacturer, color, filament type
**Frontend:**
- `client/src/components/filamentColorLookup.tsx` - Modal with manufacturer filter + color grid
- Added search button next to ColorPicker on filament create and edit forms
- Click a swatch to apply its measured hex color
### Bug Fixes
- **Filament Select Refresh**: Fixed race condition where form value was set before options re-rendered. Uses `pendingFilamentId` state + useEffect.
- **Parse Temps from Comments**: Added regex-based temperature parser button on filament edit page.
## Known Issues / TODO
### Parse Temperature from Comments
Some filaments have temp info in comment field (e.g., "extrude 190-230 / bed 50-70") but not in the min/max fields. Could add a migration or button to parse and populate.
### Filament Select Not Refreshing After Inline Creation
When creating a filament via the inline modal on spool create page, the select dropdown doesn't immediately show the new filament with its name (shows ID instead).
### 3dfilamentprofiles Sample Data
The 3dfp GitHub repo only contains sample data (3 filaments). The full dataset may require using their website API directly.
### Future: Issue #9 - Hierarchical Locations
Add room/bin/shelf support for locations. See Gitea issue #9 for details.
### Issue #7 - 3dfilamentprofiles Integration REMOVED
The 3dfilamentprofiles.com website has Vercel bot protection that blocks automated requests. Their GitHub repo (jklewa/filament-profiles-data) only provides sample data (~3 filaments). Since SpoolmanDB already has 6,957+ filaments from major brands, the 3dfp integration was removed rather than maintaining dead code.

389
client/package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "spoolman-ui",
"version": "0.22.1",
"version": "0.23C.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "spoolman-ui",
"version": "0.22.1",
"version": "0.23C.2",
"dependencies": {
"@ant-design/v5-patch-for-react-19": "^1.0.3",
"@loadable/component": "^5.16.7",
@@ -34,6 +34,7 @@
"react-i18next": "^16.5.1",
"react-router": "^7.11.0",
"react-to-print": "^3.2.0",
"recharts": "^3.7.0",
"uuid": "^13.0.0",
"vite-plugin-svgr": "^4.5.0",
"zustand": "^5.0.9"
@@ -3359,6 +3360,48 @@
"integrity": "sha512-/RVXdLvJxLg4QKvMoM5WlwNR9ViO9z8B/qPcc+C0Sa/teJY7QG7kJ441DwzOjMYEY7GmU4dj5EcGHIkKZiQZCA==",
"license": "MIT"
},
"node_modules/@reduxjs/toolkit": {
"version": "2.11.2",
"resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.2.tgz",
"integrity": "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==",
"license": "MIT",
"dependencies": {
"@standard-schema/spec": "^1.0.0",
"@standard-schema/utils": "^0.3.0",
"immer": "^11.0.0",
"redux": "^5.0.1",
"redux-thunk": "^3.1.0",
"reselect": "^5.1.0"
},
"peerDependencies": {
"react": "^16.9.0 || ^17.0.0 || ^18 || ^19",
"react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0"
},
"peerDependenciesMeta": {
"react": {
"optional": true
},
"react-redux": {
"optional": true
}
}
},
"node_modules/@reduxjs/toolkit/node_modules/immer": {
"version": "11.1.3",
"resolved": "https://registry.npmjs.org/immer/-/immer-11.1.3.tgz",
"integrity": "sha512-6jQTc5z0KJFtr1UgFpIL3N9XSC3saRaI9PwWtzM2pSqkNGtiNkYY2OSwkOGDK2XcTRcLb1pi/aNkKZz0nxVH4Q==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/immer"
}
},
"node_modules/@reduxjs/toolkit/node_modules/redux": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
"license": "MIT"
},
"node_modules/@refinedev/antd": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/@refinedev/antd/-/antd-6.0.3.tgz",
@@ -4187,6 +4230,18 @@
"url": "https://github.com/sindresorhus/is?sponsor=1"
}
},
"node_modules/@standard-schema/spec": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
"license": "MIT"
},
"node_modules/@standard-schema/utils": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz",
"integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==",
"license": "MIT"
},
"node_modules/@surma/rollup-plugin-off-main-thread": {
"version": "2.2.3",
"resolved": "https://registry.npmjs.org/@surma/rollup-plugin-off-main-thread/-/rollup-plugin-off-main-thread-2.2.3.tgz",
@@ -4511,6 +4566,69 @@
"@babel/types": "^7.28.2"
}
},
"node_modules/@types/d3-array": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
"integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==",
"license": "MIT"
},
"node_modules/@types/d3-color": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
"integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
"license": "MIT"
},
"node_modules/@types/d3-ease": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz",
"integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==",
"license": "MIT"
},
"node_modules/@types/d3-interpolate": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
"integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
"license": "MIT",
"dependencies": {
"@types/d3-color": "*"
}
},
"node_modules/@types/d3-path": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz",
"integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==",
"license": "MIT"
},
"node_modules/@types/d3-scale": {
"version": "4.0.9",
"resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz",
"integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==",
"license": "MIT",
"dependencies": {
"@types/d3-time": "*"
}
},
"node_modules/@types/d3-shape": {
"version": "3.1.8",
"resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz",
"integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==",
"license": "MIT",
"dependencies": {
"@types/d3-path": "*"
}
},
"node_modules/@types/d3-time": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz",
"integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==",
"license": "MIT"
},
"node_modules/@types/d3-timer": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz",
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
"license": "MIT"
},
"node_modules/@types/emscripten": {
"version": "1.41.5",
"resolved": "https://registry.npmjs.org/@types/emscripten/-/emscripten-1.41.5.tgz",
@@ -4622,6 +4740,12 @@
"integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==",
"license": "MIT"
},
"node_modules/@types/use-sync-external-store": {
"version": "0.0.6",
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
"integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==",
"license": "MIT"
},
"node_modules/@types/uuid": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz",
@@ -5886,6 +6010,15 @@
"node": ">=0.10.0"
}
},
"node_modules/clsx": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
"integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
@@ -6157,6 +6290,127 @@
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"license": "MIT"
},
"node_modules/d3-array": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
"integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
"license": "ISC",
"dependencies": {
"internmap": "1 - 2"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-color": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
"integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-ease": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
"integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-format": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz",
"integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-interpolate": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
"integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
"license": "ISC",
"dependencies": {
"d3-color": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-path": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz",
"integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-scale": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
"integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
"license": "ISC",
"dependencies": {
"d3-array": "2.10.0 - 3",
"d3-format": "1 - 3",
"d3-interpolate": "1.2.0 - 3",
"d3-time": "2.1.1 - 3",
"d3-time-format": "2 - 4"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-shape": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
"integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==",
"license": "ISC",
"dependencies": {
"d3-path": "^3.1.0"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-time": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
"integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
"license": "ISC",
"dependencies": {
"d3-array": "2 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-time-format": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz",
"integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
"license": "ISC",
"dependencies": {
"d3-time": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-timer": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
"integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/data-view-buffer": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz",
@@ -6263,6 +6517,12 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/decimal.js-light": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz",
"integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==",
"license": "MIT"
},
"node_modules/decode-uri-component": {
"version": "0.2.2",
"resolved": "https://registry.npmjs.org/decode-uri-component/-/decode-uri-component-0.2.2.tgz",
@@ -6772,6 +7032,16 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/es-toolkit": {
"version": "1.44.0",
"resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.44.0.tgz",
"integrity": "sha512-6penXeZalaV88MM3cGkFZZfOoLGWshWWfdy0tWw/RlVVyhvMaWSBTOvXNeiW3e5FwdS5ePW0LGEu17zT139ktg==",
"license": "MIT",
"workspaces": [
"docs",
"benchmarks"
]
},
"node_modules/esbuild": {
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz",
@@ -8439,6 +8709,16 @@
"node": ">= 4"
}
},
"node_modules/immer": {
"version": "10.2.0",
"resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz",
"integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/immer"
}
},
"node_modules/import-fresh": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
@@ -8577,6 +8857,15 @@
"node": ">= 0.4"
}
},
"node_modules/internmap": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
"integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/ipaddr.js": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
@@ -11919,6 +12208,29 @@
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
"license": "MIT"
},
"node_modules/react-redux": {
"version": "9.2.0",
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
"integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==",
"license": "MIT",
"dependencies": {
"@types/use-sync-external-store": "^0.0.6",
"use-sync-external-store": "^1.4.0"
},
"peerDependencies": {
"@types/react": "^18.2.25 || ^19",
"react": "^18.0 || ^19",
"redux": "^5.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"redux": {
"optional": true
}
}
},
"node_modules/react-refresh": {
"version": "0.18.0",
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz",
@@ -12020,6 +12332,42 @@
"node": ">= 4"
}
},
"node_modules/recharts": {
"version": "3.7.0",
"resolved": "https://registry.npmjs.org/recharts/-/recharts-3.7.0.tgz",
"integrity": "sha512-l2VCsy3XXeraxIID9fx23eCb6iCBsxUQDnE8tWm6DFdszVAO7WVY/ChAD9wVit01y6B2PMupYiMmQwhgPHc9Ew==",
"license": "MIT",
"workspaces": [
"www"
],
"dependencies": {
"@reduxjs/toolkit": "1.x.x || 2.x.x",
"clsx": "^2.1.1",
"decimal.js-light": "^2.5.1",
"es-toolkit": "^1.39.3",
"eventemitter3": "^5.0.1",
"immer": "^10.1.1",
"react-redux": "8.x.x || 9.x.x",
"reselect": "5.1.1",
"tiny-invariant": "^1.3.3",
"use-sync-external-store": "^1.2.2",
"victory-vendor": "^37.0.2"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/recharts/node_modules/eventemitter3": {
"version": "5.0.4",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz",
"integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==",
"license": "MIT"
},
"node_modules/redeyed": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/redeyed/-/redeyed-2.1.1.tgz",
@@ -12039,6 +12387,15 @@
"@babel/runtime": "^7.9.2"
}
},
"node_modules/redux-thunk": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz",
"integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==",
"license": "MIT",
"peerDependencies": {
"redux": "^5.0.0"
}
},
"node_modules/reflect.getprototypeof": {
"version": "1.0.10",
"resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
@@ -12207,6 +12564,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/reselect": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz",
"integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==",
"license": "MIT"
},
"node_modules/resize-observer-polyfill": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz",
@@ -14129,6 +14492,28 @@
"url": "https://opencollective.com/unified"
}
},
"node_modules/victory-vendor": {
"version": "37.3.6",
"resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz",
"integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==",
"license": "MIT AND ISC",
"dependencies": {
"@types/d3-array": "^3.0.3",
"@types/d3-ease": "^3.0.0",
"@types/d3-interpolate": "^3.0.1",
"@types/d3-scale": "^4.0.2",
"@types/d3-shape": "^3.1.0",
"@types/d3-time": "^3.0.0",
"@types/d3-timer": "^3.0.0",
"d3-array": "^3.1.6",
"d3-ease": "^3.0.1",
"d3-interpolate": "^3.0.1",
"d3-scale": "^4.0.2",
"d3-shape": "^3.1.0",
"d3-time": "^3.0.0",
"d3-timer": "^3.0.1"
}
},
"node_modules/vite": {
"version": "7.3.0",
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.0.tgz",

View File

@@ -1,6 +1,6 @@
{
"name": "spoolman-ui",
"version": "0.23C.1",
"version": "0.23C.2",
"engines": {
"node": "20.x"
},
@@ -33,6 +33,7 @@
"react-i18next": "^16.5.1",
"react-router": "^7.11.0",
"react-to-print": "^3.2.0",
"recharts": "^3.7.0",
"uuid": "^13.0.0",
"vite-plugin-svgr": "^4.5.0",
"zustand": "^5.0.9"

View File

@@ -27,11 +27,31 @@
"unArchive": "Unarchive",
"hideArchived": "Hide Archived",
"showArchived": "Show Archived",
"prev": "Prev",
"next": "Next",
"yes": "Yes",
"no": "No",
"notAccessTitle": "You don't have permission to access",
"hideColumns": "Hide Columns",
"clearFilters": "Clear Filters"
"clearFilters": "Clear Filters",
"galleryView": "Gallery",
"tableView": "Table",
"viewAll": "View All"
},
"warnWhenUnsavedChanges": "Are you sure you want to leave? You have unsaved changes.",
"batch": {
"selected": "{{count}} selected",
"clear_selection": "Clear",
"archive_confirm": "Archive Spools",
"archive_confirm_content": "Are you sure you want to archive {{count}} spools?",
"unarchive_confirm": "Unarchive Spools",
"unarchive_confirm_content": "Are you sure you want to unarchive {{count}} spools?",
"delete_confirm": "Delete Spools",
"delete_confirm_content": "Are you sure you want to delete {{count}} spools? This action cannot be undone.",
"move": "Move",
"move_title": "Move Spools to Location",
"move_description": "Select a location for the {{count}} selected spools."
},
"notifications": {
"success": "Successful",
"error": "Error (status code: {{statusCode}})",
@@ -47,6 +67,11 @@
"validationError": "Validation error: {{error}}"
},
"kofi": "Tip me on Ko-fi",
"color": {
"click_to_copy": "Click to copy",
"copied": "Copied {{color}} to clipboard",
"copy_failed": "Failed to copy to clipboard"
},
"loading": "Loading",
"version": "Version",
"unknown": "Unknown",
@@ -156,6 +181,7 @@
},
"spool": {
"spool": "Spools",
"search_placeholder": "Search by name, material, vendor, location...",
"fields": {
"id": "ID",
"filament_name": "Filament",
@@ -163,15 +189,19 @@
"filament_internal": "Internal",
"filament_external": "External",
"price": "Price",
"color": "Color",
"material": "Material",
"weight_to_use": "Weight",
"used_weight": "Used Weight",
"remaining_weight": "Remaining Weight",
"remaining_value": "Remaining Value",
"measured_weight": "Measured Weight",
"used_length": "Used Length",
"remaining_length": "Remaining Length",
"initial_weight": "Initial Weight",
"spool_weight": "Empty Weight",
"extra_weight": "Extra Weight",
"color_hex": "Color Override",
"location": "Location",
"lot_nr": "Lot Nr",
"first_used": "First Used",
@@ -179,8 +209,11 @@
"registered": "Registered",
"comment": "Comment",
"archived": "Archived",
"filament_fields": "Filament Custom Fields",
"adjustment_history": "Adjustment History",
"show_history": "Show History",
"print_history": "Print History",
"show_print_history": "Show Print History",
"adjustment_timestamp": "Date/Time",
"adjustment_type": "Type",
"adjustment_type_weight": "Weight",
@@ -196,6 +229,8 @@
"measured_weight": "How much the filament and spool weigh.",
"initial_weight": "The initial weight of filament on the spool (net weight). Will use the weight from the filament object if not set.",
"spool_weight": "The weight of the spool when it is empty. Leave empty to use the value from the filament or the manufacturer.",
"extra_weight": "Additional weight to account for when measuring, such as DryPods, custom spool holders, or other accessories.",
"color_hex": "Override the filament color for this specific spool.",
"location": "Where the spool is located if you have multiple locations where you store your spools.",
"lot_nr": "Manufacturer's lot number. Can be used to ensure a print has consistent color if multiple spools are used.",
"external_filament": "You have selected a filament from the external database. A filament object (and possibly a manufacturer object) will be created automatically when you create this spool. This can create duplicate filament objects if you have already created a filament object for this filament."
@@ -228,6 +263,40 @@
"last_used": "Last used {{date}}"
}
},
"location": {
"location": "Location",
"locations": "Locations",
"create": "Create Location",
"titles": {
"create": "Create Location",
"edit": "Edit Location",
"list": "Locations"
},
"fields": {
"name": "Name",
"name_required": "Location name is required",
"name_placeholder": "e.g., Shelf A",
"parent": "Parent Location",
"parent_placeholder": "Select parent (optional)",
"location_type": "Type",
"location_type_placeholder": "Select type (optional)",
"description": "Description",
"spool_count": "Spools",
"full_path": "Full Path"
},
"types": {
"room": "Room",
"shelf": "Shelf",
"bin": "Bin",
"drawer": "Drawer",
"box": "Box",
"other": "Other"
},
"messages": {
"delete_confirm": "Are you sure you want to delete this location?",
"has_children": "This location has child locations"
}
},
"filament": {
"filament": "Filaments",
"fields": {
@@ -255,7 +324,9 @@
"longitudinal": "Longitudinal",
"external_id": "External ID",
"spools": "Show Spools",
"not_set": "Not Set"
"not_set": "Not Set",
"spool_count": "Spools",
"total_remaining_weight": "Total Remaining"
},
"fields_help": {
"name": "Filament name, to distinguish this filament type among others from the same manufacturer. Should contain the color for example.",
@@ -284,10 +355,21 @@
"created_success": "Created filament \"{{name}}\"",
"vendor_placeholder": "Select a manufacturer",
"name_placeholder": "E.g. Matte Black",
"material_placeholder": "PLA, PETG, ABS, etc."
"material_placeholder": "PLA, PETG, ABS, etc.",
"parse_temps": "Parse temps from comment",
"temps_parsed": "Temperature fields updated from comment",
"no_temps_found": "No temperature patterns found in comment"
},
"buttons": {
"add_spool": "Add Spool"
},
"color_lookup": {
"button": "Find color from filamentcolors.xyz",
"title": "Find Color - filamentcolors.xyz",
"manufacturer": "Manufacturer",
"color_search": "Search color name...",
"results": "results",
"showing_page": "page {{page}}"
}
},
"vendor": {
@@ -317,10 +399,54 @@
"name_required": "Name is required"
}
},
"print_job": {
"fields": {
"created": "Created",
"filename": "Filename",
"filament_used": "Filament Used",
"status": "Status",
"finished": "Finished"
},
"status": {
"pending": "Pending",
"completed": "Completed",
"cancelled": "Cancelled"
}
},
"home": {
"home": "Home",
"welcome": "Welcome to your Spoolman instance!",
"description": "It looks like you haven't added any spools yet. See the <helpPageLink>Help page</helpPageLink> for help getting started."
"description": "It looks like you haven't added any spools yet. See the <helpPageLink>Help page</helpPageLink> for help getting started.",
"all_spools": "All Spools",
"low_stock_alert": "{{count}} spools below {{threshold}}g",
"low_stock_warning": "Low stock",
"alerts": {
"low_stock": "Low Stock",
"pending_jobs": "Pending Jobs",
"needs_weighing": "Needs Weighing"
},
"charts": {
"material_distribution": "Material Distribution"
},
"quick_stats": {
"total_spools": "Total Spools",
"total_filaments": "Filament Types",
"total_vendors": "Vendors",
"total_weight": "Total Remaining",
"total_value": "Inventory Value",
"total_spent": "Total Spent",
"avg_cost_per_kg": "Avg Cost/kg"
},
"analytics": {
"title": "Usage Analytics",
"7_days": "7 Days",
"30_days": "30 Days",
"90_days": "90 Days",
"total_used": "Total used: {{weight}} kg",
"used": "Used",
"spent": "Spent",
"by_material": "Usage by Material"
}
},
"help": {
"help": "Help",
@@ -375,6 +501,22 @@
"replace_warning_title": "Replace all data?",
"replace_warning": "This will DELETE all existing data and replace it with the backup. This cannot be undone!"
},
"auth": {
"title": "Authentication",
"description": "Require users to log in to access Spoolman. Once enabled, you'll be redirected to the login page.",
"status_enabled": "Authentication is enabled",
"status_disabled": "Authentication is disabled",
"setup_title": "Enable Authentication",
"setup_description": "Create an admin account to enable authentication. This user will have full access to manage Spoolman.",
"enable_button": "Create Admin & Enable",
"disable_title": "Disable Authentication",
"disable_description": "Enter an admin password to confirm disabling authentication. Anyone will be able to access Spoolman without logging in.",
"disable_button": "Disable Authentication",
"admin_password": "Admin Password",
"password_required": "Password is required",
"enabled_success": "Authentication enabled",
"disabled_success": "Authentication disabled"
},
"extra_fields": {
"tab": "Extra Fields",
"description": "<p>Here you can add extra custom fields to your entities.</p><p>Once a field is added, you can not change its key or type, and for choice type fields you can not remove choices or change the multi choice state. If you remove a field, the associated data for all entities will be deleted.</p><p>The key is what other programs read/write the data as, so if your custom field is supposed to integrate with a third-party program, make sure to set it correctly. Default value is only applied to new items.</p><p>Extra fields can not be sorted or filtered in the table views.</p>",
@@ -405,6 +547,39 @@
"key_not_changed": "Please change the key to something else.",
"delete_confirm": "Delete field {{name}}?",
"delete_confirm_description": "This will delete the field and all associated data for all entities."
},
"users": {
"tab": "Users",
"title": "User Management",
"add": "Add User",
"edit": "Edit User",
"username": "Username",
"password": "Password",
"role": "Role",
"status": "Status",
"actions": "Actions",
"active": "Active",
"inactive": "Inactive",
"you": "You",
"created": "User created successfully",
"updated": "User updated successfully",
"deleted": "User deleted successfully",
"delete_confirm": "Are you sure you want to delete this user?",
"username_required": "Username is required",
"username_min": "Username must be at least 3 characters",
"password_required": "Password is required",
"password_min": "Password must be at least 6 characters",
"password_change_hint": "Leave blank to keep current password",
"leave_blank": "Leave blank to keep current",
"role_viewer": "Viewer - Can only view data",
"role_operator": "Operator - Can view and record usage",
"role_editor": "Editor - Can view, use, create, and edit",
"role_admin": "Admin - Full access including user management",
"auth_disabled": "Authentication is not enabled",
"auth_disabled_hint": "Enable authentication in Settings > General to manage users",
"admin_required": "Admin Access Required",
"admin_required_hint": "Only administrators can manage users",
"logout": "Log Out"
}
},
"documentTitle": {
@@ -446,5 +621,53 @@
"new_location": "New Location",
"no_location": "No Location",
"no_locations_help": "This page lets you organize your spools in locations, add some spools to get started!"
},
"print-queue": {
"print-queue": "Print Queue"
},
"print_queue": {
"title": "Print Queue",
"pending_jobs": "Pending Jobs",
"needs_weighing": "Spools Needing Weigh-In",
"recent_history": "Recent History",
"no_pending_jobs": "No pending print jobs",
"no_history": "No completed or cancelled jobs yet",
"fields": {
"filename": "Filename",
"spool": "Spool",
"filament_used": "Filament Used",
"created": "Created",
"finished": "Finished",
"status": "Status"
},
"status": {
"pending": "Pending",
"completed": "Completed",
"cancelled": "Cancelled"
},
"stats": {
"pending": "Pending",
"needs_weighing": "Need Weigh-In",
"completed_today": "Completed Today",
"total_jobs": "Total Jobs"
},
"complete": "Complete",
"cancel": "Cancel",
"cancel_job": "Cancel Job",
"weigh_in": "Weigh In",
"clear_flag": "Clear Flag",
"clear_flag_tooltip": "Mark as weighed without updating weight",
"confirm_complete": "Complete Print Job?",
"confirm_complete_description": "This will deduct {{grams}}g from the spool.",
"confirm_cancel": "Cancel Print Job?",
"confirm_cancel_description": "The spool will be flagged for manual weigh-in since filament usage is unknown.",
"confirm_delete": "Delete Print Job?",
"confirm_delete_description": "This will permanently remove this job from history.",
"job_completed": "Print job completed",
"job_complete_error": "Failed to complete print job",
"job_cancelled": "Print job cancelled - spool flagged for weigh-in",
"job_cancel_error": "Failed to cancel print job",
"job_deleted": "Print job deleted",
"weighing_cleared": "Weigh-in flag cleared"
}
}

View File

@@ -9,6 +9,7 @@ import {
FileOutlined,
HighlightOutlined,
HomeOutlined,
PrinterOutlined,
QuestionOutlined,
TableOutlined,
ToolOutlined,
@@ -26,10 +27,16 @@ import { Favicon } from "./components/favicon";
import { SpoolmanLayout } from "./components/layout";
import liveProvider from "./components/liveProvider";
import SpoolmanNotificationProvider from "./components/notificationProvider";
import { ProtectedRoute } from "./components/protectedRoute";
import { AuthProvider, useAuth } from "./contexts/auth";
import { ColorModeContextProvider } from "./contexts/color-mode";
import { languages } from "./i18n";
import { setupAxiosAuth } from "./utils/axiosAuth";
import { getAPIURL, getBasePath } from "./utils/url";
// Setup axios auth interceptors once at module load
setupAxiosAuth();
interface ResourcePageProps {
resource: "spools" | "filaments" | "vendors";
page: "list" | "create" | "edit" | "show";
@@ -53,6 +60,11 @@ const LoadablePage = loadable((props: LoadablePageProps) => import(`./pages/${pr
cacheKey: (props: LoadablePageProps) => `page-${props.name}`,
});
// Lazy load login page
const LoginPage = loadable(() => import("./pages/login/index"), {
fallback: <div>Loading...</div>,
});
function App() {
const { t, i18n } = useTranslation();
@@ -88,6 +100,7 @@ function App() {
return (
<BrowserRouter basename={getBasePath() + "/"}>
<AuthProvider>
<RefineKbarProvider>
<ColorModeContextProvider>
<ConfigProvider locale={antdLocale}>
@@ -142,6 +155,14 @@ function App() {
icon: <UserOutlined />,
},
},
{
name: "print-queue",
list: "/print-queue",
meta: {
canDelete: false,
icon: <PrinterOutlined />,
},
},
{
name: "locations",
list: "/locations",
@@ -174,11 +195,17 @@ function App() {
}}
>
<Routes>
{/* Login route - outside protected area */}
<Route path="/login" element={<LoginPage />} />
{/* Protected routes */}
<Route
element={
<ProtectedRoute>
<SpoolmanLayout>
<Outlet />
</SpoolmanLayout>
</ProtectedRoute>
}
>
<Route index element={<LoadablePage name="home" />} />
@@ -225,6 +252,7 @@ function App() {
<Route path="/settings/*" element={<LoadablePage name="settings" />} />
<Route path="/help" element={<LoadablePage name="help" />} />
<Route path="/locations" element={<LoadablePage name="locations" />} />
<Route path="/print-queue" element={<LoadablePage name="print-queue" />} />
<Route path="*" element={<ErrorComponent />} />
</Route>
</Routes>
@@ -238,6 +266,7 @@ function App() {
</ConfigProvider>
</ColorModeContextProvider>
</RefineKbarProvider>
</AuthProvider>
</BrowserRouter>
);
}

View File

@@ -0,0 +1,143 @@
import { useTranslate } from "@refinedev/core";
import { Form, Input, Modal, Select, message } from "antd";
import { useQuery } from "@tanstack/react-query";
import { useState } from "react";
import { getAPIURL } from "../utils/url";
interface LocationTreeNode {
id: number;
name: string;
location_type?: string;
children: LocationTreeNode[];
}
interface LocationCreateModalProps {
isOpen: boolean;
onClose: () => void;
onSuccess: () => void;
parentId?: number;
}
const LOCATION_TYPES = ["room", "shelf", "bin", "drawer", "box", "other"];
function flattenTree(nodes: LocationTreeNode[], prefix = ""): { value: number; label: string }[] {
const result: { value: number; label: string }[] = [];
for (const node of nodes) {
const label = prefix ? `${prefix} > ${node.name}` : node.name;
result.push({ value: node.id, label });
if (node.children.length > 0) {
result.push(...flattenTree(node.children, label));
}
}
return result;
}
export function LocationCreateModal({
isOpen,
onClose,
onSuccess,
parentId,
}: LocationCreateModalProps) {
const t = useTranslate();
const [form] = Form.useForm();
const [loading, setLoading] = useState(false);
const [messageApi, contextHolder] = message.useMessage();
const { data: treeData } = useQuery<LocationTreeNode[]>({
queryKey: ["locations", "tree"],
queryFn: async () => {
const response = await fetch(getAPIURL() + "/location/tree");
if (!response.ok) {
throw new Error("Failed to fetch location tree");
}
return response.json();
},
enabled: isOpen,
});
const parentOptions = treeData ? flattenTree(treeData) : [];
const handleSubmit = async (values: { name: string; parent_id?: number; location_type?: string }) => {
setLoading(true);
try {
const response = await fetch(getAPIURL() + "/location", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(values),
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.message || "Failed to create location");
}
messageApi.success(t("notifications.createSuccess", { resource: t("location.location") }));
form.resetFields();
onSuccess();
} catch (err) {
messageApi.error(err instanceof Error ? err.message : "Failed to create location");
} finally {
setLoading(false);
}
};
return (
<>
{contextHolder}
<Modal
title={t("location.titles.create")}
open={isOpen}
onOk={() => form.submit()}
onCancel={() => {
form.resetFields();
onClose();
}}
confirmLoading={loading}
>
<Form
form={form}
layout="vertical"
onFinish={handleSubmit}
initialValues={{ parent_id: parentId }}
>
<Form.Item
name="name"
label={t("location.fields.name")}
rules={[{ required: true, message: t("location.fields.name_required") }]}
>
<Input placeholder={t("location.fields.name_placeholder")} />
</Form.Item>
<Form.Item
name="parent_id"
label={t("location.fields.parent")}
>
<Select
allowClear
showSearch
placeholder={t("location.fields.parent_placeholder")}
options={parentOptions}
filterOption={(input, option) =>
(option?.label as string).toLowerCase().includes(input.toLowerCase())
}
/>
</Form.Item>
<Form.Item
name="location_type"
label={t("location.fields.location_type")}
>
<Select
allowClear
placeholder={t("location.fields.location_type_placeholder")}
options={LOCATION_TYPES.map((type) => ({
value: type,
label: t(`location.types.${type}`),
}))}
/>
</Form.Item>
</Form>
</Modal>
</>
);
}

View File

@@ -0,0 +1,110 @@
import { PlusOutlined } from "@ant-design/icons";
import { useTranslate } from "@refinedev/core";
import { TreeSelect, Divider, Button, Spin } from "antd";
import { useQuery } from "@tanstack/react-query";
import { useState } from "react";
import { getAPIURL } from "../utils/url";
import { LocationCreateModal } from "./LocationCreateModal";
interface LocationTreeNode {
id: number;
name: string;
location_type?: string;
description?: string;
children: LocationTreeNode[];
}
interface TreeDataNode {
value: number;
title: string;
children?: TreeDataNode[];
}
function transformTree(nodes: LocationTreeNode[]): TreeDataNode[] {
return nodes.map((node) => ({
value: node.id,
title: node.location_type ? `${node.name} (${node.location_type})` : node.name,
children: node.children.length > 0 ? transformTree(node.children) : undefined,
}));
}
interface LocationTreeSelectProps {
value?: number;
onChange?: (value: number | undefined) => void;
allowCreate?: boolean;
placeholder?: string;
style?: React.CSSProperties;
}
export function LocationTreeSelect({
value,
onChange,
allowCreate = true,
placeholder,
style,
}: LocationTreeSelectProps) {
const t = useTranslate();
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
const { data, isLoading, refetch } = useQuery<LocationTreeNode[]>({
queryKey: ["locations", "tree"],
queryFn: async () => {
const response = await fetch(getAPIURL() + "/location/tree");
if (!response.ok) {
throw new Error("Failed to fetch location tree");
}
return response.json();
},
});
const treeData = data ? transformTree(data) : [];
const handleCreateSuccess = () => {
setIsCreateModalOpen(false);
refetch();
};
return (
<>
<TreeSelect
style={style}
value={value}
onChange={onChange}
treeData={treeData}
placeholder={placeholder ?? t("spool.fields.location")}
allowClear
showSearch
treeDefaultExpandAll
loading={isLoading}
notFoundContent={isLoading ? <Spin size="small" /> : undefined}
dropdownRender={(menu) => (
<>
{menu}
{allowCreate && (
<>
<Divider style={{ margin: "8px 0" }} />
<Button
type="text"
icon={<PlusOutlined />}
onClick={() => setIsCreateModalOpen(true)}
style={{ width: "100%" }}
>
{t("location.create")}
</Button>
</>
)}
</>
)}
filterTreeNode={(input, treeNode) =>
(treeNode.title as string).toLowerCase().includes(input.toLowerCase())
}
/>
<LocationCreateModal
isOpen={isCreateModalOpen}
onClose={() => setIsCreateModalOpen(false)}
onSuccess={handleCreateSuccess}
/>
</>
);
}

View File

@@ -220,6 +220,52 @@ export function FilteredQueryColumn<Obj extends Entity>(props: FilteredQueryColu
return Column({ ...props, filters, filteredValue, onFilterDropdownOpen, loadingFilters: query.isLoading });
}
interface ColorFilterColumnProps<Obj extends Entity> extends BaseColumnProps<Obj> {
filterValueQuery: UseQueryResult<ColumnFilterItem[], unknown>;
colorGetter: (record: Obj) => string | undefined;
}
export function ColorFilterColumn<Obj extends Entity>(props: ColorFilterColumnProps<Obj>) {
const query = props.filterValueQuery;
let filters: ColumnFilterItem[] = [];
if (query.data) {
filters = query.data;
}
const typedFilters = typeFilters<Obj>(props.tableState.filters);
const filteredValue = getFiltersForField(typedFilters, props.dataId ?? (props.id as keyof Obj));
const onFilterDropdownOpen = () => {
query.refetch();
};
return Column({
...props,
filters,
filteredValue,
onFilterDropdownOpen,
loadingFilters: query.isLoading,
allowMultipleFilters: true,
render: (_: unknown, record: Obj) => {
const colorHex = props.colorGetter(record);
if (!colorHex) return null;
return (
<div
style={{
width: "1.5em",
height: "1.5em",
backgroundColor: "#" + colorHex,
borderRadius: "3px",
border: "1px solid var(--swatch-border-color, rgba(68, 68, 68, 0.3))",
}}
title={"#" + colorHex}
/>
);
},
});
}
interface NumberColumnProps<Obj extends Entity> extends BaseColumnProps<Obj> {
unit: string;
maxDecimals?: number;
@@ -394,8 +440,9 @@ export function CustomFieldColumn<Obj extends Entity>(props: Omit<BaseColumnProp
const commonProps = {
...props,
id: ["extra", field.key],
dataId: `extra.${field.key}` as keyof Obj & string,
title: field.name,
sorter: false,
sorter: true,
transform: (value: unknown) => {
if (value === null || value === undefined) {
return undefined;

View File

@@ -0,0 +1,166 @@
import { SearchOutlined } from "@ant-design/icons";
import { useTranslate } from "@refinedev/core";
import { useQuery } from "@tanstack/react-query";
import { Button, Input, Modal, Select, Space, Spin, Tooltip } from "antd";
import { useState } from "react";
import { getAPIURL } from "../utils/url";
interface Swatch {
id: number;
color_name: string;
hex_color: string | null;
manufacturer_name: string;
filament_type: string;
image_front: string | null;
}
interface Manufacturer {
id: number;
name: string;
}
interface FilamentColorLookupProps {
onSelect: (hexColor: string) => void;
}
export function FilamentColorLookup({ onSelect }: FilamentColorLookupProps) {
const t = useTranslate();
const [open, setOpen] = useState(false);
const [manufacturer, setManufacturer] = useState<string | undefined>();
const [colorSearch, setColorSearch] = useState("");
const [page, setPage] = useState(1);
const { data: manufacturers, isLoading: mfrsLoading } = useQuery<Manufacturer[]>({
queryKey: ["filamentcolors", "manufacturers"],
queryFn: async () => {
const res = await fetch(`${getAPIURL()}/filamentcolors/manufacturers`);
return res.json();
},
enabled: open,
});
const { data: searchResult, isLoading: searchLoading } = useQuery<{ count: number; swatches: Swatch[] }>({
queryKey: ["filamentcolors", "search", manufacturer, colorSearch, page],
queryFn: async () => {
const params = new URLSearchParams();
if (manufacturer) params.set("manufacturer", manufacturer);
if (colorSearch) params.set("color", colorSearch);
params.set("limit", "50");
params.set("page", String(page));
const res = await fetch(`${getAPIURL()}/filamentcolors/search?${params}`);
return res.json();
},
enabled: open,
});
const handleSelect = (hex: string) => {
onSelect(hex);
setOpen(false);
};
return (
<>
<Tooltip title={t("filament.color_lookup.button")}>
<Button icon={<SearchOutlined />} onClick={() => setOpen(true)} size="small" />
</Tooltip>
<Modal
title={t("filament.color_lookup.title")}
open={open}
onCancel={() => setOpen(false)}
footer={null}
width={700}
>
<Space.Compact style={{ width: "100%", marginBottom: 12 }}>
<Select
showSearch
allowClear
placeholder={t("filament.color_lookup.manufacturer")}
style={{ width: "50%" }}
loading={mfrsLoading}
value={manufacturer}
onChange={(v) => { setManufacturer(v); setPage(1); }}
options={manufacturers?.map((m) => ({ label: m.name, value: m.name }))}
filterOption={(input, option) =>
(option?.label as string)?.toLowerCase().includes(input.toLowerCase())
}
/>
<Input
placeholder={t("filament.color_lookup.color_search")}
value={colorSearch}
onChange={(e) => { setColorSearch(e.target.value); setPage(1); }}
allowClear
style={{ width: "50%" }}
/>
</Space.Compact>
{searchLoading ? (
<div style={{ textAlign: "center", padding: 40 }}><Spin /></div>
) : (
<>
<div style={{ marginBottom: 8, fontSize: 12, color: "#888" }}>
{searchResult?.count ?? 0} {t("filament.color_lookup.results")}
{searchResult && searchResult.count > 50 && ` (${t("filament.color_lookup.showing_page", { page })})`}
</div>
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(auto-fill, minmax(100px, 1fr))",
gap: 8,
maxHeight: 400,
overflowY: "auto",
}}
>
{searchResult?.swatches.map((s) => (
<Tooltip
key={s.id}
title={`${s.manufacturer_name} - ${s.color_name} (${s.filament_type}) #${s.hex_color ?? "?"}`}
>
<div
onClick={() => s.hex_color && handleSelect(s.hex_color)}
style={{
cursor: s.hex_color ? "pointer" : "default",
border: "1px solid #d9d9d9",
borderRadius: 6,
padding: 6,
textAlign: "center",
opacity: s.hex_color ? 1 : 0.4,
}}
>
<div
style={{
width: "100%",
aspectRatio: "1",
borderRadius: 4,
backgroundColor: s.hex_color ? `#${s.hex_color}` : "#ccc",
marginBottom: 4,
}}
/>
<div style={{ fontSize: 10, lineHeight: 1.2, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
{s.color_name}
</div>
<div style={{ fontSize: 9, color: "#888", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
{s.filament_type}
</div>
</div>
</Tooltip>
))}
</div>
{searchResult && searchResult.count > 50 && (
<div style={{ marginTop: 12, textAlign: "center" }}>
<Button size="small" disabled={page <= 1} onClick={() => setPage((p) => p - 1)}>
{t("buttons.prev")}
</Button>
<span style={{ margin: "0 12px", fontSize: 12 }}>
{page} / {Math.ceil(searchResult.count / 50)}
</span>
<Button size="small" disabled={page * 50 >= searchResult.count} onClick={() => setPage((p) => p + 1)}>
{t("buttons.next")}
</Button>
</div>
)}
</>
)}
</Modal>
</>
);
}

View File

@@ -1,10 +1,10 @@
import { useTranslate } from "@refinedev/core";
import { Form, Modal, Select, Tabs } from "antd";
import { useMemo, useState } from "react";
import { Form, Modal, Select } from "antd";
import { useMemo } from "react";
import { Trans } from "react-i18next";
import { formatFilamentLabel } from "../pages/spools/functions";
import { searchMatches } from "../utils/filtering";
import { ExternalFilament, ExternalSource, useGetExternalDBFilaments } from "../utils/queryExternalDB";
import { ExternalFilament, useGetExternalDBFilaments } from "../utils/queryExternalDB";
export function FilamentImportModal(props: {
isOpen: boolean;
@@ -13,25 +13,14 @@ export function FilamentImportModal(props: {
}) {
const [form] = Form.useForm();
const t = useTranslate();
const [selectedSource, setSelectedSource] = useState<ExternalSource | "all">("all");
const externalFilaments = useGetExternalDBFilaments();
// Filter and format filaments based on selected source
// Format filaments for the select dropdown
const filamentOptions = useMemo(() => {
const filtered = externalFilaments.data?.filter((item) => {
if (selectedSource === "all") return true;
return item.source === selectedSource;
}) ?? [];
const options = filtered.map((item) => {
// Add source indicator to label if showing all
const sourceIndicator = selectedSource === "all" && item.source === ExternalSource.FILAMENT_PROFILES_3D
? "[3DFP] "
: "";
const options = (externalFilaments.data ?? []).map((item) => {
return {
label: sourceIndicator + formatFilamentLabel(
label: formatFilamentLabel(
item.name,
item.diameter,
item.manufacturer,
@@ -46,20 +35,8 @@ export function FilamentImportModal(props: {
options.sort((a, b) => a.label.localeCompare(b.label, undefined, { sensitivity: "base" }));
return options;
}, [externalFilaments.data, selectedSource]);
// Count filaments by source
const counts = useMemo(() => {
const spoolmandb = externalFilaments.data?.filter(f => f.source === ExternalSource.SPOOLMANDB || !f.source).length ?? 0;
const threeDFP = externalFilaments.data?.filter(f => f.source === ExternalSource.FILAMENT_PROFILES_3D).length ?? 0;
return { spoolmandb, threeDFP, total: spoolmandb + threeDFP };
}, [externalFilaments.data]);
const handleSourceChange = (key: string) => {
setSelectedSource(key as ExternalSource | "all");
form.resetFields(["filament"]);
};
return (
<Modal
title={t("filament.form.import_external")}
@@ -77,25 +54,6 @@ export function FilamentImportModal(props: {
/>
</p>
<Tabs
activeKey={selectedSource}
onChange={handleSourceChange}
items={[
{
key: "all",
label: `${t("filament.form.all_sources")} (${counts.total})`,
},
{
key: ExternalSource.SPOOLMANDB,
label: `SpoolmanDB (${counts.spoolmandb})`,
},
{
key: ExternalSource.FILAMENT_PROFILES_3D,
label: `3D Filament Profiles (${counts.threeDFP})`,
},
]}
/>
<Form
layout="vertical"
form={form}
@@ -107,7 +65,6 @@ export function FilamentImportModal(props: {
props.onImport(filament);
props.onClose();
form.resetFields();
setSelectedSource("all");
}}
>
<Form.Item name="filament" rules={[{ required: true }]}>

View File

@@ -37,11 +37,13 @@ export function useSpoolmanFilamentFilter(enabled: boolean = false) {
tooltipParts.push(
<div
key="color"
className="color-swatch-mini"
style={{
borderRadius: ".4em",
width: "1.4em",
height: "1.4em",
backgroundColor: "#" + filament.color_hex,
border: "1px solid var(--swatch-border-color, rgba(68, 68, 68, 0.3))",
}}
></div>
);
@@ -201,3 +203,36 @@ export function useSpoolmanLocations(enabled: boolean = false) {
},
});
}
export function useSpoolmanColors(enabled: boolean = false) {
return useQuery<string[], unknown, ColumnFilterItem[]>({
enabled: enabled,
queryKey: ["colors"],
queryFn: async () => {
const response = await fetch(getAPIURL() + "/color");
if (!response.ok) {
throw new Error("Network response was not ok");
}
return response.json();
},
select: (data) => {
return data.map((colorHex) => ({
text: (
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<div
style={{
width: "1.2em",
height: "1.2em",
backgroundColor: "#" + colorHex,
borderRadius: "2px",
border: "1px solid var(--swatch-border-color, rgba(68, 68, 68, 0.3))",
}}
/>
<span style={{ fontFamily: "monospace", fontSize: "0.85em" }}>#{colorHex}</span>
</div>
),
value: colorHex,
}));
},
});
}

View File

@@ -0,0 +1,28 @@
import { Spin } from "antd";
import React from "react";
import { Navigate } from "react-router";
import { useAuth } from "../contexts/auth";
interface ProtectedRouteProps {
children: React.ReactNode;
}
export const ProtectedRoute: React.FC<ProtectedRouteProps> = ({ children }) => {
const { isAuthenticated, isLoading, authStatus } = useAuth();
// Show loading while checking auth
if (isLoading) {
return (
<div style={{ display: "flex", justifyContent: "center", alignItems: "center", height: "100vh" }}>
<Spin size="large" />
</div>
);
}
// If auth is enabled but not authenticated, redirect to login
if (authStatus?.enabled && !isAuthenticated) {
return <Navigate to="/login" replace />;
}
return <>{children}</>;
};

View File

@@ -22,7 +22,12 @@
.spool-icon * {
flex: 1 1 0px;
border-radius: 2px;
border: #44444430 solid 2px;
border: rgba(68, 68, 68, 0.3) solid 2px;
}
/* Light border for dark mode to make dark colors visible */
body[data-theme="dark"] .spool-icon * {
border-color: rgba(255, 255, 255, 0.3);
}
.spool-icon.vertical *:first-child {

View File

@@ -1,16 +1,21 @@
import { CopyOutlined } from "@ant-design/icons";
import { message, Tooltip } from "antd";
import { useTranslate } from "@refinedev/core";
import "./spoolIcon.css";
interface Props {
color: string | { colors: string[]; vertical: boolean };
size?: "small" | "large";
no_margin? : boolean
no_margin?: boolean;
showHex?: boolean;
}
export default function SpoolIcon(props: Readonly<Props>) {
const t = useTranslate();
let dirClass = "vertical";
let cols = [];
let size = props.size ? props.size : "small";
let no_margin = props.no_margin ? "no-margin" : "";
let cols: string[] = [];
const size = props.size ? props.size : "small";
const no_margin = props.no_margin ? "no-margin" : "";
if (typeof props.color === "string") {
cols = [props.color];
@@ -19,16 +24,49 @@ export default function SpoolIcon(props: Readonly<Props>) {
cols = props.color.colors;
}
return (
// Normalize hex codes (remove # if present, uppercase)
const normalizedCols = cols.map((col) => col.replace("#", "").toUpperCase());
const hexDisplay = normalizedCols.length === 1 ? `#${normalizedCols[0]}` : normalizedCols.map((c) => `#${c}`).join(", ");
const copyToClipboard = async () => {
try {
await navigator.clipboard.writeText(hexDisplay);
message.success(t("color.copied", { color: hexDisplay }));
} catch {
message.error(t("color.copy_failed"));
}
};
const swatchContent = (
<div className={"spool-icon " + dirClass + " " + size + " " + no_margin}>
{cols.map((col) => (
{normalizedCols.map((col) => (
<div
key={col}
style={{
backgroundColor: "#" + col.replace("#", ""),
backgroundColor: "#" + col,
}}
></div>
))}
</div>
);
if (props.showHex) {
return (
<Tooltip title={`${t("color.click_to_copy")}: ${hexDisplay}`}>
<div
className="spool-icon-with-hex"
onClick={copyToClipboard}
style={{ cursor: "pointer", display: "inline-flex", alignItems: "center", gap: 8 }}
>
{swatchContent}
<span className="hex-code" style={{ fontFamily: "monospace", fontSize: "0.85em", opacity: 0.8 }}>
{hexDisplay}
<CopyOutlined style={{ marginLeft: 4, fontSize: "0.9em" }} />
</span>
</div>
</Tooltip>
);
}
return swatchContent;
}

View File

@@ -0,0 +1,174 @@
import React, { createContext, useCallback, useContext, useEffect, useState } from "react";
import { getAPIURL } from "../../utils/url";
export interface User {
id: number;
username: string;
role: "admin" | "editor" | "operator" | "viewer";
is_active: boolean;
created_at: string;
}
export interface AuthStatus {
enabled: boolean;
has_users: boolean;
}
interface AuthContextType {
user: User | null;
token: string | null;
isAuthenticated: boolean;
isLoading: boolean;
authStatus: AuthStatus | null;
login: (username: string, password: string) => Promise<void>;
logout: () => void;
setup: (username: string, password: string) => Promise<void>;
refreshAuthStatus: () => Promise<void>;
}
const AuthContext = createContext<AuthContextType | undefined>(undefined);
const TOKEN_KEY = "spoolman_auth_token";
export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [user, setUser] = useState<User | null>(null);
const [token, setToken] = useState<string | null>(() => localStorage.getItem(TOKEN_KEY));
const [isLoading, setIsLoading] = useState(true);
const [authStatus, setAuthStatus] = useState<AuthStatus | null>(null);
const apiUrl = getAPIURL();
// Fetch auth status
const refreshAuthStatus = useCallback(async () => {
try {
const res = await fetch(`${apiUrl}/auth/status`);
if (res.ok) {
const status = await res.json();
setAuthStatus(status);
return status;
}
} catch (err) {
console.error("Failed to fetch auth status:", err);
}
return null;
}, [apiUrl]);
// Fetch current user if we have a token
const fetchUser = useCallback(async (authToken: string) => {
try {
const res = await fetch(`${apiUrl}/auth/me`, {
headers: { Authorization: `Bearer ${authToken}` },
});
if (res.ok) {
const userData = await res.json();
setUser(userData);
return true;
} else {
// Token invalid, clear it
localStorage.removeItem(TOKEN_KEY);
setToken(null);
setUser(null);
return false;
}
} catch (err) {
console.error("Failed to fetch user:", err);
return false;
}
}, [apiUrl]);
// Initialize auth state
useEffect(() => {
const init = async () => {
setIsLoading(true);
const status = await refreshAuthStatus();
if (status?.enabled && token) {
await fetchUser(token);
}
setIsLoading(false);
};
init();
}, [refreshAuthStatus, fetchUser, token]);
const login = async (username: string, password: string) => {
const formData = new URLSearchParams();
formData.append("username", username);
formData.append("password", password);
const res = await fetch(`${apiUrl}/auth/login`, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: formData,
});
if (!res.ok) {
const error = await res.json();
throw new Error(error.detail || "Login failed");
}
const data = await res.json();
localStorage.setItem(TOKEN_KEY, data.access_token);
setToken(data.access_token);
await fetchUser(data.access_token);
};
const logout = () => {
localStorage.removeItem(TOKEN_KEY);
setToken(null);
setUser(null);
};
const setup = async (username: string, password: string) => {
const res = await fetch(`${apiUrl}/auth/setup`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password }),
});
if (!res.ok) {
const error = await res.json();
throw new Error(error.detail || "Setup failed");
}
// After setup, log in
await login(username, password);
await refreshAuthStatus();
};
const isAuthenticated = !!user || !authStatus?.enabled;
return (
<AuthContext.Provider
value={{
user,
token,
isAuthenticated,
isLoading,
authStatus,
login,
logout,
setup,
refreshAuthStatus,
}}
>
{children}
</AuthContext.Provider>
);
};
export const useAuth = () => {
const context = useContext(AuthContext);
if (context === undefined) {
throw new Error("useAuth must be used within an AuthProvider");
}
return context;
};
// Helper to check if user has minimum role
export const hasRole = (user: User | null, minRole: "viewer" | "operator" | "editor" | "admin"): boolean => {
if (!user) return false;
const roleHierarchy = { viewer: 0, operator: 1, editor: 2, admin: 3 };
return roleHierarchy[user.role] >= roleHierarchy[minRole];
};

View File

@@ -32,6 +32,8 @@ export const ColorModeContextProvider: React.FC<PropsWithChildren> = ({ children
useEffect(() => {
window.localStorage.setItem("colorMode", mode);
// Set data attribute on body for CSS targeting
document.body.setAttribute("data-theme", mode);
}, [mode]);
useEffect(() => {

View File

@@ -5,11 +5,12 @@ import { PlusOutlined } from "@ant-design/icons";
import TextArea from "antd/es/input/TextArea";
import dayjs from "dayjs";
import utc from "dayjs/plugin/utc";
import { useEffect, useMemo, useState } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { ExtraFieldFormItem, ParsedExtras, StringifiedExtras } from "../../components/extraFields";
import { FilamentImportModal } from "../../components/filamentImportModal";
import { VendorCreateModal } from "../../components/vendorCreateModal";
import { MultiColorPicker } from "../../components/multiColorPicker";
import { FilamentColorLookup } from "../../components/filamentColorLookup";
import {
DEFAULT_DENSITY,
DEFAULT_DIAMETER,
@@ -20,6 +21,7 @@ import { formatNumberOnUserInput, numberParser, numberParserAllowEmpty } from ".
import { ExternalFilament } from "../../utils/queryExternalDB";
import { EntityType, useGetFields } from "../../utils/queryFields";
import { getCurrencySymbol, useCurrency } from "../../utils/settings";
import { useFormShortcuts } from "../../utils/useFormShortcuts";
import { getOrCreateVendorFromExternal } from "../vendors/functions";
import { IVendor } from "../vendors/model";
import { IFilament, IFilamentParsedExtras } from "./model";
@@ -83,6 +85,12 @@ export const FilamentCreate: React.FC<IResourceComponentsProps & CreateOrClonePr
redirect(redirectTo);
};
// Keyboard shortcut: Ctrl+S / Cmd+S to save
const handleSaveShortcut = useCallback(() => {
handleSubmit("list");
}, []);
useFormShortcuts(handleSaveShortcut);
const { selectProps: vendorSelect } = useSelect<IVendor>({
resource: "vendor",
optionLabel: "name",
@@ -241,6 +249,7 @@ export const FilamentCreate: React.FC<IResourceComponentsProps & CreateOrClonePr
</Radio.Group>
</Form.Item>
{colorType == "single" && (
<div style={{ display: "flex", gap: 8, alignItems: "start" }}>
<Form.Item
name={"color_hex"}
rules={[
@@ -254,6 +263,8 @@ export const FilamentCreate: React.FC<IResourceComponentsProps & CreateOrClonePr
>
<ColorPicker format="hex" />
</Form.Item>
<FilamentColorLookup onSelect={(hex) => form.setFieldValue("color_hex", hex)} />
</div>
)}
{colorType == "multi" && (
<Form.Item

View File

@@ -1,17 +1,68 @@
import { LeftOutlined, RightOutlined, ThunderboltOutlined } from "@ant-design/icons";
import { Edit, useForm, useSelect } from "@refinedev/antd";
import { HttpError, IResourceComponentsProps, useTranslate } from "@refinedev/core";
import { Alert, ColorPicker, DatePicker, Form, Input, InputNumber, message, Radio, Select, Space, Typography } from "antd";
import { HttpError, IResourceComponentsProps, useList, useTranslate } from "@refinedev/core";
import { Alert, Button, ColorPicker, DatePicker, Form, Input, InputNumber, message, Radio, Select, Space, Typography } from "antd";
import TextArea from "antd/es/input/TextArea";
import dayjs from "dayjs";
import React, { useEffect, useState } from "react";
import React, { useEffect, useMemo, useState } from "react";
import { useNavigate } from "react-router";
import { ExtraFieldFormItem, ParsedExtras, StringifiedExtras } from "../../components/extraFields";
import { MultiColorPicker } from "../../components/multiColorPicker";
import { FilamentColorLookup } from "../../components/filamentColorLookup";
import { formatNumberOnUserInput, numberParser, numberParserAllowEmpty } from "../../utils/parsing";
import { EntityType, useGetFields } from "../../utils/queryFields";
import { getCurrencySymbol, useCurrency } from "../../utils/settings";
import { IVendor } from "../vendors/model";
import { IFilament, IFilamentParsedExtras } from "./model";
/**
* Parse temperature ranges from a comment string.
* Handles patterns like:
* "extrude 190-230 / bed 50-70"
* "nozzle: 200-220°C, bed: 60-70°C"
* "hotend 200 bed 60"
*/
function parseTempsFromComment(comment: string): {
extruderMin?: number;
extruderMax?: number;
bedMin?: number;
bedMax?: number;
} {
const result: { extruderMin?: number; extruderMax?: number; bedMin?: number; bedMax?: number } = {};
// Patterns for extruder temperature
const extruderPatterns = [
/(?:extrud(?:e|er)|nozzle|hotend|print\s*temp)[:\s]*(\d{2,3})\s*[-~to]+\s*(\d{2,3})/i,
/(?:extrud(?:e|er)|nozzle|hotend|print\s*temp)[:\s]*(\d{2,3})\s*°?C?/i,
];
// Patterns for bed temperature
const bedPatterns = [
/(?:bed|plate|build\s*plate)[:\s]*(\d{2,3})\s*[-~to]+\s*(\d{2,3})/i,
/(?:bed|plate|build\s*plate)[:\s]*(\d{2,3})\s*°?C?/i,
];
for (const pattern of extruderPatterns) {
const match = comment.match(pattern);
if (match) {
result.extruderMin = parseInt(match[1]);
result.extruderMax = match[2] ? parseInt(match[2]) : parseInt(match[1]);
break;
}
}
for (const pattern of bedPatterns) {
const match = comment.match(pattern);
if (match) {
result.bedMin = parseInt(match[1]);
result.bedMax = match[2] ? parseInt(match[2]) : parseInt(match[1]);
break;
}
}
return result;
}
/*
The API returns the extra fields as JSON values, but we need to parse them into their real types
in order for Ant design's form to work properly. ParsedExtras does this for us.
@@ -21,13 +72,21 @@ the form's onFinish method. Form.Item's normalize should do this, but it doesn't
export const FilamentEdit: React.FC<IResourceComponentsProps> = () => {
const t = useTranslate();
const navigate = useNavigate();
const [messageApi, contextHolder] = message.useMessage();
const [hasChanged, setHasChanged] = useState(false);
const extraFields = useGetFields(EntityType.filament);
const currency = useCurrency();
const [colorType, setColorType] = useState<"single" | "multi">("single");
const { formProps, saveButtonProps } = useForm<IFilament, HttpError, IFilament, IFilament>({
// Get all filament IDs for prev/next navigation
const { result: allFilaments } = useList<IFilament>({
resource: "filament",
pagination: { mode: "off" },
sorters: [{ field: "id", order: "asc" }],
});
const { formProps, saveButtonProps, id, form } = useForm<IFilament, HttpError, IFilament, IFilament>({
liveMode: "manual",
onLiveEvent() {
// Warn the user if the filament has been updated since the form was opened
@@ -36,6 +95,17 @@ export const FilamentEdit: React.FC<IResourceComponentsProps> = () => {
},
});
// Calculate prev/next IDs for navigation
const { prevId, nextId } = useMemo(() => {
if (!allFilaments?.data || !id) return { prevId: null, nextId: null };
const ids = allFilaments.data.map((f: IFilament) => f.id);
const currentIndex = ids.indexOf(Number(id));
return {
prevId: currentIndex > 0 ? ids[currentIndex - 1] : null,
nextId: currentIndex < ids.length - 1 ? ids[currentIndex + 1] : null,
};
}, [allFilaments, id]);
// Get vendor selection options
const { selectProps } = useSelect<IVendor>({
resource: "vendor",
@@ -76,7 +146,28 @@ export const FilamentEdit: React.FC<IResourceComponentsProps> = () => {
};
return (
<Edit saveButtonProps={saveButtonProps}>
<Edit
saveButtonProps={saveButtonProps}
headerButtons={({ defaultButtons }) => (
<>
<Button
icon={<LeftOutlined />}
disabled={!prevId}
onClick={() => prevId && navigate(`/filament/edit/${prevId}`)}
>
{t("buttons.prev")}
</Button>
<Button
icon={<RightOutlined />}
disabled={!nextId}
onClick={() => nextId && navigate(`/filament/edit/${nextId}`)}
>
{t("buttons.next")}
</Button>
{defaultButtons}
</>
)}
>
{contextHolder}
<Form {...formProps} layout="vertical">
<Form.Item
@@ -158,6 +249,7 @@ export const FilamentEdit: React.FC<IResourceComponentsProps> = () => {
</Radio.Group>
</Form.Item>
{colorType == "single" && (
<div style={{ display: "flex", gap: 8, alignItems: "start" }}>
<Form.Item
name={"color_hex"}
rules={[
@@ -171,6 +263,8 @@ export const FilamentEdit: React.FC<IResourceComponentsProps> = () => {
>
<ColorPicker />
</Form.Item>
<FilamentColorLookup onSelect={(hex) => form.setFieldValue("color_hex", hex)} />
</div>
)}
{colorType == "multi" && (
<Form.Item
@@ -288,6 +382,29 @@ export const FilamentEdit: React.FC<IResourceComponentsProps> = () => {
>
<InputNumber addonAfter="g" precision={1} />
</Form.Item>
<div style={{ marginBottom: 16 }}>
<Button
size="small"
icon={<ThunderboltOutlined />}
onClick={() => {
const comment = form.getFieldValue("comment") || "";
const temps = parseTempsFromComment(comment);
if (!temps.extruderMin && !temps.bedMin) {
messageApi.warning(t("filament.form.no_temps_found"));
return;
}
const fields: Record<string, number | undefined> = {};
if (temps.extruderMin) fields.settings_extruder_temp_min = temps.extruderMin;
if (temps.extruderMax) fields.settings_extruder_temp_max = temps.extruderMax;
if (temps.bedMin) fields.settings_bed_temp_min = temps.bedMin;
if (temps.bedMax) fields.settings_bed_temp_max = temps.bedMax;
form.setFieldsValue(fields);
messageApi.success(t("filament.form.temps_parsed"));
}}
>
{t("filament.form.parse_temps")}
</Button>
</div>
<Form.Item label={t("filament.fields.settings_extruder_temp")}>
<Space.Compact>
<Form.Item

View File

@@ -65,6 +65,8 @@ const allColumns: (keyof IFilamentCollapsed & string)[] = [
"article_number",
"settings_extruder_temp",
"settings_bed_temp",
"spool_count",
"total_remaining_weight",
"registered",
"comment",
];
@@ -324,6 +326,22 @@ export const FilamentList: React.FC<IResourceComponentsProps> = () => {
maxDecimals: 0,
width: 100,
}),
NumberColumn({
...commonProps,
id: "spool_count",
i18ncat: "filament",
unit: "",
maxDecimals: 0,
width: 80,
}),
NumberColumn({
...commonProps,
id: "total_remaining_weight",
i18ncat: "filament",
unit: "g",
maxDecimals: 0,
width: 120,
}),
DateColumn({
...commonProps,
id: "registered",

View File

@@ -24,6 +24,8 @@ export interface IFilament {
multi_color_direction?: string;
external_id?: string;
extra: { [key: string]: string };
spool_count?: number;
total_remaining_weight?: number;
}
// IFilamentParsedExtras is the same as IFilament, but with the extra field parsed into its real types

View File

@@ -88,8 +88,7 @@ export const FilamentShow: React.FC<IResourceComponentsProps> = () => {
<Title level={5}>{t("filament.fields.name")}</Title>
<TextField value={record?.name} />
<Title level={5}>{t("filament.fields.color_hex")}</Title>
{colorObj && <SpoolIcon color={colorObj} size="large" no_margin />}
{record?.color_hex && <TextField value={`#${record?.color_hex}`} />}
{colorObj && <SpoolIcon color={colorObj} size="large" no_margin showHex />}
<Title level={5}>{t("filament.fields.material")}</Title>
<TextField value={record?.material} />
<Title level={5}>{t("filament.fields.price")}</Title>

View File

@@ -0,0 +1,119 @@
import { ExclamationCircleOutlined, PrinterOutlined, DashboardOutlined } from "@ant-design/icons";
import { useList, useTranslate } from "@refinedev/core";
import { Badge, Card, Col, Row, theme } from "antd";
import { Link } from "react-router";
import { ISpool } from "../../spools/model";
import { IPrintJob } from "../../spools/model";
const { useToken } = theme;
const LOW_STOCK_THRESHOLD = 100;
interface AlertCardProps {
icon: React.ReactNode;
label: string;
count: number;
to: string;
color: string;
loading?: boolean;
}
function AlertCard({ icon, label, count, to, color, loading }: AlertCardProps) {
const { token } = useToken();
if (count === 0 && !loading) return null;
return (
<Col xs={24} sm={8}>
<Link to={to}>
<Badge count={count} style={{ backgroundColor: color }} overflowCount={99}>
<Card size="small" hoverable loading={loading}>
<span style={{ display: "flex", alignItems: "center", gap: 8 }}>
<span style={{ color, fontSize: 18 }}>{icon}</span>
<span>{label}</span>
</span>
</Card>
</Badge>
</Link>
</Col>
);
}
interface AlertCardsProps {
compact?: boolean;
}
export function AlertCards({ compact }: AlertCardsProps) {
const t = useTranslate();
const { token } = useToken();
// Low stock spools
const { result: lowStockResult, query: lowStockQuery } = useList<ISpool>({
resource: "spool",
pagination: { pageSize: 1 },
meta: {
queryParams: {
allow_archived: false,
remaining_weight_lt: LOW_STOCK_THRESHOLD,
},
},
});
// Pending print jobs
const { result: pendingJobsResult, query: pendingJobsQuery } = useList<IPrintJob>({
resource: "print-job",
pagination: { pageSize: 1 },
filters: [{ field: "status", operator: "eq", value: "pending" }],
});
// Spools needing weighing
const { result: needsWeighingResult, query: needsWeighingQuery } = useList<ISpool>({
resource: "spool",
pagination: { pageSize: 1 },
meta: {
queryParams: {
allow_archived: false,
needs_weighing: true,
},
},
});
const alerts = [
{
icon: <ExclamationCircleOutlined />,
label: t("home.alerts.low_stock"),
count: lowStockResult?.total ?? 0,
to: `/spool?remaining_weight_lt=${LOW_STOCK_THRESHOLD}`,
color: token.colorError,
loading: lowStockQuery.isLoading,
},
{
icon: <PrinterOutlined />,
label: t("home.alerts.pending_jobs"),
count: pendingJobsResult?.total ?? 0,
to: "/print-queue",
color: token.colorWarning,
loading: pendingJobsQuery.isLoading,
},
{
icon: <DashboardOutlined />,
label: t("home.alerts.needs_weighing"),
count: needsWeighingResult?.total ?? 0,
to: "/spool?needs_weighing=true",
color: token.colorInfo,
loading: needsWeighingQuery.isLoading,
},
];
const visibleAlerts = alerts.filter((a) => a.count > 0 || a.loading);
if (visibleAlerts.length === 0) return null;
return (
<Row gutter={[compact ? 8 : 12, compact ? 8 : 12]} style={{ marginTop: compact ? 8 : 16 }}>
{visibleAlerts.map((alert, i) => (
<AlertCard key={i} {...alert} />
))}
</Row>
);
}

View File

@@ -0,0 +1,105 @@
import { useList, useTranslate } from "@refinedev/core";
import { Card, theme } from "antd";
import { useMemo } from "react";
import { PieChart, Pie, Cell, ResponsiveContainer, Tooltip, Legend } from "recharts";
import { ISpool } from "../../spools/model";
const { useToken } = theme;
// Color palette for the pie chart
const COLORS = [
"#1890ff", // blue
"#52c41a", // green
"#faad14", // gold
"#f5222d", // red
"#722ed1", // purple
"#13c2c2", // cyan
"#eb2f96", // magenta
"#fa8c16", // orange
"#a0d911", // lime
"#2f54eb", // geekblue
];
interface MaterialDistributionChartProps {
square?: boolean;
}
export function MaterialDistributionChart({ square }: MaterialDistributionChartProps) {
const t = useTranslate();
const { token } = useToken();
// Fetch all spools for aggregation
const { result: spoolsResult, query: spoolsQuery } = useList<ISpool>({
resource: "spool",
pagination: { pageSize: 1000 },
meta: {
queryParams: {
allow_archived: false,
},
},
});
// Aggregate by material
const chartData = useMemo(() => {
const data = spoolsResult?.data || [];
const materialCounts: Record<string, number> = {};
data.forEach((spool) => {
const material = spool.filament.material || t("unknown");
materialCounts[material] = (materialCounts[material] || 0) + 1;
});
return Object.entries(materialCounts)
.map(([name, value]) => ({ name, value }))
.sort((a, b) => b.value - a.value)
.slice(0, 10); // Top 10 materials
}, [spoolsResult?.data, t]);
if (spoolsQuery.isLoading || chartData.length === 0) {
return null;
}
return (
<Card
title={t("home.charts.material_distribution")}
size="small"
style={square ? { aspectRatio: "1", display: "flex", flexDirection: "column" } : { marginBottom: 16 }}
styles={square ? { body: { flex: 1, padding: "8px 12px", display: "flex", flexDirection: "column" } } : undefined}
>
<ResponsiveContainer width="100%" height={square ? "100%" : 250}>
<PieChart>
<Pie
data={chartData}
cx="50%"
cy="50%"
innerRadius={square ? 40 : 50}
outerRadius={square ? 70 : 80}
paddingAngle={2}
dataKey="value"
label={square ? undefined : ({ name, percent }) => `${name} (${((percent ?? 0) * 100).toFixed(0)}%)`}
labelLine={square ? false : { stroke: token.colorTextSecondary }}
>
{chartData.map((_, index) => (
<Cell key={`cell-${index}`} fill={COLORS[index % COLORS.length]} />
))}
</Pie>
<Tooltip
formatter={(value) => [`${value} spools`, t("spool.spool")]}
contentStyle={{
backgroundColor: token.colorBgElevated,
border: `1px solid ${token.colorBorder}`,
borderRadius: token.borderRadius,
}}
/>
{square && (
<Legend
layout="horizontal"
verticalAlign="bottom"
wrapperStyle={{ fontSize: 11 }}
/>
)}
</PieChart>
</ResponsiveContainer>
</Card>
);
}

View File

@@ -0,0 +1,169 @@
import { DollarOutlined, HighlightOutlined, InboxOutlined, DatabaseOutlined, UserOutlined, RiseOutlined } from "@ant-design/icons";
import { useList, useTranslate } from "@refinedev/core";
import { useQuery } from "@tanstack/react-query";
import { Card, Col, Row, Statistic, theme } from "antd";
import { useMemo } from "react";
import { Link } from "react-router";
import { IFilament } from "../../filaments/model";
import { ISpool } from "../../spools/model";
import { IVendor } from "../../vendors/model";
import { useCurrencyFormatter } from "../../../utils/settings";
import { getAPIURL } from "../../../utils/url";
const { useToken } = theme;
interface QuickStatsProps {
compact?: boolean;
}
export function QuickStats({ compact }: QuickStatsProps) {
const t = useTranslate();
const { token } = useToken();
const currencyFormatter = useCurrencyFormatter();
const { result: filamentsResult, query: filamentsQuery } = useList<IFilament>({
resource: "filament",
pagination: { pageSize: 1 },
});
const { result: vendorsResult, query: vendorsQuery } = useList<IVendor>({
resource: "vendor",
pagination: { pageSize: 1 },
});
// Fetch all spools for total weight calculation
const { result: spoolsResult, query: spoolsQuery } = useList<ISpool>({
resource: "spool",
pagination: { pageSize: 1000 },
meta: {
queryParams: {
allow_archived: false,
},
},
});
// Calculate total remaining weight
const totalWeight = useMemo(() => {
const data = spoolsResult?.data || [];
return data.reduce((sum, spool) => sum + (spool.remaining_weight ?? 0), 0);
}, [spoolsResult?.data]);
// Calculate total inventory value
const totalValue = useMemo(() => {
const data = spoolsResult?.data || [];
return data.reduce((sum, spool) => sum + (spool.remaining_value ?? 0), 0);
}, [spoolsResult?.data]);
// Fetch cost stats from backend
const { data: costStats, isLoading: costLoading } = useQuery<{
total_spent: number;
total_weight_purchased: number;
avg_cost_per_kg: number | null;
}>({
queryKey: ["analytics", "cost"],
queryFn: async () => {
const res = await fetch(`${getAPIURL()}/analytics/cost`);
return res.json();
},
});
const formatWeight = (grams: number) => {
if (grams >= 1000) {
return `${(grams / 1000).toFixed(1)} kg`;
}
return `${Math.round(grams)} g`;
};
const stats = [
{
title: t("home.quick_stats.total_filaments"),
value: filamentsResult?.total ?? 0,
loading: filamentsQuery.isLoading,
prefix: <HighlightOutlined />,
to: "/filament",
},
{
title: t("home.quick_stats.total_vendors"),
value: vendorsResult?.total ?? 0,
loading: vendorsQuery.isLoading,
prefix: <UserOutlined />,
to: "/vendor",
},
{
title: t("home.quick_stats.total_weight"),
value: formatWeight(totalWeight),
loading: spoolsQuery.isLoading,
prefix: <DatabaseOutlined />,
},
{
title: t("home.quick_stats.total_value"),
value: currencyFormatter.format(totalValue),
loading: spoolsQuery.isLoading,
prefix: <DollarOutlined />,
},
{
title: t("home.quick_stats.total_spent"),
value: currencyFormatter.format(costStats?.total_spent ?? 0),
loading: costLoading,
prefix: <DollarOutlined />,
},
{
title: t("home.quick_stats.avg_cost_per_kg"),
value: costStats?.avg_cost_per_kg != null ? currencyFormatter.format(costStats.avg_cost_per_kg) : "-",
loading: costLoading,
prefix: <RiseOutlined />,
},
];
if (compact) {
return (
<Row gutter={[8, 8]}>
{stats.map((stat, i) => (
<Col xs={12} key={i}>
<Card size="small" styles={{ body: { padding: "8px 12px" } }}>
<Statistic
title={stat.title}
value={stat.value}
loading={stat.loading}
prefix={stat.prefix}
valueStyle={{ fontSize: 16 }}
/>
</Card>
</Col>
))}
</Row>
);
}
return (
<Row gutter={[12, 12]} style={{ marginTop: 16 }}>
<Col xs={12} md={8} lg={4}>
<Link to="/spool">
<Card hoverable size="small">
<Statistic
title={t("home.quick_stats.total_spools")}
value={spoolsResult?.total ?? 0}
loading={spoolsQuery.isLoading}
prefix={<InboxOutlined />}
/>
</Card>
</Link>
</Col>
{stats.map((stat, i) => (
<Col xs={12} md={8} lg={4} key={i}>
{stat.to ? (
<Link to={stat.to}>
<Card hoverable size="small">
<Statistic title={stat.title} value={stat.value} loading={stat.loading} prefix={stat.prefix} />
</Card>
</Link>
) : (
<Card size="small">
<Statistic title={stat.title} value={stat.value} loading={stat.loading} prefix={stat.prefix} />
</Card>
)}
</Col>
))}
</Row>
);
}

View File

@@ -0,0 +1,153 @@
import { useTranslate } from "@refinedev/core";
import { useQuery } from "@tanstack/react-query";
import { Card, Segmented, Spin, Typography } from "antd";
import { useState } from "react";
import { Area, AreaChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
import { getAPIURL } from "../../../utils/url";
import { useCurrencyFormatter } from "../../../utils/settings";
interface UsageDataPoint {
date: string;
weight_used: number;
}
interface MaterialUsage {
material: string;
weight_used: number;
}
interface CostByMaterial {
material: string;
total_cost: number;
total_weight: number;
}
export function UsageAnalytics() {
const t = useTranslate();
const currencyFormatter = useCurrencyFormatter();
const [days, setDays] = useState<number>(30);
const { data: usageData, isLoading: usageLoading } = useQuery<UsageDataPoint[]>({
queryKey: ["analytics", "usage", days],
queryFn: async () => {
const res = await fetch(`${getAPIURL()}/analytics/usage?days=${days}`);
return res.json();
},
});
const { data: materialData, isLoading: materialLoading } = useQuery<MaterialUsage[]>({
queryKey: ["analytics", "by-material", days],
queryFn: async () => {
const res = await fetch(`${getAPIURL()}/analytics/by-material?days=${days}`);
return res.json();
},
});
const { data: costData } = useQuery<{ cost_by_material: CostByMaterial[] }>({
queryKey: ["analytics", "cost"],
queryFn: async () => {
const res = await fetch(`${getAPIURL()}/analytics/cost`);
return res.json();
},
});
// Format date for display
const formatDate = (dateStr: string) => {
const date = new Date(dateStr);
return date.toLocaleDateString(undefined, { month: "short", day: "numeric" });
};
// Calculate total usage
const totalUsage = usageData?.reduce((sum, d) => sum + d.weight_used, 0) ?? 0;
return (
<Card
title={t("home.analytics.title")}
extra={
<Segmented
value={days}
onChange={(v) => setDays(v as number)}
options={[
{ label: t("home.analytics.7_days"), value: 7 },
{ label: t("home.analytics.30_days"), value: 30 },
{ label: t("home.analytics.90_days"), value: 90 },
]}
/>
}
style={{ marginTop: 16 }}
>
{usageLoading ? (
<div style={{ textAlign: "center", padding: 40 }}>
<Spin />
</div>
) : (
<>
<Typography.Text type="secondary">
{t("home.analytics.total_used", { weight: (totalUsage / 1000).toFixed(2) })}
</Typography.Text>
<ResponsiveContainer width="100%" height={200}>
<AreaChart data={usageData} margin={{ top: 10, right: 10, left: 0, bottom: 0 }}>
<defs>
<linearGradient id="colorUsage" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#1890ff" stopOpacity={0.8} />
<stop offset="95%" stopColor="#1890ff" stopOpacity={0.1} />
</linearGradient>
</defs>
<XAxis
dataKey="date"
tickFormatter={formatDate}
tick={{ fontSize: 11 }}
interval="preserveStartEnd"
/>
<YAxis tick={{ fontSize: 11 }} tickFormatter={(v) => `${v}g`} width={50} />
<Tooltip
formatter={(value) => [`${(value as number).toFixed(1)} g`, t("home.analytics.used")]}
labelFormatter={(label) => new Date(label).toLocaleDateString()}
/>
<Area
type="monotone"
dataKey="weight_used"
stroke="#1890ff"
fillOpacity={1}
fill="url(#colorUsage)"
/>
</AreaChart>
</ResponsiveContainer>
{materialData && materialData.length > 0 && (
<>
<Typography.Title level={5} style={{ marginTop: 16 }}>
{t("home.analytics.by_material")}
</Typography.Title>
<div style={{ display: "flex", flexWrap: "wrap", gap: 8 }}>
{materialData.map((m) => {
const costInfo = costData?.cost_by_material?.find((c) => c.material === m.material);
// Calculate cost of material used based on unit cost (cost per gram)
const costPerGram = costInfo && costInfo.total_weight > 0 ? costInfo.total_cost / costInfo.total_weight : null;
const usageCost = costPerGram != null ? costPerGram * m.weight_used : null;
return (
<Card size="small" key={m.material} style={{ minWidth: 120 }}>
<Typography.Text strong>{m.material}</Typography.Text>
<br />
<Typography.Text type="secondary">
{m.weight_used >= 1000 ? `${(m.weight_used / 1000).toFixed(2)} kg` : `${m.weight_used.toFixed(0)} g`}
</Typography.Text>
{usageCost != null && (
<>
<br />
<Typography.Text type="secondary">
{currencyFormatter.format(usageCost)}
</Typography.Text>
</>
)}
</Card>
);
})}
</div>
</>
)}
</>
)}
</Card>
);
}

View File

@@ -1,129 +1,179 @@
import { FileOutlined, HighlightOutlined, PlusOutlined, UnorderedListOutlined, UserOutlined } from "@ant-design/icons";
import {
ExclamationCircleOutlined,
PlusOutlined,
} from "@ant-design/icons";
import { IResourceComponentsProps, useList, useTranslate } from "@refinedev/core";
import { Card, Col, Row, Statistic, theme } from "antd";
import { Button, Card, Col, Pagination, Row, Statistic, Table, theme, Tooltip } from "antd";
import { Content } from "antd/es/layout/layout";
import Title from "antd/es/typography/Title";
import dayjs from "dayjs";
import utc from "dayjs/plugin/utc";
import React, { ReactNode } from "react";
import { Trans } from "react-i18next";
import { Link } from "react-router";
import Logo from "../../icon.svg?react";
import React, { useState } from "react";
import { Link, useNavigate } from "react-router";
import SpoolIcon from "../../components/spoolIcon";
import { ISpool } from "../spools/model";
import { AlertCards } from "./components/AlertCards";
import { MaterialDistributionChart } from "./components/MaterialDistributionChart";
import { QuickStats } from "./components/QuickStats";
import { UsageAnalytics } from "./components/UsageAnalytics";
dayjs.extend(utc);
const { useToken } = theme;
const LOW_STOCK_THRESHOLD = 100; // grams
export const Home: React.FC<IResourceComponentsProps> = () => {
const { token } = useToken();
const t = useTranslate();
const navigate = useNavigate();
const spools = useList<ISpool>({
const [currentPage, setCurrentPage] = useState(1);
const pageSize = 10;
// Fetch all spools (not archived) for the main list
const { result: spoolsResult, query: spoolsQuery } = useList<ISpool>({
resource: "spool",
pagination: { pageSize: 1 },
});
const filaments = useList<ISpool>({
resource: "filament",
pagination: { pageSize: 1 },
});
const vendors = useList<ISpool>({
resource: "vendor",
pagination: { pageSize: 1 },
pagination: { currentPage, pageSize },
sorters: [{ field: "last_used", order: "desc" }],
meta: {
queryParams: {
allow_archived: false,
},
},
});
const hasSpools = !spools.result || spools.result.data.length > 0;
const ResourceStatsCard = (props: { loading: boolean; value: number; resource: string; icon: ReactNode }) => (
<Col xs={12} md={6}>
<Card
loading={props.loading}
hoverable
style={{ cursor: "pointer" }}
styles={{ body: { padding: 0 } }}
actions={[
<Link to={`/${props.resource}`} onClick={(e) => e.stopPropagation()}>
<UnorderedListOutlined />
</Link>,
<Link to={`/${props.resource}/create`} onClick={(e) => e.stopPropagation()}>
<PlusOutlined />
</Link>,
]}
>
<Link to={`/${props.resource}`} style={{ display: "block", padding: "24px", color: "inherit" }}>
<Statistic title={t(`${props.resource}.${props.resource}`)} value={props.value} prefix={props.icon} />
</Link>
</Card>
</Col>
const spoolColumns = [
{
title: "",
dataIndex: "color",
key: "color",
width: 50,
render: (_: unknown, record: ISpool) => {
const colorObj = record.color_hex
? record.color_hex
: record.filament.multi_color_hexes
? {
colors: record.filament.multi_color_hexes.split(","),
vertical: record.filament.multi_color_direction === "longitudinal",
}
: record.filament.color_hex;
return colorObj ? <SpoolIcon color={colorObj} /> : null;
},
},
{
title: t("spool.fields.filament_name"),
dataIndex: "filament",
key: "filament",
render: (_: unknown, record: ISpool) => {
const vendorName = record.filament.vendor?.name;
const filamentName = record.filament.name ?? `Filament #${record.filament.id}`;
return vendorName ? `${vendorName} - ${filamentName}` : filamentName;
},
},
{
title: t("spool.fields.material"),
dataIndex: ["filament", "material"],
key: "material",
width: 100,
},
{
title: t("spool.fields.remaining_weight"),
dataIndex: "remaining_weight",
key: "remaining_weight",
width: 120,
align: "right" as const,
render: (weight: number | undefined) => {
if (weight === undefined) return t("unknown");
const isLowStock = weight < LOW_STOCK_THRESHOLD;
return (
<span style={{ color: isLowStock ? token.colorError : undefined, fontWeight: isLowStock ? 600 : undefined }}>
{Math.round(weight)} g
{isLowStock && (
<Tooltip title={t("home.low_stock_warning")}>
<ExclamationCircleOutlined style={{ marginLeft: 6 }} />
</Tooltip>
)}
</span>
);
},
},
];
return (
<Content
style={{
padding: "2em 20px",
padding: "1.5em 20px",
minHeight: 280,
maxWidth: 800,
maxWidth: 900,
margin: "0 auto",
backgroundColor: token.colorBgContainer,
borderRadius: token.borderRadiusLG,
color: token.colorText,
fontFamily: token.fontFamily,
fontSize: token.fontSizeLG,
lineHeight: 1.5,
}}
>
<Title
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: token.fontSizeHeading1,
}}
>
<div
style={{
display: "inline-block",
height: "1.5em",
marginRight: "0.5em",
}}
>
<Logo />
</div>
Spoolman
</Title>
<Row justify="center" gutter={[16, 16]} style={{ marginTop: "3em" }}>
<ResourceStatsCard
resource="spool"
value={spools.result?.total || 0}
loading={spools.query.isLoading}
icon={<FileOutlined />}
/>
<ResourceStatsCard
resource="filament"
value={filaments.result?.total || 0}
loading={filaments.query.isLoading}
icon={<HighlightOutlined />}
/>
<ResourceStatsCard
resource="vendor"
value={vendors.result?.total || 0}
loading={vendors.query.isLoading}
icon={<UserOutlined />}
{/* Top section: Pie chart + stats/alerts side by side */}
<Row gutter={16} style={{ marginBottom: 16 }}>
<Col xs={24} md={10}>
<MaterialDistributionChart square />
</Col>
<Col xs={24} md={14}>
<Row justify="space-between" align="middle" style={{ marginBottom: 12 }}>
<Col>
<Statistic
title={t("spool.spool")}
value={spoolsResult?.total ?? 0}
loading={spoolsQuery.isLoading}
valueStyle={{ fontSize: 36, fontWeight: 600 }}
/>
</Col>
<Col>
<Button type="primary" size="large" icon={<PlusOutlined />} onClick={() => navigate("/spool/create")}>
{t("spool.titles.create")}
</Button>
</Col>
</Row>
{!hasSpools && (
<>
<p style={{ marginTop: 32 }}>{t("home.welcome")}</p>
<p>
<Trans
i18nKey="home.description"
components={{
helpPageLink: <Link to="/help" />,
}}
<QuickStats compact />
<AlertCards compact />
</Col>
</Row>
{/* Spool list */}
<Card
title={t("home.all_spools")}
extra={<Link to="/spool">{t("buttons.viewAll")}</Link>}
styles={{ body: { padding: 0 } }}
style={{ marginBottom: 16 }}
>
<Table<ISpool>
dataSource={spoolsResult?.data as ISpool[]}
columns={spoolColumns}
rowKey="id"
loading={spoolsQuery.isLoading}
pagination={false}
size="small"
onRow={(record) => ({
onClick: () => navigate(`/spool/show/${record.id}`),
style: { cursor: "pointer" },
})}
/>
</p>
</>
)}
<div style={{ padding: "12px 16px", borderTop: `1px solid ${token.colorBorderSecondary}` }}>
<Pagination
current={currentPage}
pageSize={pageSize}
total={spoolsResult?.total ?? 0}
onChange={setCurrentPage}
showSizeChanger={false}
size="small"
/>
</div>
</Card>
{/* Usage analytics & insights */}
<UsageAnalytics />
{/* Alerts */}
<AlertCards />
</Content>
);
};

View File

@@ -0,0 +1,98 @@
import { LockOutlined, UserOutlined } from "@ant-design/icons";
import { Button, Card, Form, Input, message, Typography } from "antd";
import React, { useState } from "react";
import { useNavigate } from "react-router";
import { useAuth } from "../../contexts/auth";
const { Title, Text } = Typography;
interface LoginForm {
username: string;
password: string;
}
export const LoginPage: React.FC = () => {
const { login, setup, authStatus, refreshAuthStatus } = useAuth();
const navigate = useNavigate();
const [isLoading, setIsLoading] = useState(false);
const [isSetupMode, setIsSetupMode] = useState(false);
const needsSetup = authStatus?.enabled && !authStatus?.has_users;
const handleSubmit = async (values: LoginForm) => {
setIsLoading(true);
try {
if (needsSetup || isSetupMode) {
await setup(values.username, values.password);
message.success("Admin account created successfully!");
} else {
await login(values.username, values.password);
message.success("Logged in successfully!");
}
navigate("/");
} catch (err) {
message.error(err instanceof Error ? err.message : "Authentication failed");
} finally {
setIsLoading(false);
}
};
return (
<div
style={{
display: "flex",
justifyContent: "center",
alignItems: "center",
minHeight: "100vh",
background: "#f0f2f5",
}}
>
<Card style={{ width: 400, boxShadow: "0 4px 12px rgba(0,0,0,0.1)" }}>
<div style={{ textAlign: "center", marginBottom: 24 }}>
<Title level={2} style={{ marginBottom: 8 }}>
Spoolman
</Title>
<Text type="secondary">
{needsSetup || isSetupMode ? "Create Admin Account" : "Sign in to continue"}
</Text>
</div>
<Form<LoginForm> name="login" onFinish={handleSubmit} layout="vertical" requiredMark={false}>
<Form.Item
name="username"
rules={[
{ required: true, message: "Please enter your username" },
{ min: 3, message: "Username must be at least 3 characters" },
]}
>
<Input prefix={<UserOutlined />} placeholder="Username" size="large" autoFocus />
</Form.Item>
<Form.Item
name="password"
rules={[
{ required: true, message: "Please enter your password" },
{ min: 6, message: "Password must be at least 6 characters" },
]}
>
<Input.Password prefix={<LockOutlined />} placeholder="Password" size="large" />
</Form.Item>
<Form.Item style={{ marginBottom: 12 }}>
<Button type="primary" htmlType="submit" size="large" block loading={isLoading}>
{needsSetup || isSetupMode ? "Create Account" : "Sign In"}
</Button>
</Form.Item>
</Form>
{needsSetup && (
<Text type="secondary" style={{ display: "block", textAlign: "center" }}>
This is the first time setup. Create an admin account to get started.
</Text>
)}
</Card>
</div>
);
};
export default LoginPage;

View File

@@ -0,0 +1,456 @@
import {
CheckCircleOutlined,
CloseCircleOutlined,
DeleteOutlined,
ExclamationCircleOutlined,
FileOutlined,
ReloadOutlined,
ToolOutlined,
} from "@ant-design/icons";
import { IResourceComponentsProps, useCustomMutation, useInvalidate, useList, useTranslate } from "@refinedev/core";
import { Badge, Button, Card, Col, Empty, Modal, Row, Space, Statistic, Table, Tag, theme, Tooltip } from "antd";
import { Content } from "antd/es/layout/layout";
import Title from "antd/es/typography/Title";
import dayjs from "dayjs";
import relativeTime from "dayjs/plugin/relativeTime";
import utc from "dayjs/plugin/utc";
import React, { useMemo } from "react";
import { Link } from "react-router";
import { IPrintJob, ISpool } from "../spools/model";
dayjs.extend(utc);
dayjs.extend(relativeTime);
const { useToken } = theme;
const { confirm } = Modal;
export const PrintQueue: React.FC<IResourceComponentsProps> = () => {
const { token } = useToken();
const t = useTranslate();
const invalidate = useInvalidate();
const { mutate: customMutate } = useCustomMutation();
// Fetch print jobs
const { result: printJobs, query: printJobsQuery } = useList<IPrintJob>({
resource: "print-job",
pagination: { mode: "off" },
sorters: [{ field: "created", order: "desc" }],
});
// Fetch spools that need weighing
const { result: spoolsNeedingWeighing, query: spoolsQuery } = useList<ISpool>({
resource: "spool",
filters: [{ field: "needs_weighing", operator: "eq", value: true }],
pagination: { mode: "off" },
});
// Categorize jobs
const { pendingJobs, completedJobs, cancelledJobs } = useMemo(() => {
const jobs = printJobs?.data || [];
return {
pendingJobs: jobs.filter((j) => j.status === "pending"),
completedJobs: jobs.filter((j) => j.status === "completed").slice(0, 10),
cancelledJobs: jobs.filter((j) => j.status === "cancelled").slice(0, 10),
};
}, [printJobs]);
const handleComplete = (job: IPrintJob) => {
confirm({
title: t("print_queue.confirm_complete"),
content: t("print_queue.confirm_complete_description", { grams: job.filament_used_g.toFixed(1) }),
icon: <CheckCircleOutlined style={{ color: token.colorSuccess }} />,
okText: t("buttons.confirm"),
okType: "primary",
cancelText: t("buttons.cancel"),
onOk: () => {
customMutate({
url: `print-job/${job.id}/complete`,
method: "post",
values: {},
successNotification: {
message: t("print_queue.job_completed"),
type: "success",
},
errorNotification: {
message: t("print_queue.job_complete_error"),
type: "error",
},
}, {
onSuccess: () => {
invalidate({ resource: "print-job", invalidates: ["list"] });
invalidate({ resource: "spool", invalidates: ["list", "detail"] });
},
});
},
});
};
const handleCancel = (job: IPrintJob) => {
confirm({
title: t("print_queue.confirm_cancel"),
content: t("print_queue.confirm_cancel_description"),
icon: <ExclamationCircleOutlined style={{ color: token.colorWarning }} />,
okText: t("print_queue.cancel_job"),
okType: "danger",
cancelText: t("buttons.cancel"),
onOk: () => {
customMutate({
url: `print-job/${job.id}/cancel`,
method: "post",
values: {},
successNotification: {
message: t("print_queue.job_cancelled"),
type: "success",
},
errorNotification: {
message: t("print_queue.job_cancel_error"),
type: "error",
},
}, {
onSuccess: () => {
invalidate({ resource: "print-job", invalidates: ["list"] });
invalidate({ resource: "spool", invalidates: ["list", "detail"] });
},
});
},
});
};
const handleDelete = (job: IPrintJob) => {
confirm({
title: t("print_queue.confirm_delete"),
content: t("print_queue.confirm_delete_description"),
icon: <DeleteOutlined style={{ color: token.colorError }} />,
okText: t("buttons.delete"),
okType: "danger",
cancelText: t("buttons.cancel"),
onOk: () => {
customMutate({
url: `print-job/${job.id}`,
method: "delete",
values: {},
successNotification: {
message: t("print_queue.job_deleted"),
type: "success",
},
}, {
onSuccess: () => {
invalidate({ resource: "print-job", invalidates: ["list"] });
},
});
},
});
};
const handleClearWeighingFlag = (spool: ISpool) => {
customMutate({
url: `spool/${spool.id}`,
method: "patch",
values: { needs_weighing: false },
successNotification: {
message: t("print_queue.weighing_cleared"),
type: "success",
},
}, {
onSuccess: () => {
invalidate({ resource: "spool", invalidates: ["list", "detail"] });
},
});
};
const getSpoolName = (job: IPrintJob) => {
if (job.spool?.filament) {
const fil = job.spool.filament;
if (fil.vendor?.name) {
return `${fil.vendor.name} - ${fil.name || fil.material}`;
}
return fil.name || fil.material || `Filament #${fil.id}`;
}
return `Spool #${job.spool_id}`;
};
const pendingColumns = [
{
title: t("print_queue.fields.filename"),
dataIndex: "filename",
key: "filename",
render: (filename: string) => (
<Space>
<FileOutlined />
{filename}
</Space>
),
},
{
title: t("print_queue.fields.spool"),
key: "spool",
render: (_: unknown, record: IPrintJob) => (
<Link to={`/spool/show/${record.spool_id}`}>{getSpoolName(record)}</Link>
),
},
{
title: t("print_queue.fields.filament_used"),
key: "filament_used",
render: (_: unknown, record: IPrintJob) => `${record.filament_used_g.toFixed(1)} g`,
},
{
title: t("print_queue.fields.created"),
dataIndex: "created",
key: "created",
render: (created: string) => (
<Tooltip title={dayjs.utc(created).local().format("YYYY-MM-DD HH:mm:ss")}>
{dayjs.utc(created).fromNow()}
</Tooltip>
),
},
{
title: t("table.actions"),
key: "actions",
render: (_: unknown, record: IPrintJob) => (
<Space>
<Button
type="primary"
icon={<CheckCircleOutlined />}
onClick={() => handleComplete(record)}
>
{t("print_queue.complete")}
</Button>
<Button
danger
icon={<CloseCircleOutlined />}
onClick={() => handleCancel(record)}
>
{t("print_queue.cancel")}
</Button>
</Space>
),
},
];
const historyColumns = [
{
title: t("print_queue.fields.filename"),
dataIndex: "filename",
key: "filename",
},
{
title: t("print_queue.fields.spool"),
key: "spool",
render: (_: unknown, record: IPrintJob) => (
<Link to={`/spool/show/${record.spool_id}`}>{getSpoolName(record)}</Link>
),
},
{
title: t("print_queue.fields.filament_used"),
key: "filament_used",
render: (_: unknown, record: IPrintJob) => `${record.filament_used_g.toFixed(1)} g`,
},
{
title: t("print_queue.fields.status"),
dataIndex: "status",
key: "status",
render: (status: string) => (
<Tag color={status === "completed" ? "success" : "error"}>
{status === "completed" ? t("print_queue.status.completed") : t("print_queue.status.cancelled")}
</Tag>
),
},
{
title: t("print_queue.fields.finished"),
dataIndex: "finished",
key: "finished",
render: (finished: string) =>
finished ? (
<Tooltip title={dayjs.utc(finished).local().format("YYYY-MM-DD HH:mm:ss")}>
{dayjs.utc(finished).fromNow()}
</Tooltip>
) : (
"-"
),
},
{
title: t("table.actions"),
key: "actions",
render: (_: unknown, record: IPrintJob) => (
<Button
type="text"
danger
icon={<DeleteOutlined />}
onClick={() => handleDelete(record)}
/>
),
},
];
const weighingColumns = [
{
title: t("spool.fields.id"),
dataIndex: "id",
key: "id",
},
{
title: t("spool.fields.filament_name"),
key: "filament",
render: (_: unknown, record: ISpool) => {
const fil = record.filament;
if (fil.vendor?.name) {
return `${fil.vendor.name} - ${fil.name || fil.material}`;
}
return fil.name || fil.material || `Filament #${fil.id}`;
},
},
{
title: t("spool.fields.location"),
dataIndex: "location",
key: "location",
},
{
title: t("table.actions"),
key: "actions",
render: (_: unknown, record: ISpool) => (
<Space>
<Link to={`/spool/edit/${record.id}`}>
<Button type="primary" icon={<ToolOutlined />}>
{t("print_queue.weigh_in")}
</Button>
</Link>
<Tooltip title={t("print_queue.clear_flag_tooltip")}>
<Button onClick={() => handleClearWeighingFlag(record)}>
{t("print_queue.clear_flag")}
</Button>
</Tooltip>
</Space>
),
},
];
const isLoading = printJobsQuery.isLoading || spoolsQuery.isLoading;
return (
<Content
style={{
padding: "2em 20px",
minHeight: 280,
maxWidth: 1200,
margin: "0 auto",
backgroundColor: token.colorBgContainer,
borderRadius: token.borderRadiusLG,
}}
>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 24 }}>
<Title level={2} style={{ margin: 0 }}>
{t("print_queue.title")}
</Title>
<Button
icon={<ReloadOutlined />}
onClick={() => {
invalidate({ resource: "print-job", invalidates: ["list"] });
invalidate({ resource: "spool", invalidates: ["list"] });
}}
>
{t("buttons.refresh")}
</Button>
</div>
{/* Stats cards */}
<Row gutter={[16, 16]} style={{ marginBottom: 24 }}>
<Col xs={12} sm={6}>
<Card>
<Statistic
title={t("print_queue.stats.pending")}
value={pendingJobs.length}
valueStyle={{ color: token.colorWarning }}
/>
</Card>
</Col>
<Col xs={12} sm={6}>
<Card>
<Statistic
title={t("print_queue.stats.needs_weighing")}
value={spoolsNeedingWeighing?.data?.length || 0}
valueStyle={{ color: (spoolsNeedingWeighing?.data?.length || 0) > 0 ? token.colorError : undefined }}
/>
</Card>
</Col>
<Col xs={12} sm={6}>
<Card>
<Statistic
title={t("print_queue.stats.completed_today")}
value={completedJobs.filter((j) => dayjs.utc(j.finished).isAfter(dayjs().startOf("day"))).length}
valueStyle={{ color: token.colorSuccess }}
/>
</Card>
</Col>
<Col xs={12} sm={6}>
<Card>
<Statistic
title={t("print_queue.stats.total_jobs")}
value={printJobs?.data?.length || 0}
/>
</Card>
</Col>
</Row>
{/* Pending Jobs */}
<Card
title={
<Badge count={pendingJobs.length} offset={[15, 0]} color={token.colorWarning}>
<span>{t("print_queue.pending_jobs")}</span>
</Badge>
}
style={{ marginBottom: 24 }}
>
{pendingJobs.length > 0 ? (
<Table
dataSource={pendingJobs}
columns={pendingColumns}
rowKey="id"
pagination={false}
loading={isLoading}
/>
) : (
<Empty description={t("print_queue.no_pending_jobs")} />
)}
</Card>
{/* Spools Needing Weighing */}
{(spoolsNeedingWeighing?.data?.length || 0) > 0 && (
<Card
title={
<Badge count={spoolsNeedingWeighing?.data?.length || 0} offset={[15, 0]} color={token.colorError}>
<span>{t("print_queue.needs_weighing")}</span>
</Badge>
}
style={{ marginBottom: 24 }}
>
<Table
dataSource={spoolsNeedingWeighing?.data || []}
columns={weighingColumns}
rowKey="id"
pagination={false}
loading={isLoading}
/>
</Card>
)}
{/* Recent History */}
<Card title={t("print_queue.recent_history")}>
{completedJobs.length > 0 || cancelledJobs.length > 0 ? (
<Table
dataSource={[...completedJobs, ...cancelledJobs].sort(
(a, b) => dayjs(b.finished).valueOf() - dayjs(a.finished).valueOf()
).slice(0, 10)}
columns={historyColumns}
rowKey="id"
pagination={false}
loading={isLoading}
/>
) : (
<Empty description={t("print_queue.no_history")} />
)}
</Card>
</Content>
);
};
export default PrintQueue;

View File

@@ -1,11 +1,12 @@
import { DownloadOutlined, UploadOutlined } from "@ant-design/icons";
import { DownloadOutlined, LockOutlined, UploadOutlined } from "@ant-design/icons";
import { useTranslate } from "@refinedev/core";
import { Button, Checkbox, Divider, Form, Input, message, Popconfirm, Space, Upload } from "antd";
import { Button, Checkbox, Divider, Form, Input, message, Modal, Popconfirm, Space, Switch, Upload } from "antd";
import type { UploadFile } from "antd";
import { useContext, useEffect, useState } from "react";
import { getAPIURL } from "../../utils/url";
import { useGetSettings, useSetSetting } from "../../utils/querySettings";
import { ColorModeContext, THEME_COLORS, ThemeColorKey } from "../../contexts/color-mode";
import { useAuth } from "../../contexts/auth";
export function GeneralSettings() {
const settings = useGetSettings();
@@ -137,13 +138,191 @@ export function GeneralSettings() {
<Divider />
<AuthSettings messageApi={messageApi} t={t} />
<Divider />
<BackupRestore messageApi={messageApi} t={t} />
<Divider />
<AboutSection />
{contextHolder}
</>
);
}
interface AuthSettingsProps {
messageApi: ReturnType<typeof message.useMessage>[0];
t: ReturnType<typeof useTranslate>;
}
function AuthSettings({ messageApi, t }: AuthSettingsProps) {
const { authStatus, refreshAuthStatus, setup } = useAuth();
const [toggling, setToggling] = useState(false);
const [setupModalOpen, setSetupModalOpen] = useState(false);
const [disableModalOpen, setDisableModalOpen] = useState(false);
const [setupForm] = Form.useForm();
const [disableForm] = Form.useForm();
const handleToggle = async (enabled: boolean) => {
if (enabled) {
// If enabling and no users exist, show setup modal
if (!authStatus?.has_users) {
setSetupModalOpen(true);
return;
}
// If users exist, just enable
await toggleAuth(true);
} else {
// Disabling - show password confirmation modal
setDisableModalOpen(true);
}
};
const toggleAuth = async (enabled: boolean, password?: string) => {
setToggling(true);
try {
const res = await fetch(`${getAPIURL()}/auth/toggle`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ enabled, password }),
});
if (!res.ok) {
const error = await res.json();
throw new Error(error.detail || "Failed to toggle authentication");
}
await refreshAuthStatus();
messageApi.success(
enabled
? t("settings.auth.enabled_success")
: t("settings.auth.disabled_success")
);
if (enabled) {
// Redirect to login after enabling
window.location.href = "/login";
}
} catch (err) {
messageApi.error(err instanceof Error ? err.message : "Failed to toggle authentication");
} finally {
setToggling(false);
}
};
const handleSetup = async (values: { username: string; password: string }) => {
setToggling(true);
try {
// Create the first admin user
await setup(values.username, values.password);
// Enable auth
await toggleAuth(true);
setSetupModalOpen(false);
setupForm.resetFields();
} catch (err) {
messageApi.error(err instanceof Error ? err.message : "Setup failed");
} finally {
setToggling(false);
}
};
const handleDisable = async (values: { password: string }) => {
await toggleAuth(false, values.password);
setDisableModalOpen(false);
disableForm.resetFields();
};
return (
<div style={{ maxWidth: "600px", margin: "0 auto" }}>
<h3>
<LockOutlined style={{ marginRight: 8 }} />
{t("settings.auth.title")}
</h3>
<p style={{ marginBottom: "1em", color: "#888" }}>
{t("settings.auth.description")}
</p>
<div style={{ display: "flex", alignItems: "center", gap: 16 }}>
<Switch
checked={authStatus?.enabled ?? false}
onChange={handleToggle}
loading={toggling}
/>
<span>
{authStatus?.enabled
? t("settings.auth.status_enabled")
: t("settings.auth.status_disabled")}
</span>
</div>
{/* Setup Modal - Create first admin user */}
<Modal
title={t("settings.auth.setup_title")}
open={setupModalOpen}
onCancel={() => {
setSetupModalOpen(false);
setupForm.resetFields();
}}
onOk={() => setupForm.submit()}
okText={t("settings.auth.enable_button")}
confirmLoading={toggling}
>
<p style={{ marginBottom: 16 }}>{t("settings.auth.setup_description")}</p>
<Form form={setupForm} layout="vertical" onFinish={handleSetup}>
<Form.Item
name="username"
label={t("settings.users.username")}
rules={[
{ required: true, message: t("settings.users.username_required") },
{ min: 3, message: t("settings.users.username_min") },
]}
>
<Input />
</Form.Item>
<Form.Item
name="password"
label={t("settings.users.password")}
rules={[
{ required: true, message: t("settings.users.password_required") },
{ min: 6, message: t("settings.users.password_min") },
]}
>
<Input.Password />
</Form.Item>
</Form>
</Modal>
{/* Disable Modal - Require password confirmation */}
<Modal
title={t("settings.auth.disable_title")}
open={disableModalOpen}
onCancel={() => {
setDisableModalOpen(false);
disableForm.resetFields();
}}
onOk={() => disableForm.submit()}
okText={t("settings.auth.disable_button")}
okButtonProps={{ danger: true }}
confirmLoading={toggling}
>
<p style={{ marginBottom: 16 }}>{t("settings.auth.disable_description")}</p>
<Form form={disableForm} layout="vertical" onFinish={handleDisable}>
<Form.Item
name="password"
label={t("settings.auth.admin_password")}
rules={[{ required: true, message: t("settings.auth.password_required") }]}
>
<Input.Password />
</Form.Item>
</Form>
</Modal>
</div>
);
}
interface BackupRestoreProps {
messageApi: ReturnType<typeof message.useMessage>[0];
t: ReturnType<typeof useTranslate>;
@@ -272,3 +451,27 @@ function BackupRestore({ messageApi, t }: BackupRestoreProps) {
</div>
);
}
function AboutSection() {
return (
<div style={{ maxWidth: "600px", margin: "0 auto" }}>
<h3>About</h3>
<p style={{ color: "#888", lineHeight: 1.8 }}>
Based on{" "}
<a href="https://github.com/Donkie/Spoolman" target="_blank" rel="noopener noreferrer">
Spoolman
</a>{" "}
by{" "}
<a href="https://github.com/Donkie" target="_blank" rel="noopener noreferrer">
Donkie
</a>
<br />
UX enhancements developed with{" "}
<a href="https://claude.ai" target="_blank" rel="noopener noreferrer">
Claude
</a>{" "}
(Anthropic)
</p>
</div>
);
}

View File

@@ -1,4 +1,4 @@
import { FileOutlined, HighlightOutlined, SolutionOutlined, ToolOutlined, UserOutlined } from "@ant-design/icons";
import { FileOutlined, HighlightOutlined, SolutionOutlined, TeamOutlined, ToolOutlined, UserOutlined } from "@ant-design/icons";
import { IResourceComponentsProps, useTranslate } from "@refinedev/core";
import { Menu, theme } from "antd";
import { Content } from "antd/es/layout/layout";
@@ -8,6 +8,7 @@ import React from "react";
import { Route, Routes, useNavigate } from "react-router";
import { ExtraFieldsSettings } from "./extraFieldsSettings";
import { GeneralSettings } from "./generalSettings";
import { UserSettings } from "./userSettings";
dayjs.extend(utc);
@@ -58,6 +59,7 @@ export const Settings: React.FC<IResourceComponentsProps> = () => {
}}
items={[
{ key: "", label: t("settings.general.tab"), icon: <ToolOutlined /> },
{ key: "users", label: t("settings.users.tab"), icon: <TeamOutlined /> },
{
key: "extra",
label: t("settings.extra_fields.tab"),
@@ -88,6 +90,7 @@ export const Settings: React.FC<IResourceComponentsProps> = () => {
<main>
<Routes>
<Route index element={<GeneralSettings />} />
<Route path="/users" element={<UserSettings />} />
<Route path="/extra/:entityType" element={<ExtraFieldsSettings />} />
</Routes>
</main>

View File

@@ -0,0 +1,285 @@
import { DeleteOutlined, EditOutlined, PlusOutlined, UserOutlined } from "@ant-design/icons";
import { useTranslate } from "@refinedev/core";
import { Button, Form, Input, message, Modal, Popconfirm, Select, Space, Table, Tag, Typography } from "antd";
import React, { useCallback, useEffect, useState } from "react";
import { hasRole, useAuth, User } from "../../contexts/auth";
import { getAPIURL } from "../../utils/url";
const { Title, Text } = Typography;
interface UserFormValues {
username: string;
password?: string;
role: "admin" | "editor" | "operator" | "viewer";
}
export const UserSettings: React.FC = () => {
const t = useTranslate();
const { user: currentUser, token, authStatus, logout } = useAuth();
const [users, setUsers] = useState<User[]>([]);
const [loading, setLoading] = useState(false);
const [modalOpen, setModalOpen] = useState(false);
const [editingUser, setEditingUser] = useState<User | null>(null);
const [form] = Form.useForm<UserFormValues>();
const apiUrl = getAPIURL();
const fetchUsers = useCallback(async () => {
if (!token) return;
setLoading(true);
try {
const res = await fetch(`${apiUrl}/auth/users`, {
headers: { Authorization: `Bearer ${token}` },
});
if (res.ok) {
setUsers(await res.json());
}
} catch (err) {
console.error("Failed to fetch users:", err);
} finally {
setLoading(false);
}
}, [apiUrl, token]);
useEffect(() => {
if (authStatus?.enabled && hasRole(currentUser, "admin")) {
fetchUsers();
}
}, [authStatus, currentUser, fetchUsers]);
const handleCreate = async (values: UserFormValues) => {
try {
const res = await fetch(`${apiUrl}/auth/users`, {
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify(values),
});
if (!res.ok) {
const error = await res.json();
throw new Error(error.detail || "Failed to create user");
}
message.success(t("settings.users.created"));
setModalOpen(false);
form.resetFields();
fetchUsers();
} catch (err) {
message.error(err instanceof Error ? err.message : "Failed to create user");
}
};
const handleUpdate = async (values: UserFormValues) => {
if (!editingUser) return;
try {
const body: Partial<UserFormValues> = { role: values.role };
if (values.password) {
body.password = values.password;
}
const res = await fetch(`${apiUrl}/auth/users/${editingUser.id}`, {
method: "PATCH",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify(body),
});
if (!res.ok) {
const error = await res.json();
throw new Error(error.detail || "Failed to update user");
}
message.success(t("settings.users.updated"));
setModalOpen(false);
setEditingUser(null);
form.resetFields();
fetchUsers();
} catch (err) {
message.error(err instanceof Error ? err.message : "Failed to update user");
}
};
const handleDelete = async (userId: number) => {
try {
const res = await fetch(`${apiUrl}/auth/users/${userId}`, {
method: "DELETE",
headers: { Authorization: `Bearer ${token}` },
});
if (!res.ok) {
const error = await res.json();
throw new Error(error.detail || "Failed to delete user");
}
message.success(t("settings.users.deleted"));
fetchUsers();
} catch (err) {
message.error(err instanceof Error ? err.message : "Failed to delete user");
}
};
const openCreateModal = () => {
setEditingUser(null);
form.resetFields();
form.setFieldsValue({ role: "viewer" });
setModalOpen(true);
};
const openEditModal = (user: User) => {
setEditingUser(user);
form.setFieldsValue({ username: user.username, role: user.role, password: undefined });
setModalOpen(true);
};
const roleColors: Record<string, string> = {
admin: "red",
editor: "blue",
operator: "green",
viewer: "default",
};
// If auth is not enabled, show message
if (!authStatus?.enabled) {
return (
<div style={{ textAlign: "center", padding: 40 }}>
<UserOutlined style={{ fontSize: 48, color: "#999", marginBottom: 16 }} />
<Title level={4}>{t("settings.users.auth_disabled")}</Title>
<Text type="secondary">{t("settings.users.auth_disabled_hint")}</Text>
</div>
);
}
// If user is not admin, show access denied
if (!hasRole(currentUser, "admin")) {
return (
<div style={{ textAlign: "center", padding: 40 }}>
<UserOutlined style={{ fontSize: 48, color: "#999", marginBottom: 16 }} />
<Title level={4}>{t("settings.users.admin_required")}</Title>
<Text type="secondary">{t("settings.users.admin_required_hint")}</Text>
</div>
);
}
return (
<>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 16 }}>
<Title level={4} style={{ margin: 0 }}>
{t("settings.users.title")}
</Title>
<Button type="primary" icon={<PlusOutlined />} onClick={openCreateModal}>
{t("settings.users.add")}
</Button>
</div>
<Table<User>
dataSource={users}
rowKey="id"
loading={loading}
pagination={false}
columns={[
{
title: t("settings.users.username"),
dataIndex: "username",
key: "username",
render: (username: string, record: User) => (
<Space>
<UserOutlined />
{username}
{record.id === currentUser?.id && <Tag>{t("settings.users.you")}</Tag>}
</Space>
),
},
{
title: t("settings.users.role"),
dataIndex: "role",
key: "role",
render: (role: string) => <Tag color={roleColors[role]}>{role.toUpperCase()}</Tag>,
},
{
title: t("settings.users.status"),
dataIndex: "is_active",
key: "is_active",
render: (isActive: boolean) => (
<Tag color={isActive ? "green" : "red"}>{isActive ? t("settings.users.active") : t("settings.users.inactive")}</Tag>
),
},
{
title: t("settings.users.actions"),
key: "actions",
width: 120,
render: (_: unknown, record: User) => (
<Space>
<Button size="small" icon={<EditOutlined />} onClick={() => openEditModal(record)} />
<Popconfirm
title={t("settings.users.delete_confirm")}
onConfirm={() => handleDelete(record.id)}
disabled={record.id === currentUser?.id}
>
<Button size="small" danger icon={<DeleteOutlined />} disabled={record.id === currentUser?.id} />
</Popconfirm>
</Space>
),
},
]}
/>
<Modal
title={editingUser ? t("settings.users.edit") : t("settings.users.add")}
open={modalOpen}
onCancel={() => {
setModalOpen(false);
setEditingUser(null);
form.resetFields();
}}
onOk={() => form.submit()}
okText={editingUser ? t("buttons.save") : t("buttons.create")}
>
<Form<UserFormValues>
form={form}
layout="vertical"
onFinish={editingUser ? handleUpdate : handleCreate}
>
<Form.Item
name="username"
label={t("settings.users.username")}
rules={[
{ required: !editingUser, message: t("settings.users.username_required") },
{ min: 3, message: t("settings.users.username_min") },
]}
>
<Input disabled={!!editingUser} />
</Form.Item>
<Form.Item
name="password"
label={t("settings.users.password")}
rules={[
{ required: !editingUser, message: t("settings.users.password_required") },
{ min: 6, message: t("settings.users.password_min") },
]}
help={editingUser ? t("settings.users.password_change_hint") : undefined}
>
<Input.Password placeholder={editingUser ? t("settings.users.leave_blank") : undefined} />
</Form.Item>
<Form.Item
name="role"
label={t("settings.users.role")}
rules={[{ required: true }]}
>
<Select>
<Select.Option value="viewer">{t("settings.users.role_viewer")}</Select.Option>
<Select.Option value="operator">{t("settings.users.role_operator")}</Select.Option>
<Select.Option value="editor">{t("settings.users.role_editor")}</Select.Option>
<Select.Option value="admin">{t("settings.users.role_admin")}</Select.Option>
</Select>
</Form.Item>
</Form>
</Modal>
{currentUser && (
<div style={{ marginTop: 24, textAlign: "center" }}>
<Button onClick={logout}>{t("settings.users.logout")}</Button>
</div>
)}
</>
);
};
export default UserSettings;

View File

@@ -0,0 +1,91 @@
import { DeleteOutlined, InboxOutlined, UndoOutlined, EnvironmentOutlined, CloseOutlined } from "@ant-design/icons";
import { useTranslate } from "@refinedev/core";
import { Affix, Button, Card, Space, Modal, Select } from "antd";
import { useState } from "react";
import { useSpoolmanLocations } from "../../../components/otherModels";
interface BatchActionBarProps {
selectedIds: number[];
onArchive: () => void;
onUnarchive: () => void;
onDelete: () => void;
onMove: (location: string) => void;
onClear: () => void;
}
export function BatchActionBar(props: BatchActionBarProps) {
const t = useTranslate();
const [isMoveModalOpen, setIsMoveModalOpen] = useState(false);
const [selectedLocation, setSelectedLocation] = useState<string>();
const locations = useSpoolmanLocations(isMoveModalOpen);
if (props.selectedIds.length === 0) return null;
const handleMoveConfirm = () => {
if (selectedLocation) {
props.onMove(selectedLocation);
setIsMoveModalOpen(false);
setSelectedLocation(undefined);
}
};
return (
<>
<Affix offsetBottom={20}>
<Card
size="small"
style={{
boxShadow: "0 -2px 8px rgba(0,0,0,0.15)",
borderRadius: 8,
maxWidth: 600,
margin: "0 auto",
}}
>
<Space wrap>
<span style={{ fontWeight: 500 }}>
{t("batch.selected", { count: props.selectedIds.length })}
</span>
<Button icon={<InboxOutlined />} onClick={props.onArchive}>
{t("buttons.archive")}
</Button>
<Button icon={<UndoOutlined />} onClick={props.onUnarchive}>
{t("buttons.unArchive")}
</Button>
<Button icon={<EnvironmentOutlined />} onClick={() => setIsMoveModalOpen(true)}>
{t("batch.move")}
</Button>
<Button danger icon={<DeleteOutlined />} onClick={props.onDelete}>
{t("buttons.delete")}
</Button>
<Button type="text" icon={<CloseOutlined />} onClick={props.onClear}>
{t("batch.clear_selection")}
</Button>
</Space>
</Card>
</Affix>
<Modal
title={t("batch.move_title")}
open={isMoveModalOpen}
onOk={handleMoveConfirm}
onCancel={() => {
setIsMoveModalOpen(false);
setSelectedLocation(undefined);
}}
okButtonProps={{ disabled: !selectedLocation }}
>
<p>{t("batch.move_description", { count: props.selectedIds.length })}</p>
<Select
style={{ width: "100%" }}
placeholder={t("spool.fields.location")}
value={selectedLocation}
onChange={setSelectedLocation}
loading={locations.isLoading}
options={locations.data?.map((loc) => ({ label: loc, value: loc })) || []}
showSearch
allowClear
/>
</Modal>
</>
);
}

View File

@@ -0,0 +1,91 @@
import { useTranslate } from "@refinedev/core";
import { Card, Progress, Typography } from "antd";
import { useNavigate } from "react-router";
import SpoolIcon from "../../../components/spoolIcon";
import { ISpool } from "../model";
interface SpoolGalleryProps {
dataSource: ISpool[];
loading?: boolean;
}
export function SpoolGallery({ dataSource, loading }: SpoolGalleryProps) {
const t = useTranslate();
const navigate = useNavigate();
if (loading) {
return (
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(140px, 1fr))", gap: 12 }}>
{Array.from({ length: 12 }).map((_, i) => (
<Card key={i} loading size="small" style={{ height: 160 }} />
))}
</div>
);
}
return (
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(140px, 1fr))", gap: 12 }}>
{dataSource.map((spool) => {
const colorObj = spool.color_hex
? spool.color_hex
: spool.filament.multi_color_hexes
? {
colors: spool.filament.multi_color_hexes.split(","),
vertical: spool.filament.multi_color_direction === "longitudinal",
}
: spool.filament.color_hex;
const remaining = spool.remaining_weight;
const initial = spool.initial_weight ?? spool.filament.weight;
const percent = remaining != null && initial ? Math.round((remaining / initial) * 100) : undefined;
const vendorName = spool.filament.vendor?.name;
const filamentName = spool.filament.name ?? `#${spool.filament.id}`;
const label = vendorName ? `${vendorName} - ${filamentName}` : filamentName;
return (
<Card
key={spool.id}
size="small"
hoverable
onClick={() => navigate(`/spool/show/${spool.id}`)}
style={{
opacity: spool.archived ? 0.5 : 1,
textAlign: "center",
}}
styles={{ body: { padding: "12px 8px" } }}
>
<div style={{ marginBottom: 8 }}>
{colorObj ? <SpoolIcon color={colorObj} size="large" /> : <SpoolIcon color="#cccccc" size="large" />}
</div>
<Typography.Text
ellipsis={{ tooltip: label }}
style={{ fontSize: 12, display: "block", marginBottom: 4 }}
>
{label}
</Typography.Text>
{spool.filament.material && (
<Typography.Text type="secondary" style={{ fontSize: 11 }}>
{spool.filament.material}
</Typography.Text>
)}
{percent !== undefined && (
<Progress
percent={percent}
size="small"
showInfo={false}
strokeColor={percent < 10 ? "#ff4d4f" : percent < 25 ? "#faad14" : "#52c41a"}
style={{ marginTop: 6 }}
/>
)}
{remaining != null && (
<Typography.Text type="secondary" style={{ fontSize: 10 }}>
{Math.round(remaining)}g
</Typography.Text>
)}
</Card>
);
})}
</div>
);
}

View File

@@ -1,11 +1,11 @@
import { MinusOutlined, PlusOutlined } from "@ant-design/icons";
import { Create, useForm } from "@refinedev/antd";
import { HttpError, IResourceComponentsProps, useTranslate } from "@refinedev/core";
import { Alert, Button, DatePicker, Divider, Form, Input, InputNumber, Radio, Select, Typography } from "antd";
import { Alert, Button, ColorPicker, DatePicker, Divider, Form, Input, InputNumber, Radio, Select, Typography } from "antd";
import TextArea from "antd/es/input/TextArea";
import dayjs from "dayjs";
import utc from "dayjs/plugin/utc";
import { useEffect, useMemo, useState } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { ExtraFieldFormItem, ParsedExtras, StringifiedExtras } from "../../components/extraFields";
import { FilamentCreateModal } from "../../components/filamentCreateModal";
import { useSpoolmanLocations } from "../../components/otherModels";
@@ -14,6 +14,7 @@ import "../../utils/overrides.css";
import { formatNumberOnUserInput, numberParser, numberParserAllowEmpty } from "../../utils/parsing";
import { EntityType, useGetFields } from "../../utils/queryFields";
import { getCurrencySymbol, useCurrency } from "../../utils/settings";
import { useFormShortcuts } from "../../utils/useFormShortcuts";
import { createFilamentFromExternal } from "../filaments/functions";
import { IFilament } from "../filaments/model";
import { useGetFilamentSelectOptions } from "./functions";
@@ -50,6 +51,7 @@ export const SpoolCreate: React.FC<IResourceComponentsProps & CreateOrCloneProps
const initialWeightValue = Form.useWatch("initial_weight", form);
const spoolWeightValue = Form.useWatch("spool_weight", form);
const extraWeightValue = Form.useWatch("extra_weight", form);
if (props.mode === "clone") {
// Clear out the values that we don't want to clone
@@ -84,14 +86,23 @@ export const SpoolCreate: React.FC<IResourceComponentsProps & CreateOrCloneProps
refetch: refetchFilaments,
} = useGetFilamentSelectOptions();
// Track pending filament selection after inline creation
const [pendingFilamentId, setPendingFilamentId] = useState<number | null>(null);
const handleFilamentCreated = async (filament: IFilament) => {
// Refetch filament list so the select options include the new filament
setPendingFilamentId(filament.id);
await refetchFilaments();
// Select the newly created filament
form.setFieldValue("filament_id", filament.id);
setIsFilamentModalOpen(false);
};
// Set the filament_id once the new filament appears in the options
useEffect(() => {
if (pendingFilamentId !== null && internalSelectOptions?.some((opt) => opt.value === pendingFilamentId)) {
form.setFieldValue("filament_id", pendingFilamentId);
setPendingFilamentId(null);
}
}, [pendingFilamentId, internalSelectOptions]);
const selectedFilamentID = Form.useWatch("filament_id", form);
const selectedFilament = useMemo(() => {
// id is a number of it's an internal filament, and a string of it's an external filament.
@@ -140,6 +151,12 @@ export const SpoolCreate: React.FC<IResourceComponentsProps & CreateOrCloneProps
redirect(redirectTo);
};
// Keyboard shortcut: Ctrl+S / Cmd+S to save
const handleSaveShortcut = useCallback(() => {
handleSubmit("list");
}, []);
useFormShortcuts(handleSaveShortcut);
// Use useEffect to update the form's initialValues when the extra fields are loaded
// This is necessary because the form is rendered before the extra fields are loaded
useEffect(() => {
@@ -201,10 +218,15 @@ export const SpoolCreate: React.FC<IResourceComponentsProps & CreateOrCloneProps
return initialWeightValue ?? selectedFilament?.weight ?? 0;
};
const getExtraWeight = (): number => {
return extraWeightValue ?? 0;
};
const getGrossWeight = (): number => {
const net_weight = getFilamentWeight();
const spool_weight = getSpoolWeight();
return net_weight + spool_weight;
const extra_weight = getExtraWeight();
return net_weight + spool_weight + extra_weight;
};
const getMeasuredWeight = (): number => {
@@ -395,6 +417,30 @@ export const SpoolCreate: React.FC<IResourceComponentsProps & CreateOrCloneProps
<InputNumber addonAfter="g" precision={1} />
</Form.Item>
<Form.Item
label={t("spool.fields.extra_weight")}
help={t("spool.fields_help.extra_weight")}
name={["extra_weight"]}
rules={[
{
required: false,
type: "number",
min: 0,
},
]}
>
<InputNumber addonAfter="g" precision={1} />
</Form.Item>
<Form.Item
label={t("spool.fields.color_hex")}
help={t("spool.fields_help.color_hex")}
name={["color_hex"]}
getValueFromEvent={(e) => e?.toHex()}
>
<ColorPicker allowClear format="hex" />
</Form.Item>
<Form.Item hidden={true} name={["used_weight"]} initialValue={0}>
<InputNumber value={usedWeight} />
</Form.Item>

View File

@@ -1,6 +1,7 @@
import { LeftOutlined, RightOutlined } from "@ant-design/icons";
import { Edit, useForm } from "@refinedev/antd";
import { HttpError, IResourceComponentsProps, useTranslate } from "@refinedev/core";
import { Alert, DatePicker, Divider, Form, Input, InputNumber, Radio, Select, Typography } from "antd";
import { HttpError, IResourceComponentsProps, useList, useTranslate } from "@refinedev/core";
import { Alert, Button, ColorPicker, DatePicker, Divider, Form, Input, InputNumber, Radio, Select, Typography } from "antd";
import TextArea from "antd/es/input/TextArea";
import { message } from "antd/lib";
import dayjs from "dayjs";
@@ -37,7 +38,14 @@ export const SpoolEdit: React.FC<IResourceComponentsProps> = () => {
const [searchParams, _] = useSearchParams();
const navigate = useNavigate();
const { form, formProps, saveButtonProps } = useForm<ISpool, HttpError, ISpoolRequest, ISpool>({
// Get all spool IDs for prev/next navigation
const { result: allSpools } = useList<ISpool>({
resource: "spool",
pagination: { mode: "off" },
sorters: [{ field: "id", order: "asc" }],
});
const { form, formProps, saveButtonProps, id } = useForm<ISpool, HttpError, ISpoolRequest, ISpool>({
liveMode: "manual",
onLiveEvent() {
// Warn the user if the spool has been updated since the form was opened
@@ -59,6 +67,18 @@ export const SpoolEdit: React.FC<IResourceComponentsProps> = () => {
const initialWeightValue = Form.useWatch("initial_weight", form);
const spoolWeightValue = Form.useWatch("spool_weight", form);
const extraWeightValue = Form.useWatch("extra_weight", form);
// Calculate prev/next IDs for navigation
const { prevId, nextId } = useMemo(() => {
if (!allSpools?.data || !id) return { prevId: null, nextId: null };
const ids = allSpools.data.map((s: ISpool) => s.id);
const currentIndex = ids.indexOf(Number(id));
return {
prevId: currentIndex > 0 ? ids[currentIndex - 1] : null,
nextId: currentIndex < ids.length - 1 ? ids[currentIndex + 1] : null,
};
}, [allSpools, id]);
// Add the filament_id field to the form
if (formProps.initialValues) {
@@ -170,10 +190,15 @@ export const SpoolEdit: React.FC<IResourceComponentsProps> = () => {
return initialWeightValue ?? selectedFilament?.weight ?? 0;
};
const getExtraWeight = (): number => {
return extraWeightValue ?? 0;
};
const getGrossWeight = (): number => {
const net_weight = getFilamentWeight();
const spool_weight = getSpoolWeight();
return net_weight + spool_weight;
const extra_weight = getExtraWeight();
return net_weight + spool_weight + extra_weight;
};
const getMeasuredWeight = (): number => {
@@ -231,7 +256,28 @@ export const SpoolEdit: React.FC<IResourceComponentsProps> = () => {
}, [initialUsedWeight]);
return (
<Edit saveButtonProps={saveButtonProps}>
<Edit
saveButtonProps={saveButtonProps}
headerButtons={({ defaultButtons }) => (
<>
<Button
icon={<LeftOutlined />}
disabled={!prevId}
onClick={() => prevId && navigate(`/spool/edit/${prevId}`)}
>
{t("buttons.prev")}
</Button>
<Button
icon={<RightOutlined />}
disabled={!nextId}
onClick={() => nextId && navigate(`/spool/edit/${nextId}`)}
>
{t("buttons.next")}
</Button>
{defaultButtons}
</>
)}
>
{contextHolder}
<Form {...formProps} layout="vertical">
<Form.Item
@@ -354,6 +400,30 @@ export const SpoolEdit: React.FC<IResourceComponentsProps> = () => {
<InputNumber addonAfter="g" precision={1} />
</Form.Item>
<Form.Item
label={t("spool.fields.extra_weight")}
help={t("spool.fields_help.extra_weight")}
name={["extra_weight"]}
rules={[
{
required: false,
type: "number",
min: 0,
},
]}
>
<InputNumber addonAfter="g" precision={1} />
</Form.Item>
<Form.Item
label={t("spool.fields.color_hex")}
help={t("spool.fields_help.color_hex")}
name={["color_hex"]}
getValueFromEvent={(e) => e?.toHex()}
>
<ColorPicker allowClear format="hex" />
</Form.Item>
<Form.Item hidden={true} name={["used_weight"]} initialValue={0}>
<InputNumber value={usedWeight} />
</Form.Item>

View File

@@ -24,6 +24,36 @@ export async function setSpoolArchived(spool: ISpool, archived: boolean) {
await fetch(request, init);
}
export async function bulkArchiveSpools(spoolIds: number[], archived: boolean) {
const response = await fetch(getAPIURL() + "/spool/bulk/archive", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ spool_ids: spoolIds, archived }),
});
if (!response.ok) throw new Error("Failed to archive spools");
return response.json();
}
export async function bulkDeleteSpools(spoolIds: number[]) {
const response = await fetch(getAPIURL() + "/spool/bulk/delete", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ spool_ids: spoolIds }),
});
if (!response.ok) throw new Error("Failed to delete spools");
return response.json();
}
export async function bulkUpdateSpools(spoolIds: number[], data: { location?: string }) {
const response = await fetch(getAPIURL() + "/spool/bulk/update", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ spool_ids: spoolIds, ...data }),
});
if (!response.ok) throw new Error("Failed to update spools");
return response.json();
}
/**
* Use some spool filament from this spool. Either specify length or weight.
* @param spool The spool

View File

@@ -1,16 +1,18 @@
import {
AppstoreOutlined,
EditOutlined,
EyeOutlined,
FilterOutlined,
InboxOutlined,
PlusSquareOutlined,
PrinterOutlined,
TableOutlined,
ToolOutlined,
ToTopOutlined,
} from "@ant-design/icons";
import { List, useTable } from "@refinedev/antd";
import { IResourceComponentsProps, useInvalidate, useNavigation, useTranslate } from "@refinedev/core";
import { Button, Dropdown, Modal, Table } from "antd";
import { Button, Dropdown, Input, Modal, Table } from "antd";
import dayjs from "dayjs";
import utc from "dayjs/plugin/utc";
import { useCallback, useMemo, useState } from "react";
@@ -18,6 +20,7 @@ import { useNavigate } from "react-router";
import {
Action,
ActionsColumn,
ColorFilterColumn,
CustomFieldColumn,
DateColumn,
FilteredQueryColumn,
@@ -28,6 +31,7 @@ import {
} from "../../components/column";
import { useLiveify } from "../../components/liveify";
import {
useSpoolmanColors,
useSpoolmanFilamentFilter,
useSpoolmanLocations,
useSpoolmanLotNumbers,
@@ -37,8 +41,10 @@ import { removeUndefined } from "../../utils/filtering";
import { EntityType, useGetFields } from "../../utils/queryFields";
import { TableState, useInitialTableState, useSavedState, useStoreInitialState } from "../../utils/saveload";
import { useCurrencyFormatter } from "../../utils/settings";
import { setSpoolArchived, useSpoolAdjustModal } from "./functions";
import { bulkArchiveSpools, bulkDeleteSpools, bulkUpdateSpools, setSpoolArchived, useSpoolAdjustModal } from "./functions";
import { ISpool } from "./model";
import { BatchActionBar } from "./components/BatchActionBar";
import { SpoolGallery } from "./components/SpoolGallery";
dayjs.extend(utc);
@@ -48,6 +54,7 @@ interface ISpoolCollapsed extends ISpool {
"filament.combined_name": string; // Eg. "Prusa - PLA Red"
"filament.id": number;
"filament.material"?: string;
"filament.color_hex"?: string;
}
function collapseSpool(element: ISpool): ISpoolCollapsed {
@@ -65,6 +72,7 @@ function collapseSpool(element: ISpool): ISpoolCollapsed {
"filament.combined_name": filament_name,
"filament.id": element.filament.id,
"filament.material": element.filament.material,
"filament.color_hex": element.color_hex ?? element.filament.color_hex,
};
}
@@ -72,6 +80,7 @@ function translateColumnI18nKey(columnName: string): string {
columnName = columnName.replace(".", "_");
if (columnName === "filament_combined_name") columnName = "filament_name";
else if (columnName === "filament_material") columnName = "material";
else if (columnName === "filament_color_hex") columnName = "color";
return `spool.fields.${columnName}`;
}
@@ -80,8 +89,10 @@ const namespace = "spoolList-v2";
const allColumns: (keyof ISpoolCollapsed & string)[] = [
"id",
"filament.combined_name",
"filament.color_hex",
"filament.material",
"price",
"remaining_value",
"used_weight",
"remaining_weight",
"used_length",
@@ -94,7 +105,8 @@ const allColumns: (keyof ISpoolCollapsed & string)[] = [
"comment",
];
const defaultColumns = allColumns.filter(
(column_id) => ["registered", "used_length", "remaining_length", "lot_nr"].indexOf(column_id) === -1
(column_id) =>
["registered", "used_length", "remaining_length", "lot_nr", "filament.color_hex", "remaining_value"].indexOf(column_id) === -1
);
export const SpoolList: React.FC<IResourceComponentsProps> = () => {
@@ -113,6 +125,13 @@ export const SpoolList: React.FC<IResourceComponentsProps> = () => {
// State for the switch to show archived spools
const [showArchived, setShowArchived] = useSavedState("spoolList-showArchived", false);
// State for search and view mode
const [searchQuery, setSearchQuery] = useState("");
const [viewMode, setViewMode] = useState<"table" | "gallery">("table");
// State for batch selection
const [selectedRowKeys, setSelectedRowKeys] = useState<React.Key[]>([]);
// Fetch data from the API
// To provide the live updates, we use a custom solution (useLiveify) instead of the built-in refine "liveMode" feature.
// This is because the built-in feature does not call the liveProvider subscriber with a list of IDs, but instead
@@ -122,6 +141,7 @@ export const SpoolList: React.FC<IResourceComponentsProps> = () => {
meta: {
queryParams: {
["allow_archived"]: showArchived,
...(searchQuery ? { q: searchQuery } : {}),
},
},
syncWithLocation: false,
@@ -205,6 +225,58 @@ export const SpoolList: React.FC<IResourceComponentsProps> = () => {
}
};
// Batch action handlers
const handleBatchArchive = () => {
confirm({
title: t("batch.archive_confirm"),
content: t("batch.archive_confirm_content", { count: selectedRowKeys.length }),
okText: t("buttons.archive"),
okType: "primary",
cancelText: t("buttons.cancel"),
async onOk() {
await bulkArchiveSpools(selectedRowKeys as number[], true);
invalidate({ resource: "spool", invalidates: ["list"] });
setSelectedRowKeys([]);
},
});
};
const handleBatchUnarchive = async () => {
confirm({
title: t("batch.unarchive_confirm"),
content: t("batch.unarchive_confirm_content", { count: selectedRowKeys.length }),
okText: t("buttons.unArchive"),
okType: "primary",
cancelText: t("buttons.cancel"),
async onOk() {
await bulkArchiveSpools(selectedRowKeys as number[], false);
invalidate({ resource: "spool", invalidates: ["list"] });
setSelectedRowKeys([]);
},
});
};
const handleBatchDelete = () => {
confirm({
title: t("batch.delete_confirm"),
content: t("batch.delete_confirm_content", { count: selectedRowKeys.length }),
okText: t("buttons.delete"),
okType: "danger",
cancelText: t("buttons.cancel"),
async onOk() {
await bulkDeleteSpools(selectedRowKeys as number[]);
invalidate({ resource: "spool", invalidates: ["list"] });
setSelectedRowKeys([]);
},
});
};
const handleBatchMove = async (location: string) => {
await bulkUpdateSpools(selectedRowKeys as number[], { location });
invalidate({ resource: "spool", invalidates: ["list"] });
setSelectedRowKeys([]);
};
if (tableProps.pagination) {
tableProps.pagination.showSizeChanger = true;
}
@@ -289,6 +361,12 @@ export const SpoolList: React.FC<IResourceComponentsProps> = () => {
>
{t("buttons.clearFilters")}
</Button>
<Button
icon={viewMode === "table" ? <AppstoreOutlined /> : <TableOutlined />}
onClick={() => setViewMode(viewMode === "table" ? "gallery" : "table")}
>
{viewMode === "table" ? t("buttons.galleryView") : t("buttons.tableView")}
</Button>
<Dropdown
trigger={["click"]}
menu={{
@@ -326,6 +404,18 @@ export const SpoolList: React.FC<IResourceComponentsProps> = () => {
)}
>
{spoolAdjustModal}
<Input.Search
placeholder={t("spool.search_placeholder")}
allowClear
onSearch={(value) => {
setSearchQuery(value);
setCurrentPage(1);
}}
style={{ marginBottom: 16, maxWidth: 400 }}
/>
{viewMode === "gallery" ? (
<SpoolGallery dataSource={dataSource} loading={tableProps.loading as boolean} />
) : (
<Table
{...tableProps}
sticky
@@ -333,6 +423,11 @@ export const SpoolList: React.FC<IResourceComponentsProps> = () => {
scroll={{ x: "max-content" }}
dataSource={dataSource}
rowKey="id"
rowSelection={{
selectedRowKeys,
onChange: (newSelectedRowKeys) => setSelectedRowKeys(newSelectedRowKeys),
preserveSelectedRowKeys: true,
}}
// Make archived rows greyed out
onRow={(record) => {
if (record.archived) {
@@ -358,7 +453,9 @@ export const SpoolList: React.FC<IResourceComponentsProps> = () => {
id: "filament.combined_name",
i18nkey: "spool.fields.filament_name",
color: (record: ISpoolCollapsed) =>
record.filament.multi_color_hexes
record.color_hex
? record.color_hex
: record.filament.multi_color_hexes
? {
colors: record.filament.multi_color_hexes.split(","),
vertical: record.filament.multi_color_direction === "longitudinal",
@@ -367,6 +464,14 @@ export const SpoolList: React.FC<IResourceComponentsProps> = () => {
dataId: "filament.combined_name",
filterValueQuery: useSpoolmanFilamentFilter(),
}),
ColorFilterColumn({
...commonProps,
id: "filament.color_hex",
i18nkey: "spool.fields.color",
filterValueQuery: useSpoolmanColors(),
colorGetter: (record: ISpoolCollapsed) => record.color_hex ?? record.filament.color_hex,
width: 60,
}),
FilteredQueryColumn({
...commonProps,
id: "filament.material",
@@ -387,6 +492,19 @@ export const SpoolList: React.FC<IResourceComponentsProps> = () => {
return currencyFormatter.format(obj.price);
},
}),
SortedColumn({
...commonProps,
id: "remaining_value",
i18ncat: "spool",
align: "right",
width: 100,
render: (_, obj: ISpoolCollapsed) => {
if (obj.remaining_value === undefined) {
return "";
}
return currencyFormatter.format(obj.remaining_value);
},
}),
NumberColumn({
...commonProps,
id: "used_weight",
@@ -395,6 +513,7 @@ export const SpoolList: React.FC<IResourceComponentsProps> = () => {
unit: "g",
maxDecimals: 0,
width: 110,
sorter: true,
}),
NumberColumn({
...commonProps,
@@ -404,6 +523,7 @@ export const SpoolList: React.FC<IResourceComponentsProps> = () => {
maxDecimals: 0,
defaultText: t("unknown"),
width: 110,
sorter: true,
}),
NumberColumn({
...commonProps,
@@ -412,6 +532,7 @@ export const SpoolList: React.FC<IResourceComponentsProps> = () => {
unit: "mm",
maxDecimals: 0,
width: 120,
sorter: true,
}),
NumberColumn({
...commonProps,
@@ -421,6 +542,7 @@ export const SpoolList: React.FC<IResourceComponentsProps> = () => {
maxDecimals: 0,
defaultText: t("unknown"),
width: 120,
sorter: true,
}),
FilteredQueryColumn({
...commonProps,
@@ -466,6 +588,15 @@ export const SpoolList: React.FC<IResourceComponentsProps> = () => {
ActionsColumn(t("table.actions"), actions),
])}
/>
)}
<BatchActionBar
selectedIds={selectedRowKeys as number[]}
onArchive={handleBatchArchive}
onUnarchive={handleBatchUnarchive}
onDelete={handleBatchDelete}
onMove={handleBatchMove}
onClear={() => setSelectedRowKeys([])}
/>
</List>
);
};

View File

@@ -15,17 +15,33 @@ export interface ISpool {
price?: number;
initial_weight?: number;
spool_weight?: number;
extra_weight?: number;
color_hex?: string;
remaining_weight?: number;
used_weight: number;
remaining_length?: number;
remaining_value?: number;
used_length: number;
location?: string;
lot_nr?: string;
comment?: string;
archived: boolean;
needs_weighing: boolean;
extra: { [key: string]: string };
}
export interface IPrintJob {
id: number;
spool_id: number;
spool?: ISpool;
created: string;
finished?: string;
filename: string;
filament_used_g: number;
filament_used_mm?: number;
status: "pending" | "completed" | "cancelled";
}
// ISpoolParsedExtras is the same as ISpool, but with the extra field parsed into its real types
export type ISpoolParsedExtras = Omit<ISpool, "extra"> & { extra?: { [key: string]: unknown } };

View File

@@ -15,7 +15,7 @@ import { useCurrencyFormatter } from "../../utils/settings";
import { getAPIURL, getBasePath } from "../../utils/url";
import { IFilament } from "../filaments/model";
import { setSpoolArchived, useSpoolAdjustModal } from "./functions";
import { ISpool, ISpoolAdjustment } from "./model";
import { IPrintJob, ISpool, ISpoolAdjustment } from "./model";
dayjs.extend(utc);
@@ -25,6 +25,7 @@ const { confirm } = Modal;
export const SpoolShow: React.FC<IResourceComponentsProps> = () => {
const t = useTranslate();
const extraFields = useGetFields(EntityType.spool);
const filamentExtraFields = useGetFields(EntityType.filament);
const currencyFormatter = useCurrencyFormatter();
const invalidate = useInvalidate();
@@ -45,6 +46,16 @@ export const SpoolShow: React.FC<IResourceComponentsProps> = () => {
enabled: !!record?.id,
});
// Fetch print job history
const { data: printJobsData, isLoading: printJobsLoading } = useQuery({
queryKey: ["spool-print-jobs", record?.id],
queryFn: async () => {
const res = await fetch(`${getAPIURL()}/print-job?spool_id=${record?.id}`);
return (await res.json()) as IPrintJob[];
},
enabled: !!record?.id,
});
const spoolPrice = (item?: ISpool) => {
const price = item?.price ?? item?.filament.price;
if (price === undefined) {
@@ -116,7 +127,9 @@ export const SpoolShow: React.FC<IResourceComponentsProps> = () => {
});
};
const colorObj = record?.filament.multi_color_hexes
const colorObj = record?.color_hex
? record.color_hex
: record?.filament.multi_color_hexes
? {
colors: record.filament.multi_color_hexes.split(","),
vertical: record.filament.multi_color_direction === "longitudinal",
@@ -163,7 +176,7 @@ export const SpoolShow: React.FC<IResourceComponentsProps> = () => {
<Title level={5}>{t("spool.fields.id")}</Title>
<NumberField value={record?.id ?? ""} />
<Title level={5}>{t("spool.fields.filament")}</Title>
{colorObj && <SpoolIcon color={colorObj} size="large" no_margin />}
{colorObj && <SpoolIcon color={colorObj} size="large" no_margin showHex />}
<TextField value={record ? filamentURL(record?.filament) : ""} />
<Title level={5}>{t("spool.fields.price")}</Title>
<TextField value={spoolPrice(record)} />
@@ -235,6 +248,14 @@ export const SpoolShow: React.FC<IResourceComponentsProps> = () => {
{extraFields?.data?.map((field, index) => (
<ExtraFieldDisplay key={index} field={field} value={record?.extra[field.key]} />
))}
{filamentExtraFields?.data && filamentExtraFields.data.length > 0 && (
<>
<Title level={4}>{t("spool.fields.filament_fields")}</Title>
{filamentExtraFields.data.map((field, index) => (
<ExtraFieldDisplay key={index} field={field} value={record?.filament.extra[field.key]} />
))}
</>
)}
<Title level={4} style={{ marginTop: 24 }}>
{t("spool.fields.adjustment_history")}
</Title>
@@ -288,6 +309,69 @@ export const SpoolShow: React.FC<IResourceComponentsProps> = () => {
},
]}
/>
<Title level={4} style={{ marginTop: 24 }}>
{t("spool.fields.print_history")}
</Title>
<Collapse
items={[
{
key: "1",
label: t("spool.fields.show_print_history"),
children: (
<Table<IPrintJob>
dataSource={printJobsData}
loading={printJobsLoading}
rowKey="id"
size="small"
pagination={{ pageSize: 10 }}
columns={[
{
title: t("print_job.fields.created"),
dataIndex: "created",
key: "created",
render: (value: string) => (
<span title={dayjs.utc(value).local().format()}>
{dayjs.utc(value).local().format("YYYY-MM-DD HH:mm:ss")}
</span>
),
},
{
title: t("print_job.fields.filename"),
dataIndex: "filename",
key: "filename",
ellipsis: true,
},
{
title: t("print_job.fields.filament_used"),
dataIndex: "filament_used_g",
key: "filament_used_g",
render: (value: number) => `${value.toFixed(1)} g`,
},
{
title: t("print_job.fields.status"),
dataIndex: "status",
key: "status",
render: (value: string) => t(`print_job.status.${value}`),
},
{
title: t("print_job.fields.finished"),
dataIndex: "finished",
key: "finished",
render: (value: string | null) =>
value ? (
<span title={dayjs.utc(value).local().format()}>
{dayjs.utc(value).local().format("YYYY-MM-DD HH:mm:ss")}
</span>
) : (
"-"
),
},
]}
/>
),
},
]}
/>
</Show>
);
};

View File

@@ -4,9 +4,10 @@ import { Button, Form, Input, InputNumber, Typography } from "antd";
import TextArea from "antd/es/input/TextArea";
import dayjs from "dayjs";
import utc from "dayjs/plugin/utc";
import { useEffect } from "react";
import { useCallback, useEffect } from "react";
import { ExtraFieldFormItem, ParsedExtras, StringifiedExtras } from "../../components/extraFields";
import { EntityType, useGetFields } from "../../utils/queryFields";
import { useFormShortcuts } from "../../utils/useFormShortcuts";
import { IVendor, IVendorParsedExtras } from "./model";
dayjs.extend(utc);
@@ -41,6 +42,12 @@ export const VendorCreate: React.FC<IResourceComponentsProps & CreateOrCloneProp
redirect(redirectTo, (values as IVendor).id);
};
// Keyboard shortcut: Ctrl+S / Cmd+S to save
const handleSaveShortcut = useCallback(() => {
handleSubmit("list");
}, []);
useFormShortcuts(handleSaveShortcut);
// Use useEffect to update the form's initialValues when the extra fields are loaded
// This is necessary because the form is rendered before the extra fields are loaded
useEffect(() => {

View File

@@ -1,9 +1,11 @@
import { LeftOutlined, RightOutlined } from "@ant-design/icons";
import { Edit, useForm } from "@refinedev/antd";
import { HttpError, IResourceComponentsProps, useTranslate } from "@refinedev/core";
import { Alert, DatePicker, Form, Input, InputNumber, message, Typography } from "antd";
import { HttpError, IResourceComponentsProps, useList, useTranslate } from "@refinedev/core";
import { Alert, Button, DatePicker, Form, Input, InputNumber, message, Typography } from "antd";
import TextArea from "antd/es/input/TextArea";
import dayjs from "dayjs";
import React, { useState } from "react";
import React, { useMemo, useState } from "react";
import { useNavigate } from "react-router";
import { ExtraFieldFormItem, ParsedExtras, StringifiedExtras } from "../../components/extraFields";
import { EntityType, useGetFields } from "../../utils/queryFields";
import { IVendor, IVendorParsedExtras } from "./model";
@@ -17,11 +19,19 @@ the form's onFinish method. Form.Item's normalize should do this, but it doesn't
export const VendorEdit: React.FC<IResourceComponentsProps> = () => {
const t = useTranslate();
const navigate = useNavigate();
const [messageApi, contextHolder] = message.useMessage();
const [hasChanged, setHasChanged] = useState(false);
const extraFields = useGetFields(EntityType.vendor);
const { formProps, saveButtonProps } = useForm<IVendor, HttpError, IVendor, IVendor>({
// Get all vendor IDs for prev/next navigation
const { result: allVendors } = useList<IVendor>({
resource: "vendor",
pagination: { mode: "off" },
sorters: [{ field: "id", order: "asc" }],
});
const { formProps, saveButtonProps, id } = useForm<IVendor, HttpError, IVendor, IVendor>({
liveMode: "manual",
onLiveEvent() {
// Warn the user if the vendor has been updated since the form was opened
@@ -30,6 +40,17 @@ export const VendorEdit: React.FC<IResourceComponentsProps> = () => {
},
});
// Calculate prev/next IDs for navigation
const { prevId, nextId } = useMemo(() => {
if (!allVendors?.data || !id) return { prevId: null, nextId: null };
const ids = allVendors.data.map((v: IVendor) => v.id);
const currentIndex = ids.indexOf(Number(id));
return {
prevId: currentIndex > 0 ? ids[currentIndex - 1] : null,
nextId: currentIndex < ids.length - 1 ? ids[currentIndex + 1] : null,
};
}, [allVendors, id]);
// Parse the extra fields from string values into real types
if (formProps.initialValues) {
formProps.initialValues = ParsedExtras(formProps.initialValues);
@@ -49,7 +70,28 @@ export const VendorEdit: React.FC<IResourceComponentsProps> = () => {
};
return (
<Edit saveButtonProps={saveButtonProps}>
<Edit
saveButtonProps={saveButtonProps}
headerButtons={({ defaultButtons }) => (
<>
<Button
icon={<LeftOutlined />}
disabled={!prevId}
onClick={() => prevId && navigate(`/vendor/edit/${prevId}`)}
>
{t("buttons.prev")}
</Button>
<Button
icon={<RightOutlined />}
disabled={!nextId}
onClick={() => nextId && navigate(`/vendor/edit/${nextId}`)}
>
{t("buttons.next")}
</Button>
{defaultButtons}
</>
)}
>
{contextHolder}
<Form {...formProps} layout="vertical">
<Form.Item

View File

@@ -0,0 +1,34 @@
import { axiosInstance } from "@refinedev/simple-rest";
const TOKEN_KEY = "spoolman_auth_token";
/**
* Setup axios interceptor to add auth token to requests.
* Should be called once at app initialization.
*/
export function setupAxiosAuth() {
axiosInstance.interceptors.request.use(
(config) => {
const token = localStorage.getItem(TOKEN_KEY);
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
},
(error) => Promise.reject(error)
);
// Handle 401 responses by clearing token
axiosInstance.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 401) {
// Clear invalid token
localStorage.removeItem(TOKEN_KEY);
// Optionally redirect to login
// window.location.href = "/login";
}
return Promise.reject(error);
}
);
}

View File

@@ -1,3 +1,12 @@
/* Color swatch border for dark mode visibility (#34) */
:root {
--swatch-border-color: rgba(68, 68, 68, 0.3);
}
body[data-theme="dark"] {
--swatch-border-color: rgba(255, 255, 255, 0.3);
}
#qty-input {
text-align: center !important;
}
@@ -6,3 +15,15 @@
.ant-form {
max-width: 800px;
}
/* Sticky Save/Cancel buttons on forms (#38) */
.ant-card-actions {
position: sticky;
bottom: 0;
z-index: 100;
background: var(--ant-color-bg-container, #fff);
border-top: 1px solid var(--ant-color-border-secondary, #f0f0f0);
margin: 0 -24px -24px -24px;
padding: 12px 24px;
border-radius: 0 0 8px 8px;
}

View File

@@ -22,11 +22,6 @@ export enum Pattern {
SPARKLE = "sparkle",
}
export enum ExternalSource {
SPOOLMANDB = "spoolmandb",
FILAMENT_PROFILES_3D = "3dfilamentprofiles",
}
export interface ExternalFilament {
id: string;
manufacturer: string;
@@ -48,7 +43,6 @@ export interface ExternalFilament {
pattern?: Pattern;
translucent: boolean;
glow: boolean;
source?: ExternalSource;
}
export interface ExternalMaterial {

View File

@@ -0,0 +1,26 @@
import { useEffect } from "react";
/**
* Hook to add keyboard shortcuts to forms.
* - Ctrl+S / Cmd+S: Save (calls onSave callback)
* - Escape: Cancel (calls onCancel callback if provided)
*/
export function useFormShortcuts(onSave: () => void, onCancel?: () => void) {
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
// Ctrl+S or Cmd+S to save
if ((e.ctrlKey || e.metaKey) && e.key === "s") {
e.preventDefault();
onSave();
}
// Escape to cancel (if handler provided)
if (e.key === "Escape" && onCancel) {
e.preventDefault();
onCancel();
}
};
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [onSave, onCancel]);
}

View File

@@ -0,0 +1,53 @@
"""add_print_jobs.
Revision ID: c3d4e5f6g7h8
Revises: b2c3d4e5f6g7
Create Date: 2025-01-15 04:00:00.000000
"""
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision = "c3d4e5f6g7h8"
down_revision = "b2c3d4e5f6g7"
branch_labels = None
depends_on = None
def upgrade() -> None:
"""Create print_job table and add needs_weighing to spool."""
# Create print_job table
op.create_table(
"print_job",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("spool_id", sa.Integer(), nullable=False),
sa.Column("created", sa.DateTime(), nullable=False),
sa.Column("finished", sa.DateTime(), nullable=True),
sa.Column("filename", sa.String(length=256), nullable=False),
sa.Column("filament_used_g", sa.Float(), nullable=False),
sa.Column("filament_used_mm", sa.Float(), nullable=True),
sa.Column("status", sa.String(length=16), nullable=False, server_default="pending"),
sa.ForeignKeyConstraint(
["spool_id"],
["spool.id"],
ondelete="CASCADE",
),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(op.f("ix_print_job_id"), "print_job", ["id"], unique=False)
op.create_index(op.f("ix_print_job_spool_id"), "print_job", ["spool_id"], unique=False)
# Add needs_weighing column to spool table
op.add_column(
"spool",
sa.Column("needs_weighing", sa.Boolean(), nullable=True, server_default="0"),
)
def downgrade() -> None:
"""Drop print_job table and remove needs_weighing from spool."""
op.drop_column("spool", "needs_weighing")
op.drop_index(op.f("ix_print_job_spool_id"), table_name="print_job")
op.drop_index(op.f("ix_print_job_id"), table_name="print_job")
op.drop_table("print_job")

View File

@@ -0,0 +1,42 @@
"""Add user authentication table.
Revision ID: d4e5f6g7h8i9
Revises: c3d4e5f6g7h8
Create Date: 2025-01-16 01:00:00.000000
"""
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision = "d4e5f6g7h8i9"
down_revision = "c3d4e5f6g7h8"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"user",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("username", sa.String(length=64), nullable=False),
sa.Column("hashed_password", sa.String(length=128), nullable=False),
sa.Column(
"role",
sa.String(length=16),
nullable=False,
server_default="viewer",
comment="Role: 'admin', 'editor', 'operator', 'viewer'.",
),
sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.text("1")),
sa.Column("created_at", sa.DateTime(), nullable=False),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(op.f("ix_user_id"), "user", ["id"], unique=False)
op.create_index(op.f("ix_user_username"), "user", ["username"], unique=True)
def downgrade() -> None:
op.drop_index(op.f("ix_user_username"), table_name="user")
op.drop_index(op.f("ix_user_id"), table_name="user")
op.drop_table("user")

View File

@@ -0,0 +1,52 @@
"""Add hierarchical locations.
Revision ID: e5f6g7h8i9j0
Revises: d4e5f6g7h8i9
Create Date: 2025-01-21 01:00:00.000000
"""
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision = "e5f6g7h8i9j0"
down_revision = "d4e5f6g7h8i9"
branch_labels = None
depends_on = None
def upgrade() -> None:
"""Create location table and add location_id to spool."""
# Create location table
op.create_table(
"location",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("name", sa.String(length=64), nullable=False),
sa.Column("parent_id", sa.Integer(), nullable=True),
sa.Column("location_type", sa.String(length=32), nullable=True),
sa.Column("description", sa.String(length=256), nullable=True),
sa.ForeignKeyConstraint(
["parent_id"],
["location.id"],
ondelete="SET NULL",
),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(op.f("ix_location_id"), "location", ["id"], unique=False)
op.create_index(op.f("ix_location_parent_id"), "location", ["parent_id"], unique=False)
# Add location_id column to spool table (SQLite compatible - no FK constraint)
op.add_column(
"spool",
sa.Column("location_id", sa.Integer(), nullable=True),
)
op.create_index(op.f("ix_spool_location_id"), "spool", ["location_id"], unique=False)
def downgrade() -> None:
"""Drop location table and remove location_id from spool."""
op.drop_index(op.f("ix_spool_location_id"), table_name="spool")
op.drop_column("spool", "location_id")
op.drop_index(op.f("ix_location_parent_id"), table_name="location")
op.drop_index(op.f("ix_location_id"), table_name="location")
op.drop_table("location")

View File

@@ -0,0 +1,31 @@
"""Add extra_weight field to spool for DryPods, custom holders, etc.
Revision ID: f6g7h8i9j0k1
Revises: e5f6g7h8i9j0
Create Date: 2025-01-21 22:00:00.000000
"""
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision = "f6g7h8i9j0k1"
down_revision = "e5f6g7h8i9j0"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"spool",
sa.Column(
"extra_weight",
sa.Float(),
nullable=True,
comment="Extra weight to account for (DryPods, custom holders, etc.).",
),
)
def downgrade() -> None:
op.drop_column("spool", "extra_weight")

View File

@@ -0,0 +1,25 @@
"""Add spool color_hex override.
Revision ID: g7h8i9j0k1l2
Revises: f6g7h8i9j0k1
Create Date: 2025-01-22 01:00:00.000000
"""
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision = "g7h8i9j0k1l2"
down_revision = "f6g7h8i9j0k1"
branch_labels = None
depends_on = None
def upgrade() -> None:
"""Add color_hex column to spool table."""
op.add_column("spool", sa.Column("color_hex", sa.String(length=8), nullable=True))
def downgrade() -> None:
"""Remove color_hex column from spool table."""
op.drop_column("spool", "color_hex")

261
pdm.lock generated
View File

@@ -5,7 +5,7 @@
groups = ["default", "dev"]
strategy = ["inherit_metadata"]
lock_version = "4.5.0"
content_hash = "sha256:e641a2fe4fc6e91347172c0b5f04e36d3ab15e85c888b0b476899cd75613c887"
content_hash = "sha256:0c5ca0d0ddb245de3c1fad7fc4ae1fd3397f9aa6a7b233793b8d12d8a64a79e8"
[[metadata.targets]]
requires_python = ">=3.9,<=3.12"
@@ -172,6 +172,51 @@ files = [
{file = "asyncpg-0.31.0.tar.gz", hash = "sha256:c989386c83940bfbd787180f2b1519415e2d3d6277a70d9d0f0145ac73500735"},
]
[[package]]
name = "bcrypt"
version = "5.0.0"
requires_python = ">=3.8"
summary = "Modern password hashing for your software and your servers"
groups = ["default"]
marker = "python_version <= \"3.12\" and python_version >= \"3.9\""
files = [
{file = "bcrypt-5.0.0-cp38-abi3-macosx_10_12_universal2.whl", hash = "sha256:fc746432b951e92b58317af8e0ca746efe93e66555f1b40888865ef5bf56446b"},
{file = "bcrypt-5.0.0-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c2388ca94ffee269b6038d48747f4ce8df0ffbea43f31abfa18ac72f0218effb"},
{file = "bcrypt-5.0.0-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:560ddb6ec730386e7b3b26b8b4c88197aaed924430e7b74666a586ac997249ef"},
{file = "bcrypt-5.0.0-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:d79e5c65dcc9af213594d6f7f1fa2c98ad3fc10431e7aa53c176b441943efbdd"},
{file = "bcrypt-5.0.0-cp38-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2b732e7d388fa22d48920baa267ba5d97cca38070b69c0e2d37087b381c681fd"},
{file = "bcrypt-5.0.0-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:0c8e093ea2532601a6f686edbc2c6b2ec24131ff5c52f7610dd64fa4553b5464"},
{file = "bcrypt-5.0.0-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:5b1589f4839a0899c146e8892efe320c0fa096568abd9b95593efac50a87cb75"},
{file = "bcrypt-5.0.0-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:89042e61b5e808b67daf24a434d89bab164d4de1746b37a8d173b6b14f3db9ff"},
{file = "bcrypt-5.0.0-cp38-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:e3cf5b2560c7b5a142286f69bde914494b6d8f901aaa71e453078388a50881c4"},
{file = "bcrypt-5.0.0-cp38-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:f632fd56fc4e61564f78b46a2269153122db34988e78b6be8b32d28507b7eaeb"},
{file = "bcrypt-5.0.0-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:801cad5ccb6b87d1b430f183269b94c24f248dddbbc5c1f78b6ed231743e001c"},
{file = "bcrypt-5.0.0-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:3cf67a804fc66fc217e6914a5635000259fbbbb12e78a99488e4d5ba445a71eb"},
{file = "bcrypt-5.0.0-cp38-abi3-win32.whl", hash = "sha256:3abeb543874b2c0524ff40c57a4e14e5d3a66ff33fb423529c88f180fd756538"},
{file = "bcrypt-5.0.0-cp38-abi3-win_amd64.whl", hash = "sha256:35a77ec55b541e5e583eb3436ffbbf53b0ffa1fa16ca6782279daf95d146dcd9"},
{file = "bcrypt-5.0.0-cp38-abi3-win_arm64.whl", hash = "sha256:cde08734f12c6a4e28dc6755cd11d3bdfea608d93d958fffbe95a7026ebe4980"},
{file = "bcrypt-5.0.0-cp39-abi3-macosx_10_12_universal2.whl", hash = "sha256:0c418ca99fd47e9c59a301744d63328f17798b5947b0f791e9af3c1c499c2d0a"},
{file = "bcrypt-5.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ddb4e1500f6efdd402218ffe34d040a1196c072e07929b9820f363a1fd1f4191"},
{file = "bcrypt-5.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7aeef54b60ceddb6f30ee3db090351ecf0d40ec6e2abf41430997407a46d2254"},
{file = "bcrypt-5.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:f0ce778135f60799d89c9693b9b398819d15f1921ba15fe719acb3178215a7db"},
{file = "bcrypt-5.0.0-cp39-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a71f70ee269671460b37a449f5ff26982a6f2ba493b3eabdd687b4bf35f875ac"},
{file = "bcrypt-5.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:f8429e1c410b4073944f03bd778a9e066e7fad723564a52ff91841d278dfc822"},
{file = "bcrypt-5.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:edfcdcedd0d0f05850c52ba3127b1fce70b9f89e0fe5ff16517df7e81fa3cbb8"},
{file = "bcrypt-5.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:611f0a17aa4a25a69362dcc299fda5c8a3d4f160e2abb3831041feb77393a14a"},
{file = "bcrypt-5.0.0-cp39-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:db99dca3b1fdc3db87d7c57eac0c82281242d1eabf19dcb8a6b10eb29a2e72d1"},
{file = "bcrypt-5.0.0-cp39-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:5feebf85a9cefda32966d8171f5db7e3ba964b77fdfe31919622256f80f9cf42"},
{file = "bcrypt-5.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:3ca8a166b1140436e058298a34d88032ab62f15aae1c598580333dc21d27ef10"},
{file = "bcrypt-5.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:61afc381250c3182d9078551e3ac3a41da14154fbff647ddf52a769f588c4172"},
{file = "bcrypt-5.0.0-cp39-abi3-win32.whl", hash = "sha256:64d7ce196203e468c457c37ec22390f1a61c85c6f0b8160fd752940ccfb3a683"},
{file = "bcrypt-5.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:64ee8434b0da054d830fa8e89e1c8bf30061d539044a39524ff7dec90481e5c2"},
{file = "bcrypt-5.0.0-cp39-abi3-win_arm64.whl", hash = "sha256:f2347d3534e76bf50bca5500989d6c1d05ed64b440408057a37673282c654927"},
{file = "bcrypt-5.0.0-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:7edda91d5ab52b15636d9c30da87d2cc84f426c72b9dba7a9b4fe142ba11f534"},
{file = "bcrypt-5.0.0-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:046ad6db88edb3c5ece4369af997938fb1c19d6a699b9c1b27b0db432faae4c4"},
{file = "bcrypt-5.0.0-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:dcd58e2b3a908b5ecc9b9df2f0085592506ac2d5110786018ee5e160f28e0911"},
{file = "bcrypt-5.0.0-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:6b8f520b61e8781efee73cba14e3e8c9556ccfb375623f4f97429544734545b4"},
{file = "bcrypt-5.0.0.tar.gz", hash = "sha256:f748f7c2d6fd375cc93d3fba7ef4a9e3a092421b8dbf34d8d4dc06be9492dfdd"},
]
[[package]]
name = "black"
version = "25.1.0"
@@ -221,6 +266,69 @@ files = [
{file = "certifi-2026.1.4.tar.gz", hash = "sha256:ac726dd470482006e014ad384921ed6438c457018f4b3d204aea4281258b2120"},
]
[[package]]
name = "cffi"
version = "2.0.0"
requires_python = ">=3.9"
summary = "Foreign Function Interface for Python calling C code."
groups = ["default"]
marker = "python_version <= \"3.12\" and python_version >= \"3.9\" and platform_python_implementation != \"PyPy\""
dependencies = [
"pycparser; implementation_name != \"PyPy\"",
]
files = [
{file = "cffi-2.0.0-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:0cf2d91ecc3fcc0625c2c530fe004f82c110405f101548512cce44322fa8ac44"},
{file = "cffi-2.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f73b96c41e3b2adedc34a7356e64c8eb96e03a3782b535e043a986276ce12a49"},
{file = "cffi-2.0.0-cp310-cp310-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:53f77cbe57044e88bbd5ed26ac1d0514d2acf0591dd6bb02a3ae37f76811b80c"},
{file = "cffi-2.0.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3e837e369566884707ddaf85fc1744b47575005c0a229de3327f8f9a20f4efeb"},
{file = "cffi-2.0.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:5eda85d6d1879e692d546a078b44251cdd08dd1cfb98dfb77b670c97cee49ea0"},
{file = "cffi-2.0.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9332088d75dc3241c702d852d4671613136d90fa6881da7d770a483fd05248b4"},
{file = "cffi-2.0.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc7de24befaeae77ba923797c7c87834c73648a05a4bde34b3b7e5588973a453"},
{file = "cffi-2.0.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:cf364028c016c03078a23b503f02058f1814320a56ad535686f90565636a9495"},
{file = "cffi-2.0.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e11e82b744887154b182fd3e7e8512418446501191994dbf9c9fc1f32cc8efd5"},
{file = "cffi-2.0.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8ea985900c5c95ce9db1745f7933eeef5d314f0565b27625d9a10ec9881e1bfb"},
{file = "cffi-2.0.0-cp310-cp310-win32.whl", hash = "sha256:1f72fb8906754ac8a2cc3f9f5aaa298070652a0ffae577e0ea9bd480dc3c931a"},
{file = "cffi-2.0.0-cp310-cp310-win_amd64.whl", hash = "sha256:b18a3ed7d5b3bd8d9ef7a8cb226502c6bf8308df1525e1cc676c3680e7176739"},
{file = "cffi-2.0.0-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:b4c854ef3adc177950a8dfc81a86f5115d2abd545751a304c5bcf2c2c7283cfe"},
{file = "cffi-2.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2de9a304e27f7596cd03d16f1b7c72219bd944e99cc52b84d0145aefb07cbd3c"},
{file = "cffi-2.0.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:baf5215e0ab74c16e2dd324e8ec067ef59e41125d3eade2b863d294fd5035c92"},
{file = "cffi-2.0.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:730cacb21e1bdff3ce90babf007d0a0917cc3e6492f336c2f0134101e0944f93"},
{file = "cffi-2.0.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6824f87845e3396029f3820c206e459ccc91760e8fa24422f8b0c3d1731cbec5"},
{file = "cffi-2.0.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9de40a7b0323d889cf8d23d1ef214f565ab154443c42737dfe52ff82cf857664"},
{file = "cffi-2.0.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8941aaadaf67246224cee8c3803777eed332a19d909b47e29c9842ef1e79ac26"},
{file = "cffi-2.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a05d0c237b3349096d3981b727493e22147f934b20f6f125a3eba8f994bec4a9"},
{file = "cffi-2.0.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:94698a9c5f91f9d138526b48fe26a199609544591f859c870d477351dc7b2414"},
{file = "cffi-2.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5fed36fccc0612a53f1d4d9a816b50a36702c28a2aa880cb8a122b3466638743"},
{file = "cffi-2.0.0-cp311-cp311-win32.whl", hash = "sha256:c649e3a33450ec82378822b3dad03cc228b8f5963c0c12fc3b1e0ab940f768a5"},
{file = "cffi-2.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:66f011380d0e49ed280c789fbd08ff0d40968ee7b665575489afa95c98196ab5"},
{file = "cffi-2.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:c6638687455baf640e37344fe26d37c404db8b80d037c3d29f58fe8d1c3b194d"},
{file = "cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d"},
{file = "cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c"},
{file = "cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe"},
{file = "cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062"},
{file = "cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e"},
{file = "cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037"},
{file = "cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba"},
{file = "cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94"},
{file = "cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187"},
{file = "cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18"},
{file = "cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5"},
{file = "cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6"},
{file = "cffi-2.0.0-cp39-cp39-macosx_10_13_x86_64.whl", hash = "sha256:fe562eb1a64e67dd297ccc4f5addea2501664954f2692b69a76449ec7913ecbf"},
{file = "cffi-2.0.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:de8dad4425a6ca6e4e5e297b27b5c824ecc7581910bf9aee86cb6835e6812aa7"},
{file = "cffi-2.0.0-cp39-cp39-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:4647afc2f90d1ddd33441e5b0e85b16b12ddec4fca55f0d9671fef036ecca27c"},
{file = "cffi-2.0.0-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3f4d46d8b35698056ec29bca21546e1551a205058ae1a181d871e278b0b28165"},
{file = "cffi-2.0.0-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:e6e73b9e02893c764e7e8d5bb5ce277f1a009cd5243f8228f75f842bf937c534"},
{file = "cffi-2.0.0-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:cb527a79772e5ef98fb1d700678fe031e353e765d1ca2d409c92263c6d43e09f"},
{file = "cffi-2.0.0-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:61d028e90346df14fedc3d1e5441df818d095f3b87d286825dfcbd6459b7ef63"},
{file = "cffi-2.0.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:0f6084a0ea23d05d20c3edcda20c3d006f9b6f3fefeac38f59262e10cef47ee2"},
{file = "cffi-2.0.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:1cd13c99ce269b3ed80b417dcd591415d3372bcac067009b6e0f59c7d4015e65"},
{file = "cffi-2.0.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:89472c9762729b5ae1ad974b777416bfda4ac5642423fa93bd57a09204712322"},
{file = "cffi-2.0.0-cp39-cp39-win32.whl", hash = "sha256:2081580ebb843f759b9f617314a24ed5738c51d2aee65d31e02f6f7a2b97707a"},
{file = "cffi-2.0.0-cp39-cp39-win_amd64.whl", hash = "sha256:b882b3df248017dba09d6b16defe9b5c407fe32fc7c65a9c69798e6175601be9"},
{file = "cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529"},
]
[[package]]
name = "cfgv"
version = "3.4.0"
@@ -261,6 +369,46 @@ files = [
{file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"},
]
[[package]]
name = "cryptography"
version = "43.0.3"
requires_python = ">=3.7"
summary = "cryptography is a package which provides cryptographic recipes and primitives to Python developers."
groups = ["default"]
marker = "python_version <= \"3.12\" and python_version >= \"3.9\""
dependencies = [
"cffi>=1.12; platform_python_implementation != \"PyPy\"",
]
files = [
{file = "cryptography-43.0.3-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:bf7a1932ac4176486eab36a19ed4c0492da5d97123f1406cf15e41b05e787d2e"},
{file = "cryptography-43.0.3-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:63efa177ff54aec6e1c0aefaa1a241232dcd37413835a9b674b6e3f0ae2bfd3e"},
{file = "cryptography-43.0.3-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7e1ce50266f4f70bf41a2c6dc4358afadae90e2a1e5342d3c08883df1675374f"},
{file = "cryptography-43.0.3-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:443c4a81bb10daed9a8f334365fe52542771f25aedaf889fd323a853ce7377d6"},
{file = "cryptography-43.0.3-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:74f57f24754fe349223792466a709f8e0c093205ff0dca557af51072ff47ab18"},
{file = "cryptography-43.0.3-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9762ea51a8fc2a88b70cf2995e5675b38d93bf36bd67d91721c309df184f49bd"},
{file = "cryptography-43.0.3-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:81ef806b1fef6b06dcebad789f988d3b37ccaee225695cf3e07648eee0fc6b73"},
{file = "cryptography-43.0.3-cp37-abi3-win32.whl", hash = "sha256:cbeb489927bd7af4aa98d4b261af9a5bc025bd87f0e3547e11584be9e9427be2"},
{file = "cryptography-43.0.3-cp37-abi3-win_amd64.whl", hash = "sha256:f46304d6f0c6ab8e52770addfa2fc41e6629495548862279641972b6215451cd"},
{file = "cryptography-43.0.3-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:8ac43ae87929a5982f5948ceda07001ee5e83227fd69cf55b109144938d96984"},
{file = "cryptography-43.0.3-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:846da004a5804145a5f441b8530b4bf35afbf7da70f82409f151695b127213d5"},
{file = "cryptography-43.0.3-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f996e7268af62598f2fc1204afa98a3b5712313a55c4c9d434aef49cadc91d4"},
{file = "cryptography-43.0.3-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:f7b178f11ed3664fd0e995a47ed2b5ff0a12d893e41dd0494f406d1cf555cab7"},
{file = "cryptography-43.0.3-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:c2e6fc39c4ab499049df3bdf567f768a723a5e8464816e8f009f121a5a9f4405"},
{file = "cryptography-43.0.3-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:e1be4655c7ef6e1bbe6b5d0403526601323420bcf414598955968c9ef3eb7d16"},
{file = "cryptography-43.0.3-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:df6b6c6d742395dd77a23ea3728ab62f98379eff8fb61be2744d4679ab678f73"},
{file = "cryptography-43.0.3-cp39-abi3-win32.whl", hash = "sha256:d56e96520b1020449bbace2b78b603442e7e378a9b3bd68de65c782db1507995"},
{file = "cryptography-43.0.3-cp39-abi3-win_amd64.whl", hash = "sha256:0c580952eef9bf68c4747774cde7ec1d85a6e61de97281f2dba83c7d2c806362"},
{file = "cryptography-43.0.3-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:d03b5621a135bffecad2c73e9f4deb1a0f977b9a8ffe6f8e002bf6c9d07b918c"},
{file = "cryptography-43.0.3-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:a2a431ee15799d6db9fe80c82b055bae5a752bef645bba795e8e52687c69efe3"},
{file = "cryptography-43.0.3-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:281c945d0e28c92ca5e5930664c1cefd85efe80e5c0d2bc58dd63383fda29f83"},
{file = "cryptography-43.0.3-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:f18c716be16bc1fea8e95def49edf46b82fccaa88587a45f8dc0ff6ab5d8e0a7"},
{file = "cryptography-43.0.3-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:4a02ded6cd4f0a5562a8887df8b3bd14e822a90f97ac5e544c162899bc467664"},
{file = "cryptography-43.0.3-pp39-pypy39_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:53a583b6637ab4c4e3591a15bc9db855b8d9dee9a669b550f311480acab6eb08"},
{file = "cryptography-43.0.3-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:1ec0bcf7e17c0c5669d881b1cd38c4972fade441b27bda1051665faaa89bdcaa"},
{file = "cryptography-43.0.3-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:2ce6fae5bdad59577b44e4dfed356944fbf1d925269114c28be377692643b4ff"},
{file = "cryptography-43.0.3.tar.gz", hash = "sha256:315b9001266a492a6ff443b61238f956b214dbec9910a081ba5b6646a055a805"},
]
[[package]]
name = "distlib"
version = "0.4.0"
@@ -272,6 +420,21 @@ files = [
{file = "distlib-0.4.0.tar.gz", hash = "sha256:feec40075be03a04501a973d81f633735b4b69f98b05450592310c0f401a4e0d"},
]
[[package]]
name = "ecdsa"
version = "0.19.1"
requires_python = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,>=2.6"
summary = "ECDSA cryptographic signature library (pure python)"
groups = ["default"]
marker = "python_version <= \"3.12\" and python_version >= \"3.9\""
dependencies = [
"six>=1.9.0",
]
files = [
{file = "ecdsa-0.19.1-py2.py3-none-any.whl", hash = "sha256:30638e27cf77b7e15c4c4cc1973720149e1033827cfd00661ca5c8cc0cdb24c3"},
{file = "ecdsa-0.19.1.tar.gz", hash = "sha256:478cba7b62555866fcb3bb3fe985e06decbdb68ef55713c4e5ab98c57d508e61"},
]
[[package]]
name = "exceptiongroup"
version = "1.3.1"
@@ -679,6 +842,17 @@ files = [
{file = "packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f"},
]
[[package]]
name = "passlib"
version = "1.7.4"
summary = "comprehensive password hashing framework supporting over 30 schemes"
groups = ["default"]
marker = "python_version <= \"3.12\" and python_version >= \"3.9\""
files = [
{file = "passlib-1.7.4-py2.py3-none-any.whl", hash = "sha256:aa6bca462b8d8bda89c70b382f0c298a20b5560af6cbfa2dce410c0a2fb669f1"},
{file = "passlib-1.7.4.tar.gz", hash = "sha256:defd50f72b65c5402ab2c573830a6978e5f202ad0d984793c8dde2c4152ebe04"},
]
[[package]]
name = "pathspec"
version = "1.0.3"
@@ -801,6 +975,30 @@ files = [
{file = "psycopg2_binary-2.9.11-cp39-cp39-win_amd64.whl", hash = "sha256:875039274f8a2361e5207857899706da840768e2a775bf8c65e82f60b197df02"},
]
[[package]]
name = "pyasn1"
version = "0.6.1"
requires_python = ">=3.8"
summary = "Pure-Python implementation of ASN.1 types and DER/BER/CER codecs (X.208)"
groups = ["default"]
marker = "python_version <= \"3.12\" and python_version >= \"3.9\""
files = [
{file = "pyasn1-0.6.1-py3-none-any.whl", hash = "sha256:0d632f46f2ba09143da3a8afe9e33fb6f92fa2320ab7e886e2d0f7672af84629"},
{file = "pyasn1-0.6.1.tar.gz", hash = "sha256:6f580d2bdd84365380830acf45550f2511469f673cb4a5ae3857a3170128b034"},
]
[[package]]
name = "pycparser"
version = "2.23"
requires_python = ">=3.8"
summary = "C parser in Python"
groups = ["default"]
marker = "implementation_name != \"PyPy\" and platform_python_implementation != \"PyPy\" and python_version <= \"3.12\" and python_version >= \"3.9\""
files = [
{file = "pycparser-2.23-py3-none-any.whl", hash = "sha256:e5c6e8d3fbad53479cab09ac03729e0a9faf2bee3db8208a550daf5af81a5934"},
{file = "pycparser-2.23.tar.gz", hash = "sha256:78816d4f24add8f10a06d6f05b4d424ad9e96cfebf68a4ddc99c65c0720d00c2"},
]
[[package]]
name = "pydantic"
version = "2.12.5"
@@ -950,6 +1148,40 @@ files = [
{file = "pytest_asyncio-0.25.3.tar.gz", hash = "sha256:fc1da2cf9f125ada7e710b4ddad05518d4cee187ae9412e9ac9271003497f07a"},
]
[[package]]
name = "python-jose"
version = "3.5.0"
requires_python = ">=3.9"
summary = "JOSE implementation in Python"
groups = ["default"]
marker = "python_version <= \"3.12\" and python_version >= \"3.9\""
dependencies = [
"ecdsa!=0.15",
"pyasn1>=0.5.0",
"rsa!=4.1.1,!=4.4,<5.0,>=4.0",
]
files = [
{file = "python_jose-3.5.0-py2.py3-none-any.whl", hash = "sha256:abd1202f23d34dfad2c3d28cb8617b90acf34132c7afd60abd0b0b7d3cb55771"},
{file = "python_jose-3.5.0.tar.gz", hash = "sha256:fb4eaa44dbeb1c26dcc69e4bd7ec54a1cb8dd64d3b4d81ef08d90ff453f2b01b"},
]
[[package]]
name = "python-jose"
version = "3.5.0"
extras = ["cryptography"]
requires_python = ">=3.9"
summary = "JOSE implementation in Python"
groups = ["default"]
marker = "python_version <= \"3.12\" and python_version >= \"3.9\""
dependencies = [
"cryptography>=3.4.0",
"python-jose==3.5.0",
]
files = [
{file = "python_jose-3.5.0-py2.py3-none-any.whl", hash = "sha256:abd1202f23d34dfad2c3d28cb8617b90acf34132c7afd60abd0b0b7d3cb55771"},
{file = "python_jose-3.5.0.tar.gz", hash = "sha256:fb4eaa44dbeb1c26dcc69e4bd7ec54a1cb8dd64d3b4d81ef08d90ff453f2b01b"},
]
[[package]]
name = "python-multipart"
version = "0.0.20"
@@ -1010,6 +1242,21 @@ files = [
{file = "pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f"},
]
[[package]]
name = "rsa"
version = "4.9.1"
requires_python = "<4,>=3.6"
summary = "Pure-Python RSA implementation"
groups = ["default"]
marker = "python_version <= \"3.12\" and python_version >= \"3.9\""
dependencies = [
"pyasn1>=0.1.3",
]
files = [
{file = "rsa-4.9.1-py3-none-any.whl", hash = "sha256:68635866661c6836b8d39430f97a996acbd61bfa49406748ea243539fe239762"},
{file = "rsa-4.9.1.tar.gz", hash = "sha256:e7bdbfdb5497da4c07dfd35530e1a902659db6ff241e39d9953cad06ebd0ae75"},
]
[[package]]
name = "ruff"
version = "0.11.0"
@@ -1065,6 +1312,18 @@ files = [
{file = "setuptools-76.1.0.tar.gz", hash = "sha256:4959b9ad482ada2ba2320c8f1a8d8481d4d8d668908a7a1b84d987375cd7f5bd"},
]
[[package]]
name = "six"
version = "1.17.0"
requires_python = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7"
summary = "Python 2 and 3 compatibility utilities"
groups = ["default"]
marker = "python_version <= \"3.12\" and python_version >= \"3.9\""
files = [
{file = "six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274"},
{file = "six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81"},
]
[[package]]
name = "sqlalchemy"
version = "2.0.45"

View File

@@ -1,6 +1,6 @@
[project]
name = "spoolman"
version = "0.23C.1"
version = "0.23C.5"
description = "A web service that keeps track of 3D printing spools."
authors = [
{ name = "Tony", email = "tony@nastynas.xyz" },
@@ -26,6 +26,9 @@ dependencies = [
"httpx~=0.28",
"hishel~=0.1",
"python-multipart~=0.0",
"passlib>=1.7",
"bcrypt>=4.0",
"python-jose[cryptography]>=3.3",
]
requires-python = ">=3.9,<3.13"

214
scripts/slicer_post_process.py Executable file
View File

@@ -0,0 +1,214 @@
#!/usr/bin/env python3
"""
Elegoo Slicer / Orca Slicer Post-Processing Script for Spoolman Integration.
This script parses G-code files to extract filament usage information and creates
a pending print job in Spoolman. After the print completes, use the Spoolman
Print Queue UI to confirm the print (deducts filament) or cancel it (flags for weighing).
Usage:
1. In Elegoo Slicer, go to Printer Settings > Post-processing Scripts
2. Add the path to this script: /path/to/slicer_post_process.py
3. Configure the environment variables below or create a config file
Configuration:
Set these environment variables:
- SPOOLMAN_URL: Your Spoolman instance URL (e.g., http://192.168.0.11:8000)
- SPOOLMAN_SPOOL_ID: Default spool ID to use (or set per-filament in slicer)
Or create a file at ~/.config/spoolman/slicer.conf:
[spoolman]
url = http://192.168.0.11:8000
spool_id = 1
The script will:
1. Read the G-code file passed as argument
2. Parse Elegoo/Orca slicer comments for filament usage
3. Create a pending print job in Spoolman via API
4. The job appears in the Print Queue for confirmation after printing
"""
import configparser
import os
import re
import sys
import urllib.request
import json
from pathlib import Path
def get_config() -> tuple[str, int]:
"""Get Spoolman URL and default spool ID from environment or config file."""
url = os.environ.get("SPOOLMAN_URL")
spool_id = os.environ.get("SPOOLMAN_SPOOL_ID")
if not url:
config_path = Path.home() / ".config" / "spoolman" / "slicer.conf"
if config_path.exists():
config = configparser.ConfigParser()
config.read(config_path)
url = config.get("spoolman", "url", fallback=None)
spool_id = config.get("spoolman", "spool_id", fallback=None)
if not url:
print("ERROR: SPOOLMAN_URL not set. Set environment variable or create config file.", file=sys.stderr)
sys.exit(1)
if not spool_id:
print("ERROR: SPOOLMAN_SPOOL_ID not set. Set environment variable or create config file.", file=sys.stderr)
sys.exit(1)
return url.rstrip("/"), int(spool_id)
def parse_gcode(filepath: str) -> dict:
"""
Parse G-code file for filament usage information.
Looks for Elegoo/Orca Slicer comments like:
; total filament used [g] = 12.34
; filament used [mm] = 4567.89
; filament used [g] = 12.34
Also checks for PrusaSlicer format:
; filament used [mm] = 4567.89
; filament used [g] = 12.34
"""
result = {
"filename": Path(filepath).name,
"filament_used_g": None,
"filament_used_mm": None,
}
# Patterns for different slicers
patterns = {
# Elegoo/Orca format
"total_filament_g": re.compile(r";\s*total filament used \[g\]\s*=\s*([\d.]+)", re.IGNORECASE),
"filament_g": re.compile(r";\s*filament used \[g\]\s*=\s*([\d.]+)", re.IGNORECASE),
"filament_mm": re.compile(r";\s*filament used \[mm\]\s*=\s*([\d.]+)", re.IGNORECASE),
# Alternative formats
"filament_weight": re.compile(r";\s*(?:total )?filament (?:weight|cost)\s*=\s*([\d.]+)\s*g", re.IGNORECASE),
"filament_length": re.compile(r";\s*(?:total )?filament\s*=\s*([\d.]+)\s*mm", re.IGNORECASE),
}
try:
with open(filepath, "r", encoding="utf-8", errors="ignore") as f:
# Only read first 500 lines and last 500 lines for efficiency
lines = f.readlines()
header = lines[:500]
footer = lines[-500:] if len(lines) > 500 else []
check_lines = header + footer
for line in check_lines:
if not line.startswith(";"):
continue
# Check each pattern
for key, pattern in patterns.items():
match = pattern.search(line)
if match:
value = float(match.group(1))
if "g" in key or "weight" in key:
if result["filament_used_g"] is None:
result["filament_used_g"] = value
elif "mm" in key or "length" in key:
if result["filament_used_mm"] is None:
result["filament_used_mm"] = value
except Exception as e:
print(f"Warning: Error parsing G-code: {e}", file=sys.stderr)
return result
def create_print_job(url: str, spool_id: int, gcode_info: dict) -> bool:
"""Create a pending print job in Spoolman."""
if gcode_info["filament_used_g"] is None:
print("Warning: Could not find filament weight in G-code. Skipping job creation.", file=sys.stderr)
return False
api_url = f"{url}/api/v1/print-job"
payload = {
"spool_id": spool_id,
"filename": gcode_info["filename"],
"filament_used_g": gcode_info["filament_used_g"],
}
if gcode_info["filament_used_mm"] is not None:
payload["filament_used_mm"] = gcode_info["filament_used_mm"]
try:
data = json.dumps(payload).encode("utf-8")
req = urllib.request.Request(
api_url,
data=data,
headers={"Content-Type": "application/json"},
method="POST",
)
with urllib.request.urlopen(req, timeout=10) as response:
if response.status in (200, 201):
result = json.loads(response.read().decode("utf-8"))
print(f"Created print job #{result['id']}: {gcode_info['filename']} ({gcode_info['filament_used_g']:.1f}g)")
return True
else:
print(f"Error: API returned status {response.status}", file=sys.stderr)
return False
except urllib.error.HTTPError as e:
error_body = e.read().decode("utf-8") if e.fp else ""
print(f"Error: API request failed: {e.code} {e.reason} - {error_body}", file=sys.stderr)
return False
except urllib.error.URLError as e:
print(f"Error: Could not connect to Spoolman: {e.reason}", file=sys.stderr)
return False
except Exception as e:
print(f"Error: {e}", file=sys.stderr)
return False
def main():
"""Main entry point."""
if len(sys.argv) < 2:
print("Usage: slicer_post_process.py <gcode_file>", file=sys.stderr)
print(__doc__)
sys.exit(1)
gcode_path = sys.argv[1]
if not os.path.exists(gcode_path):
print(f"Error: G-code file not found: {gcode_path}", file=sys.stderr)
sys.exit(1)
# Get configuration
url, spool_id = get_config()
# Parse G-code
print(f"Parsing G-code: {gcode_path}")
gcode_info = parse_gcode(gcode_path)
if gcode_info["filament_used_g"]:
print(f" Filament: {gcode_info['filament_used_g']:.2f}g", end="")
if gcode_info["filament_used_mm"]:
print(f" ({gcode_info['filament_used_mm']:.1f}mm)")
else:
print()
else:
print(" Warning: Could not parse filament usage from G-code")
# Create print job
success = create_print_job(url, spool_id, gcode_info)
if success:
print(f"Print job created in Spoolman ({url})")
print("After printing, use the Print Queue to confirm or cancel the job.")
else:
print("Failed to create print job. The print will proceed without tracking.", file=sys.stderr)
# Always exit 0 so the slicer continues with the print
sys.exit(0)
if __name__ == "__main__":
main()

374
spoolman/api/v1/auth.py Normal file
View File

@@ -0,0 +1,374 @@
"""Authentication API endpoints."""
import json
import logging
from datetime import datetime
from typing import Annotated, Optional
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.security import OAuth2PasswordRequestForm
from pydantic import BaseModel, Field
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from spoolman import auth
from spoolman.auth import (
RequireAdmin,
Role,
Token,
UserResponse,
authenticate_user,
create_access_token,
get_current_user,
get_password_hash,
get_user_by_username,
is_auth_enabled,
set_auth_enabled_cache,
)
from spoolman.database import models
from spoolman.database.database import get_db_session
from spoolman.settings import SETTINGS
logger = logging.getLogger(__name__)
router = APIRouter(
prefix="/auth",
tags=["auth"],
)
class AuthStatusResponse(BaseModel):
"""Authentication status response."""
enabled: bool
has_users: bool
class UserCreateRequest(BaseModel):
"""Request to create a new user."""
username: str = Field(..., min_length=3, max_length=64)
password: str = Field(..., min_length=6)
role: Role = Role.VIEWER
class UserUpdateRequest(BaseModel):
"""Request to update a user."""
password: Optional[str] = Field(None, min_length=6)
role: Optional[Role] = None
is_active: Optional[bool] = None
class SetupRequest(BaseModel):
"""Initial setup request to create admin user."""
username: str = Field(..., min_length=3, max_length=64)
password: str = Field(..., min_length=6)
@router.get(
"/status",
name="Get auth status",
description="Check if authentication is enabled and if any users exist.",
response_model=AuthStatusResponse,
)
async def get_auth_status(
db: Annotated[AsyncSession, Depends(get_db_session)],
) -> AuthStatusResponse:
"""Get authentication status."""
result = await db.execute(select(func.count()).select_from(models.User))
user_count = result.scalar() or 0
return AuthStatusResponse(
enabled=is_auth_enabled(),
has_users=user_count > 0,
)
@router.post(
"/setup",
name="Initial setup",
description="Create the first admin user. Only works if no users exist.",
response_model=UserResponse,
)
async def setup(
body: SetupRequest,
db: Annotated[AsyncSession, Depends(get_db_session)],
) -> UserResponse:
"""Create the first admin user during initial setup."""
# Check if any users exist
result = await db.execute(select(func.count()).select_from(models.User))
user_count = result.scalar() or 0
if user_count > 0:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Setup already completed. Users already exist.",
)
# Create admin user
user = models.User(
username=body.username,
hashed_password=get_password_hash(body.password),
role=Role.ADMIN.value,
is_active=True,
created_at=datetime.utcnow(),
)
db.add(user)
await db.commit()
await db.refresh(user)
logger.info("Initial admin user created: %s", body.username)
return UserResponse.from_db(user)
@router.post(
"/login",
name="Login",
description="Authenticate and get an access token.",
response_model=Token,
)
async def login(
form_data: Annotated[OAuth2PasswordRequestForm, Depends()],
db: Annotated[AsyncSession, Depends(get_db_session)],
) -> Token:
"""Authenticate user and return JWT token."""
if not is_auth_enabled():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Authentication is not enabled",
)
user = await authenticate_user(db, form_data.username, form_data.password)
if not user:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect username or password",
headers={"WWW-Authenticate": "Bearer"},
)
access_token = create_access_token(
data={"sub": user.username, "role": user.role}
)
return Token(access_token=access_token)
@router.get(
"/me",
name="Get current user",
description="Get the currently authenticated user's info.",
response_model=UserResponse,
)
async def get_me(
current_user: Annotated[Optional[models.User], Depends(get_current_user)],
) -> UserResponse:
"""Get current user info."""
if current_user is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Not authenticated",
)
return UserResponse.from_db(current_user)
# User management endpoints (admin only)
@router.get(
"/users",
name="List users",
description="Get all users (admin only).",
response_model=list[UserResponse],
dependencies=[RequireAdmin],
)
async def list_users(
db: Annotated[AsyncSession, Depends(get_db_session)],
) -> list[UserResponse]:
"""List all users."""
result = await db.execute(select(models.User).order_by(models.User.username))
users = result.scalars().all()
return [UserResponse.from_db(u) for u in users]
@router.post(
"/users",
name="Create user",
description="Create a new user (admin only).",
response_model=UserResponse,
dependencies=[RequireAdmin],
)
async def create_user(
body: UserCreateRequest,
db: Annotated[AsyncSession, Depends(get_db_session)],
) -> UserResponse:
"""Create a new user."""
# Check if username exists
existing = await get_user_by_username(db, body.username)
if existing:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Username already exists",
)
user = models.User(
username=body.username,
hashed_password=get_password_hash(body.password),
role=body.role.value,
is_active=True,
created_at=datetime.utcnow(),
)
db.add(user)
await db.commit()
await db.refresh(user)
logger.info("User created: %s (role: %s)", body.username, body.role.value)
return UserResponse.from_db(user)
@router.patch(
"/users/{user_id}",
name="Update user",
description="Update a user (admin only).",
response_model=UserResponse,
dependencies=[RequireAdmin],
)
async def update_user(
user_id: int,
body: UserUpdateRequest,
db: Annotated[AsyncSession, Depends(get_db_session)],
) -> UserResponse:
"""Update a user."""
user = await db.get(models.User, user_id)
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User not found",
)
if body.password is not None:
user.hashed_password = get_password_hash(body.password)
if body.role is not None:
user.role = body.role.value
if body.is_active is not None:
user.is_active = body.is_active
await db.commit()
await db.refresh(user)
logger.info("User updated: %s", user.username)
return UserResponse.from_db(user)
@router.delete(
"/users/{user_id}",
name="Delete user",
description="Delete a user (admin only).",
dependencies=[RequireAdmin],
)
async def delete_user(
user_id: int,
current_user: Annotated[Optional[models.User], Depends(get_current_user)],
db: Annotated[AsyncSession, Depends(get_db_session)],
) -> dict:
"""Delete a user."""
user = await db.get(models.User, user_id)
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User not found",
)
# Prevent self-deletion
if current_user and user.id == current_user.id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Cannot delete your own account",
)
await db.delete(user)
await db.commit()
logger.info("User deleted: %s", user.username)
return {"message": "User deleted"}
class AuthToggleRequest(BaseModel):
"""Request to enable or disable authentication."""
enabled: bool
password: Optional[str] = Field(None, description="Admin password required when disabling auth")
@router.post(
"/toggle",
name="Toggle authentication",
description="Enable or disable authentication. Requires admin password to disable.",
response_model=AuthStatusResponse,
)
async def toggle_auth(
body: AuthToggleRequest,
db: Annotated[AsyncSession, Depends(get_db_session)],
) -> AuthStatusResponse:
"""Enable or disable authentication."""
# Get current user count
result = await db.execute(select(func.count()).select_from(models.User))
user_count = result.scalar() or 0
if body.enabled:
# Enabling auth
if user_count == 0:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Cannot enable auth without users. Use /auth/setup to create the first admin user.",
)
else:
# Disabling auth - require admin password for security
if not body.password:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Admin password required to disable authentication",
)
# Find any admin user and verify password
result = await db.execute(
select(models.User).where(models.User.role == Role.ADMIN.value, models.User.is_active == True) # noqa: E712
)
admin_users = result.scalars().all()
if not admin_users:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="No active admin user found",
)
# Check if password matches any admin
password_valid = False
for admin in admin_users:
if auth.verify_password(body.password, admin.hashed_password):
password_valid = True
break
if not password_valid:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid admin password",
)
# Update the setting in database
setting = models.Setting(
key="auth_enabled",
value=json.dumps(body.enabled),
last_updated=datetime.utcnow().replace(microsecond=0),
)
await db.merge(setting)
await db.commit()
# Update the cache
set_auth_enabled_cache(body.enabled)
logger.info("Authentication %s", "enabled" if body.enabled else "disabled")
return AuthStatusResponse(
enabled=body.enabled,
has_users=user_count > 0,
)

View File

@@ -366,7 +366,7 @@ async def find(
else:
filter_by_ids = None
db_items, total_count = await filament.find(
db_items, total_count, spool_stats = await filament.find(
db=db,
ids=filter_by_ids,
vendor_name=vendor_name if vendor_name is not None else vendor_name_old,
@@ -383,7 +383,14 @@ async def find(
# Set x-total-count header for pagination
return JSONResponse(
content=jsonable_encoder(
(Filament.from_db(db_item) for db_item in db_items),
(
Filament.from_db(
db_item,
spool_count=spool_stats.get(db_item.id, (0, 0))[0],
total_remaining_weight=spool_stats.get(db_item.id, (0, 0))[1],
)
for db_item in db_items
),
exclude_none=True,
),
headers={"x-total-count": str(total_count)},

164
spoolman/api/v1/location.py Normal file
View File

@@ -0,0 +1,164 @@
"""Location related endpoints."""
from typing import Annotated, Optional
from fastapi import APIRouter, Depends
from pydantic import BaseModel, Field
from sqlalchemy.ext.asyncio import AsyncSession
from spoolman.api.v1.models import Message
from spoolman.database import location
from spoolman.database.database import get_db_session
from spoolman.database import models
router = APIRouter(
prefix="/location",
tags=["location"],
)
class LocationResponse(BaseModel):
"""Location response model."""
id: int
name: str
parent_id: Optional[int] = None
location_type: Optional[str] = None
description: Optional[str] = None
full_path: Optional[str] = None # Only populated for single-item requests
spool_count: Optional[int] = None # Only populated when requested
@staticmethod
def from_db(item: models.Location, full_path: Optional[str] = None) -> "LocationResponse":
return LocationResponse(
id=item.id,
name=item.name,
parent_id=item.parent_id,
location_type=item.location_type,
description=item.description,
full_path=full_path,
)
class LocationTreeNode(BaseModel):
"""Location tree node for nested structure."""
id: int
name: str
location_type: Optional[str] = None
description: Optional[str] = None
children: list["LocationTreeNode"] = []
class LocationParameters(BaseModel):
"""Parameters for creating a location."""
name: str = Field(max_length=64, description="Location name.")
parent_id: Optional[int] = Field(None, description="Parent location ID.")
location_type: Optional[str] = Field(None, max_length=32, description="Type: room, shelf, bin, etc.")
description: Optional[str] = Field(None, max_length=256, description="Optional description.")
class LocationUpdateParameters(BaseModel):
"""Parameters for updating a location."""
name: Optional[str] = Field(None, max_length=64, description="Location name.")
parent_id: Optional[int] = Field(None, description="Parent location ID.")
location_type: Optional[str] = Field(None, max_length=32, description="Type: room, shelf, bin, etc.")
description: Optional[str] = Field(None, max_length=256, description="Optional description.")
@router.get(
"",
name="Find locations",
description="Get all locations as a flat list.",
response_model=list[LocationResponse],
)
async def find_locations(
db: Annotated[AsyncSession, Depends(get_db_session)],
) -> list[LocationResponse]:
db_items = await location.find_all(db=db)
return [LocationResponse.from_db(item) for item in db_items]
@router.get(
"/tree",
name="Get location tree",
description="Get all locations as a nested tree structure.",
response_model=list[LocationTreeNode],
)
async def get_location_tree(
db: Annotated[AsyncSession, Depends(get_db_session)],
) -> list[dict]:
return await location.get_tree(db=db)
@router.get(
"/{location_id}",
name="Get location",
description="Get a specific location by ID.",
response_model=LocationResponse,
responses={404: {"model": Message}},
)
async def get_location(
db: Annotated[AsyncSession, Depends(get_db_session)],
location_id: int,
) -> LocationResponse:
db_item = await location.get_by_id(db=db, location_id=location_id)
full_path = await location.get_full_path(db=db, location_id=location_id)
spool_count = await location.get_spool_count(db=db, location_id=location_id)
response = LocationResponse.from_db(db_item, full_path=full_path)
response.spool_count = spool_count
return response
@router.post(
"",
name="Create location",
description="Create a new location.",
response_model=LocationResponse,
responses={400: {"model": Message}, 404: {"model": Message}},
)
async def create_location(
db: Annotated[AsyncSession, Depends(get_db_session)],
body: LocationParameters,
) -> LocationResponse:
db_item = await location.create(
db=db,
name=body.name,
parent_id=body.parent_id,
location_type=body.location_type,
description=body.description,
)
return LocationResponse.from_db(db_item)
@router.patch(
"/{location_id}",
name="Update location",
description="Update a location.",
response_model=LocationResponse,
responses={400: {"model": Message}, 404: {"model": Message}},
)
async def update_location(
db: Annotated[AsyncSession, Depends(get_db_session)],
location_id: int,
body: LocationUpdateParameters,
) -> LocationResponse:
data = body.model_dump(exclude_unset=True)
db_item = await location.update(db=db, location_id=location_id, data=data)
return LocationResponse.from_db(db_item)
@router.delete(
"/{location_id}",
name="Delete location",
description="Delete a location. Spools using this location will have their location_id set to null.",
responses={404: {"model": Message}},
)
async def delete_location(
db: Annotated[AsyncSession, Depends(get_db_session)],
location_id: int,
) -> Message:
await location.delete(db=db, location_id=location_id)
return Message(message="Success!")

View File

@@ -221,9 +221,25 @@ class Filament(BaseModel):
"Query the /fields endpoint for more details about the fields."
),
)
spool_count: Optional[int] = Field(
None,
ge=0,
description="Number of spools of this filament (excluding archived). Computed field.",
examples=[5],
)
total_remaining_weight: Optional[float] = Field(
None,
ge=0,
description="Total remaining weight across all spools of this filament (excluding archived), in grams. Computed field.",
examples=[2500.0],
)
@staticmethod
def from_db(item: models.Filament) -> "Filament":
def from_db(
item: models.Filament,
spool_count: Optional[int] = None,
total_remaining_weight: Optional[float] = None,
) -> "Filament":
"""Create a new Pydantic filament object from a database filament object."""
return Filament(
id=item.id,
@@ -251,6 +267,8 @@ class Filament(BaseModel):
),
external_id=item.external_id,
extra={field.key: field.value for field in item.extra},
spool_count=spool_count,
total_remaining_weight=total_remaining_weight,
)
@@ -293,6 +311,18 @@ class Spool(BaseModel):
description=("Weight of an empty spool (tare weight)."),
examples=[246],
)
extra_weight: Optional[float] = Field(
default=None,
ge=0,
description=("Extra weight to account for, such as DryPods, custom spool holders, etc."),
examples=[50],
)
color_hex: Optional[str] = Field(
default=None,
max_length=8,
description="Spool-level color override (hex code without #). Overrides the filament color if set.",
examples=["FF0000"],
)
used_weight: float = Field(
ge=0,
description="Consumed weight of filament from the spool in grams.",
@@ -307,6 +337,12 @@ class Spool(BaseModel):
),
examples=[5612.4],
)
remaining_value: Optional[float] = Field(
default=None,
ge=0,
description="Estimated remaining value of filament on the spool based on remaining weight and price.",
examples=[12.50],
)
used_length: float = Field(
ge=0,
description="Consumed length of filament from the spool in millimeters.",
@@ -331,6 +367,10 @@ class Spool(BaseModel):
examples=[""],
)
archived: bool = Field(description="Whether this spool is archived and should not be used anymore.")
needs_weighing: bool = Field(
default=False,
description="Whether this spool needs manual weigh-in (e.g., after a cancelled print).",
)
extra: dict[str, str] = Field(
description=(
"Extra fields for this spool. All values are JSON-encoded data. "
@@ -347,14 +387,14 @@ class Spool(BaseModel):
remaining_length: Optional[float] = None
if item.initial_weight is not None:
remaining_weight = max(item.initial_weight - item.used_weight, 0)
remaining_weight = item.initial_weight - item.used_weight
remaining_length = length_from_weight(
weight=remaining_weight,
density=filament.density,
diameter=filament.diameter,
)
elif filament.weight is not None:
remaining_weight = max(filament.weight - item.used_weight, 0)
remaining_weight = filament.weight - item.used_weight
remaining_length = length_from_weight(
weight=remaining_weight,
density=filament.density,
@@ -367,6 +407,13 @@ class Spool(BaseModel):
diameter=filament.diameter,
)
# Calculate remaining value based on remaining weight and price
remaining_value: Optional[float] = None
price = item.price if item.price is not None else filament.price
initial_weight = item.initial_weight if item.initial_weight is not None else filament.weight
if remaining_weight is not None and price is not None and initial_weight is not None and initial_weight > 0:
remaining_value = round((remaining_weight / initial_weight) * price, 2)
return Spool(
id=item.id,
registered=item.registered,
@@ -376,14 +423,17 @@ class Spool(BaseModel):
price=item.price,
initial_weight=item.initial_weight,
spool_weight=item.spool_weight,
extra_weight=item.extra_weight,
used_weight=item.used_weight,
used_length=used_length,
remaining_weight=remaining_weight,
remaining_length=remaining_length,
remaining_value=remaining_value,
location=item.location,
lot_nr=item.lot_nr,
comment=item.comment,
archived=item.archived if item.archived is not None else False,
needs_weighing=item.needs_weighing if item.needs_weighing is not None else False,
extra={field.key: field.value for field in item.extra},
)
@@ -421,6 +471,7 @@ class Info(BaseModel):
db_type: str = Field(examples=["sqlite"])
git_commit: Optional[str] = Field(None, examples=["a1b2c3d"])
build_date: Optional[SpoolmanDateTime] = Field(None, examples=["2021-01-01T00:00:00Z"])
auth_enabled: bool = Field(default=False, examples=[False])
class HealthCheck(BaseModel):

View File

@@ -1,13 +1,17 @@
"""Filament related endpoints."""
import logging
from typing import Annotated
from datetime import datetime, timedelta
from typing import Annotated, Optional
from fastapi import APIRouter, Depends
import httpx
import sqlalchemy
from fastapi import APIRouter, Depends, Query
from pydantic import BaseModel, Field, RootModel
from sqlalchemy import func
from sqlalchemy.ext.asyncio import AsyncSession
from spoolman.database import filament, spool
from spoolman.database import filament, models, spool
from spoolman.database.database import get_db_session
logger = logging.getLogger(__name__)
@@ -47,6 +51,33 @@ async def find_materials(
return await filament.find_materials(db=db)
@router.get(
"/color",
name="Find colors",
description="Get a list of all filament colors (hex codes).",
response_model_exclude_none=True,
responses={
200: {
"description": "A list of all filament color hex codes.",
"content": {
"application/json": {
"example": [
"FF0000",
"00FF00",
"0000FF",
],
},
},
},
},
)
async def find_colors(
*,
db: Annotated[AsyncSession, Depends(get_db_session)],
) -> list[str]:
return await filament.find_colors(db=db)
@router.get(
"/article-number",
name="Find article numbers",
@@ -146,3 +177,299 @@ async def rename_location(
logger.info("Renaming location %s to %s", location, body.name)
await spool.rename_location(db=db, current_name=location, new_name=body.name)
return body.name
class UsageDataPoint(BaseModel):
"""A single data point for usage analytics."""
date: str = Field(description="Date in YYYY-MM-DD format.")
weight_used: float = Field(description="Total weight used in grams (positive value).")
@router.get(
"/analytics/usage",
name="Get usage analytics",
description="Get filament usage data aggregated by day for the specified period.",
response_model=list[UsageDataPoint],
)
async def get_usage_analytics(
*,
db: Annotated[AsyncSession, Depends(get_db_session)],
days: Annotated[
int,
Query(description="Number of days to look back.", ge=1, le=365),
] = 30,
) -> list[UsageDataPoint]:
"""Get usage analytics aggregated by day."""
# Calculate the start date
start_date = datetime.utcnow() - timedelta(days=days)
# Query adjustments grouped by date
# Only include negative values (filament used, not added)
stmt = (
sqlalchemy.select(
func.date(models.SpoolAdjustment.timestamp).label("date"),
func.sum(
sqlalchemy.case(
(models.SpoolAdjustment.value < 0, -models.SpoolAdjustment.value),
else_=0,
)
).label("weight_used"),
)
.where(models.SpoolAdjustment.timestamp >= start_date)
.where(models.SpoolAdjustment.adjustment_type == "weight")
.group_by(func.date(models.SpoolAdjustment.timestamp))
.order_by(func.date(models.SpoolAdjustment.timestamp))
)
result = await db.execute(stmt)
rows = result.all()
# Fill in missing dates with 0
data_by_date = {str(row.date): row.weight_used for row in rows}
all_dates = []
current_date = start_date.date()
end_date = datetime.utcnow().date()
while current_date <= end_date:
date_str = current_date.strftime("%Y-%m-%d")
all_dates.append(
UsageDataPoint(
date=date_str,
weight_used=data_by_date.get(date_str, 0),
)
)
current_date += timedelta(days=1)
return all_dates
class MaterialUsage(BaseModel):
"""Usage breakdown by material."""
material: str = Field(description="Material name.")
weight_used: float = Field(description="Total weight used in grams.")
@router.get(
"/analytics/by-material",
name="Get usage by material",
description="Get filament usage breakdown by material type.",
response_model=list[MaterialUsage],
)
async def get_usage_by_material(
*,
db: Annotated[AsyncSession, Depends(get_db_session)],
days: Annotated[
Optional[int],
Query(description="Number of days to look back. If not set, returns all-time usage.", ge=1, le=365),
] = None,
) -> list[MaterialUsage]:
"""Get usage breakdown by material."""
# Join adjustments with spools and filaments to get material
stmt = (
sqlalchemy.select(
func.coalesce(models.Filament.material, "Unknown").label("material"),
func.sum(
sqlalchemy.case(
(models.SpoolAdjustment.value < 0, -models.SpoolAdjustment.value),
else_=0,
)
).label("weight_used"),
)
.join(models.Spool, models.SpoolAdjustment.spool_id == models.Spool.id)
.join(models.Filament, models.Spool.filament_id == models.Filament.id)
.where(models.SpoolAdjustment.adjustment_type == "weight")
)
if days is not None:
start_date = datetime.utcnow() - timedelta(days=days)
stmt = stmt.where(models.SpoolAdjustment.timestamp >= start_date)
stmt = stmt.group_by(models.Filament.material).order_by(func.sum(-models.SpoolAdjustment.value).desc())
result = await db.execute(stmt)
rows = result.all()
return [MaterialUsage(material=row.material or "Unknown", weight_used=row.weight_used) for row in rows]
class CostByMaterial(BaseModel):
"""Cost breakdown by material."""
material: str = Field(description="Material name.")
total_cost: float = Field(description="Total cost spent on this material.")
total_weight: float = Field(description="Total initial weight purchased in grams.")
class CostStats(BaseModel):
"""Overall cost statistics."""
total_spent: float = Field(description="Sum of all spool purchase prices.")
total_weight_purchased: float = Field(description="Total initial weight of all spools in grams.")
avg_cost_per_kg: Optional[float] = Field(description="Average cost per kilogram of filament.")
cost_by_material: list[CostByMaterial] = Field(description="Cost breakdown by material type.")
@router.get(
"/analytics/cost",
name="Get cost statistics",
description="Get overall cost tracking statistics across all spools.",
response_model=CostStats,
)
async def get_cost_stats(
*,
db: Annotated[AsyncSession, Depends(get_db_session)],
) -> CostStats:
"""Get cost statistics including total spent and cost per kg."""
# Use COALESCE(spool.price, filament.price) for effective price
effective_price = func.coalesce(models.Spool.price, models.Filament.price)
effective_weight = func.coalesce(models.Spool.initial_weight, models.Filament.weight)
# Total spent and total weight across all spools (including archived)
totals_stmt = (
sqlalchemy.select(
func.sum(effective_price).label("total_spent"),
func.sum(effective_weight).label("total_weight"),
)
.join(models.Filament, models.Spool.filament_id == models.Filament.id)
.where(effective_price.isnot(None))
)
totals_result = await db.execute(totals_stmt)
totals_row = totals_result.one()
total_spent = totals_row.total_spent or 0.0
total_weight_purchased = totals_row.total_weight or 0.0
avg_cost_per_kg = (total_spent / total_weight_purchased * 1000) if total_weight_purchased > 0 else None
# Cost by material
material_stmt = (
sqlalchemy.select(
func.coalesce(models.Filament.material, "Unknown").label("material"),
func.sum(effective_price).label("total_cost"),
func.sum(effective_weight).label("total_weight"),
)
.join(models.Filament, models.Spool.filament_id == models.Filament.id)
.where(effective_price.isnot(None))
.group_by(models.Filament.material)
.order_by(func.sum(effective_price).desc())
)
material_result = await db.execute(material_stmt)
material_rows = material_result.all()
cost_by_material = [
CostByMaterial(
material=row.material or "Unknown",
total_cost=row.total_cost or 0.0,
total_weight=row.total_weight or 0.0,
)
for row in material_rows
]
return CostStats(
total_spent=total_spent,
total_weight_purchased=total_weight_purchased,
avg_cost_per_kg=avg_cost_per_kg,
cost_by_material=cost_by_material,
)
# --- filamentcolors.xyz integration ---
FILAMENTCOLORS_API = "https://filamentcolors.xyz/api"
class FilamentColorSwatch(BaseModel):
"""A color swatch from filamentcolors.xyz."""
id: int
color_name: str
hex_color: Optional[str] = None
manufacturer_name: str
filament_type: str
image_front: Optional[str] = None
class FilamentColorManufacturer(BaseModel):
"""A manufacturer from filamentcolors.xyz."""
id: int
name: str
class FilamentColorSearchResult(BaseModel):
"""Search results from filamentcolors.xyz."""
count: int
swatches: list[FilamentColorSwatch]
@router.get(
"/filamentcolors/manufacturers",
name="Get filamentcolors.xyz manufacturers",
description="List all manufacturers from filamentcolors.xyz.",
response_model=list[FilamentColorManufacturer],
)
async def get_filamentcolors_manufacturers() -> list[FilamentColorManufacturer]:
"""Proxy manufacturer list from filamentcolors.xyz."""
async with httpx.AsyncClient(timeout=10) as client:
resp = await client.get(f"{FILAMENTCOLORS_API}/manufacturer/", params={"format": "json", "limit": 500})
resp.raise_for_status()
data = resp.json()
return [
FilamentColorManufacturer(id=m["id"], name=m["name"])
for m in data.get("results", [])
]
@router.get(
"/filamentcolors/search",
name="Search filamentcolors.xyz swatches",
description="Search color swatches from filamentcolors.xyz by manufacturer and/or color name.",
response_model=FilamentColorSearchResult,
)
async def search_filamentcolors(
*,
manufacturer: Annotated[
Optional[str],
Query(description="Manufacturer name to filter by."),
] = None,
color: Annotated[
Optional[str],
Query(description="Color name to search for."),
] = None,
filament_type: Annotated[
Optional[str],
Query(description="Filament type to filter by (e.g., PLA, PETG)."),
] = None,
limit: Annotated[int, Query(ge=1, le=100)] = 50,
page: Annotated[int, Query(ge=1)] = 1,
) -> FilamentColorSearchResult:
"""Proxy swatch search from filamentcolors.xyz."""
params: dict = {"format": "json", "limit": limit, "page": page}
if manufacturer:
params["manufacturer__name"] = manufacturer
if color:
params["color_name__icontains"] = color
if filament_type:
params["filament_type__name__icontains"] = filament_type
async with httpx.AsyncClient(timeout=10) as client:
resp = await client.get(f"{FILAMENTCOLORS_API}/swatch/", params=params)
resp.raise_for_status()
data = resp.json()
swatches = [
FilamentColorSwatch(
id=s["id"],
color_name=s.get("color_name", ""),
hex_color=s.get("hex_color"),
manufacturer_name=s.get("manufacturer", {}).get("name", ""),
filament_type=s.get("filament_type", {}).get("name", ""),
image_front=s.get("image_front"),
)
for s in data.get("results", [])
]
return FilamentColorSearchResult(count=data.get("count", 0), swatches=swatches)

View File

@@ -0,0 +1,199 @@
"""Print job API endpoints."""
from datetime import datetime
from typing import Annotated, Optional
from fastapi import APIRouter, Depends, Query
from fastapi.encoders import jsonable_encoder
from fastapi.responses import JSONResponse
from pydantic import BaseModel, Field
from sqlalchemy.ext.asyncio import AsyncSession
from spoolman.api.v1.models import Message, Spool
from spoolman.database import print_job
from spoolman.database.database import get_db_session
from spoolman.database.models import PrintJob as DBPrintJob
router = APIRouter(
prefix="/print-job",
tags=["print-job"],
)
class PrintJobResponse(BaseModel):
"""Print job response model."""
id: int
spool_id: int
spool: Optional[Spool] = None
created: datetime
finished: Optional[datetime] = None
filename: str
filament_used_g: float
filament_used_mm: Optional[float] = None
status: str
@classmethod
def from_db(cls, db_item: DBPrintJob) -> "PrintJobResponse":
"""Create from database model."""
return cls(
id=db_item.id,
spool_id=db_item.spool_id,
spool=Spool.from_db(db_item.spool) if db_item.spool else None,
created=db_item.created,
finished=db_item.finished,
filename=db_item.filename,
filament_used_g=db_item.filament_used_g,
filament_used_mm=db_item.filament_used_mm,
status=db_item.status,
)
class CreatePrintJobRequest(BaseModel):
"""Request to create a print job."""
spool_id: int = Field(..., description="ID of the spool to use.")
filename: str = Field(..., max_length=256, description="G-code filename.")
filament_used_g: float = Field(..., ge=0, description="Estimated filament usage in grams.")
filament_used_mm: Optional[float] = Field(None, ge=0, description="Estimated filament usage in mm.")
class CompletePrintJobRequest(BaseModel):
"""Request to complete a print job."""
comment: Optional[str] = Field(None, max_length=256, description="Optional comment for adjustment history.")
@router.get(
"",
name="List print jobs",
description="Get a list of print jobs.",
response_model_exclude_none=True,
responses={200: {"model": list[PrintJobResponse]}},
)
async def find(
*,
db: Annotated[AsyncSession, Depends(get_db_session)],
status: Annotated[
Optional[str],
Query(description="Filter by status: 'pending', 'completed', 'cancelled'."),
] = None,
spool_id: Annotated[
Optional[int],
Query(description="Filter by spool ID."),
] = None,
limit: Annotated[
Optional[int],
Query(description="Maximum number of items to return."),
] = None,
offset: Annotated[
int,
Query(description="Offset for pagination."),
] = 0,
) -> JSONResponse:
"""Find print jobs."""
db_items, total_count = await print_job.find(
db=db,
status=status,
spool_id=spool_id,
limit=limit,
offset=offset,
)
return JSONResponse(
content=jsonable_encoder(
[PrintJobResponse.from_db(item) for item in db_items],
exclude_none=True,
),
headers={"x-total-count": str(total_count)},
)
@router.get(
"/{job_id}",
name="Get print job",
description="Get a specific print job.",
response_model_exclude_none=True,
responses={404: {"model": Message}},
)
async def get(
db: Annotated[AsyncSession, Depends(get_db_session)],
job_id: int,
) -> PrintJobResponse:
"""Get a print job by ID."""
db_item = await print_job.get_by_id(db, job_id)
return PrintJobResponse.from_db(db_item)
@router.post(
"",
name="Create print job",
description="Create a new pending print job (called by slicer post-processing script).",
response_model_exclude_none=True,
responses={400: {"model": Message}, 404: {"model": Message}},
)
async def create(
db: Annotated[AsyncSession, Depends(get_db_session)],
body: CreatePrintJobRequest,
) -> PrintJobResponse:
"""Create a new print job."""
db_item = await print_job.create(
db=db,
spool_id=body.spool_id,
filename=body.filename,
filament_used_g=body.filament_used_g,
filament_used_mm=body.filament_used_mm,
)
return PrintJobResponse.from_db(db_item)
@router.post(
"/{job_id}/complete",
name="Complete print job",
description="Mark a print job as completed - deducts filament from spool.",
response_model_exclude_none=True,
responses={400: {"model": Message}, 404: {"model": Message}},
)
async def complete(
db: Annotated[AsyncSession, Depends(get_db_session)],
job_id: int,
body: Optional[CompletePrintJobRequest] = None,
) -> PrintJobResponse:
"""Complete a print job."""
db_item = await print_job.complete(
db=db,
job_id=job_id,
comment=body.comment if body else None,
)
return PrintJobResponse.from_db(db_item)
@router.post(
"/{job_id}/cancel",
name="Cancel print job",
description="Mark a print job as cancelled - flags spool for manual weigh-in.",
response_model_exclude_none=True,
responses={400: {"model": Message}, 404: {"model": Message}},
)
async def cancel(
db: Annotated[AsyncSession, Depends(get_db_session)],
job_id: int,
) -> PrintJobResponse:
"""Cancel a print job."""
db_item = await print_job.cancel(db=db, job_id=job_id)
return PrintJobResponse.from_db(db_item)
@router.delete(
"/{job_id}",
name="Delete print job",
description="Delete a print job.",
response_model=Message,
responses={404: {"model": Message}},
)
async def delete(
db: Annotated[AsyncSession, Depends(get_db_session)],
job_id: int,
) -> Message:
"""Delete a print job."""
await print_job.delete(db=db, job_id=job_id)
return Message(message="Success!")

View File

@@ -15,7 +15,7 @@ from spoolman.database.database import backup_global_db
from spoolman.exceptions import ItemNotFoundError
from spoolman.ws import websocket_manager
from . import backup as backup_module, export, externaldb, field, filament, models, other, setting, spool, vendor
from . import auth, backup as backup_module, export, externaldb, field, filament, location as location_module, models, other, print_job, setting, spool, vendor
logger = logging.getLogger(__name__)
@@ -57,6 +57,7 @@ async def info() -> models.Info:
db_type=str(env.get_database_type() or "sqlite"),
git_commit=env.get_commit_hash(),
build_date=env.get_build_date(),
auth_enabled=auth.is_auth_enabled(),
)
@@ -113,3 +114,6 @@ app.include_router(other.router)
app.include_router(externaldb.router)
app.include_router(export.router)
app.include_router(backup_module.router)
app.include_router(print_job.router)
app.include_router(auth.router)
app.include_router(location_module.router)

View File

@@ -51,6 +51,18 @@ class SpoolParameters(BaseModel):
description="The weight of an empty spool, in grams. (tare weight)",
examples=[200],
)
extra_weight: Optional[float] = Field(
None,
ge=0,
description="Extra weight to account for, such as DryPods, custom spool holders, etc., in grams.",
examples=[50],
)
color_hex: Optional[str] = Field(
None,
max_length=8,
description="Spool-level color override (hex code without #). Overrides the filament color if set.",
examples=["FF0000"],
)
remaining_weight: Optional[float] = Field(
None,
ge=0,
@@ -84,6 +96,7 @@ class SpoolParameters(BaseModel):
examples=[""],
)
archived: bool = Field(default=False, description="Whether this spool is archived and should not be used anymore.")
needs_weighing: bool = Field(default=False, description="Whether this spool needs manual weigh-in.")
extra: Optional[dict[str, str]] = Field(
None,
description="Extra fields for this spool.",
@@ -211,6 +224,17 @@ async def find(
),
),
] = None,
filament_color_hex: Annotated[
Optional[str],
Query(
alias="filament.color_hex",
title="Filament Color",
description=(
"Filter by exact filament color hex code (without #). Separate multiple colors with a comma."
),
examples=["FF0000", "FF0000,00FF00,0000FF"],
),
] = None,
filament_vendor_name: Annotated[
Optional[str],
Query(
@@ -263,6 +287,33 @@ async def find(
bool,
Query(title="Allow Archived", description="Whether to include archived spools in the search results."),
] = False,
needs_weighing: Annotated[
Optional[bool],
Query(title="Needs Weighing", description="Filter by spools that need manual weigh-in."),
] = None,
remaining_weight_lt: Annotated[
Optional[float],
Query(
title="Remaining Weight Less Than",
description="Filter by spools with remaining weight (in grams) less than this value.",
),
] = None,
remaining_weight_gt: Annotated[
Optional[float],
Query(
title="Remaining Weight Greater Than",
description="Filter by spools with remaining weight (in grams) greater than this value.",
),
] = None,
q: Annotated[
Optional[str],
Query(
title="Search",
description=(
"Global search term. Searches across filament name, vendor name, material, and location."
),
),
] = None,
sort: Annotated[
Optional[str],
Query(
@@ -299,14 +350,19 @@ async def find(
db_items, total_count = await spool.find(
db=db,
q=q,
filament_name=filament_name if filament_name is not None else filament_name_old,
filament_id=filament_ids,
filament_material=filament_material if filament_material is not None else filament_material_old,
filament_color_hex=filament_color_hex,
vendor_name=filament_vendor_name if filament_vendor_name is not None else vendor_name_old,
vendor_id=filament_vendor_ids,
location=location,
lot_nr=lot_nr,
allow_archived=allow_archived,
needs_weighing=needs_weighing,
remaining_weight_lt=remaining_weight_lt,
remaining_weight_gt=remaining_weight_gt,
sort_by=sort_by,
limit=limit,
offset=offset,
@@ -415,6 +471,8 @@ async def create( # noqa: ANN201
price=body.price,
initial_weight=body.initial_weight,
spool_weight=body.spool_weight,
extra_weight=body.extra_weight,
color_hex=body.color_hex,
remaining_weight=body.remaining_weight,
used_weight=body.used_weight,
first_used=body.first_used,
@@ -500,6 +558,69 @@ async def delete(
return Message(message="Success!")
# Bulk operations
class BulkArchiveParameters(BaseModel):
spool_ids: list[int] = Field(description="List of spool IDs to archive/unarchive.")
archived: bool = Field(description="Whether to archive (true) or unarchive (false).")
class BulkDeleteParameters(BaseModel):
spool_ids: list[int] = Field(description="List of spool IDs to delete.")
class BulkUpdateParameters(BaseModel):
spool_ids: list[int] = Field(description="List of spool IDs to update.")
location: Optional[str] = Field(None, max_length=64, description="New location for all spools.")
@router.post(
"/bulk/archive",
name="Bulk archive spools",
description="Archive or unarchive multiple spools at once.",
responses={404: {"model": Message}},
)
async def bulk_archive(
db: Annotated[AsyncSession, Depends(get_db_session)],
body: BulkArchiveParameters,
) -> Message:
for spool_id in body.spool_ids:
await spool.update(db=db, spool_id=spool_id, data={"archived": body.archived})
return Message(message=f"Updated {len(body.spool_ids)} spools.")
@router.post(
"/bulk/delete",
name="Bulk delete spools",
description="Delete multiple spools at once.",
responses={404: {"model": Message}},
)
async def bulk_delete(
db: Annotated[AsyncSession, Depends(get_db_session)],
body: BulkDeleteParameters,
) -> Message:
for spool_id in body.spool_ids:
await spool.delete(db=db, spool_id=spool_id)
return Message(message=f"Deleted {len(body.spool_ids)} spools.")
@router.post(
"/bulk/update",
name="Bulk update spools",
description="Update the same fields on multiple spools at once.",
responses={404: {"model": Message}},
)
async def bulk_update(
db: Annotated[AsyncSession, Depends(get_db_session)],
body: BulkUpdateParameters,
) -> Message:
data = body.model_dump(exclude={"spool_ids"}, exclude_unset=True)
for spool_id in body.spool_ids:
await spool.update(db=db, spool_id=spool_id, data=data)
return Message(message=f"Updated {len(body.spool_ids)} spools.")
@router.put(
"/{spool_id}/use",
name="Use spool filament",

237
spoolman/auth.py Normal file
View File

@@ -0,0 +1,237 @@
"""Authentication utilities and dependencies."""
import json
import logging
import os
import secrets
from datetime import datetime, timedelta, timezone
from enum import Enum
from typing import Annotated, Optional
from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer
from jose import JWTError, jwt
from passlib.context import CryptContext
from pydantic import BaseModel
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from spoolman.database import models
from spoolman.database.database import get_db_session
logger = logging.getLogger(__name__)
# JWT Configuration
SECRET_KEY = os.environ.get("SPOOLMAN_AUTH_SECRET_KEY", secrets.token_urlsafe(32))
ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES = int(os.environ.get("SPOOLMAN_AUTH_TOKEN_EXPIRE_MINUTES", "1440")) # 24 hours default
# Password hashing
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
# OAuth2 scheme - auto_error=False allows unauthenticated access when auth is disabled
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="api/v1/auth/login", auto_error=False)
# Cached auth enabled state (loaded from database on startup)
_auth_enabled_cache: bool = False
def is_auth_enabled() -> bool:
"""Check if authentication is enabled (from cached database setting)."""
return _auth_enabled_cache
def set_auth_enabled_cache(enabled: bool) -> None:
"""Update the cached auth enabled state."""
global _auth_enabled_cache
_auth_enabled_cache = enabled
logger.info("Auth enabled cache updated: %s", enabled)
async def load_auth_enabled_from_db(db: AsyncSession) -> bool:
"""Load auth_enabled setting from database and update cache."""
from spoolman.settings import SETTINGS
setting = await db.get(models.Setting, "auth_enabled")
if setting is not None:
enabled = json.loads(setting.value)
else:
# Use default from settings definition
enabled = json.loads(SETTINGS["auth_enabled"].default)
set_auth_enabled_cache(enabled)
return enabled
class Role(str, Enum):
"""User roles with increasing permissions."""
VIEWER = "viewer" # Can only view
OPERATOR = "operator" # Can view + record usage
EDITOR = "editor" # Can view + use + create + edit
ADMIN = "admin" # Full access
# Role hierarchy - each role includes permissions of roles below it
ROLE_HIERARCHY = {
Role.VIEWER: 0,
Role.OPERATOR: 1,
Role.EDITOR: 2,
Role.ADMIN: 3,
}
class Token(BaseModel):
"""JWT token response."""
access_token: str
token_type: str = "bearer"
class TokenData(BaseModel):
"""Data extracted from JWT token."""
username: str
role: Role
class UserResponse(BaseModel):
"""User data returned by API (no password)."""
id: int
username: str
role: Role
is_active: bool
created_at: datetime
@staticmethod
def from_db(user: models.User) -> "UserResponse":
"""Convert database model to response model."""
return UserResponse(
id=user.id,
username=user.username,
role=Role(user.role),
is_active=user.is_active,
created_at=user.created_at,
)
def verify_password(plain_password: str, hashed_password: str) -> bool:
"""Verify a plain password against a hashed password."""
return pwd_context.verify(plain_password, hashed_password)
def get_password_hash(password: str) -> str:
"""Hash a password."""
return pwd_context.hash(password)
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str:
"""Create a JWT access token."""
to_encode = data.copy()
expire = datetime.now(timezone.utc) + (expires_delta or timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES))
to_encode.update({"exp": expire})
return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
async def get_user_by_username(db: AsyncSession, username: str) -> Optional[models.User]:
"""Get a user by username."""
result = await db.execute(select(models.User).where(models.User.username == username))
return result.scalar_one_or_none()
async def authenticate_user(db: AsyncSession, username: str, password: str) -> Optional[models.User]:
"""Authenticate a user by username and password."""
user = await get_user_by_username(db, username)
if not user or not verify_password(password, user.hashed_password):
return None
if not user.is_active:
return None
return user
async def get_current_user(
token: Annotated[Optional[str], Depends(oauth2_scheme)],
db: Annotated[AsyncSession, Depends(get_db_session)],
) -> Optional[models.User]:
"""Get the current authenticated user from JWT token.
Returns None if auth is disabled or no token provided.
Raises HTTPException if token is invalid.
"""
if not is_auth_enabled():
return None
if token is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Not authenticated",
headers={"WWW-Authenticate": "Bearer"},
)
try:
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
username: str = payload.get("sub")
if username is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid token",
headers={"WWW-Authenticate": "Bearer"},
)
except JWTError:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid token",
headers={"WWW-Authenticate": "Bearer"},
)
user = await get_user_by_username(db, username)
if user is None or not user.is_active:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="User not found or inactive",
headers={"WWW-Authenticate": "Bearer"},
)
return user
def require_role(minimum_role: Role):
"""Dependency factory that requires a minimum role level.
Usage:
@router.post("/spool", dependencies=[Depends(require_role(Role.EDITOR))])
async def create_spool(...):
...
"""
async def role_checker(
current_user: Annotated[Optional[models.User], Depends(get_current_user)],
) -> Optional[models.User]:
# If auth is disabled, allow all operations
if not is_auth_enabled():
return None
if current_user is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Not authenticated",
)
user_role = Role(current_user.role)
if ROLE_HIERARCHY[user_role] < ROLE_HIERARCHY[minimum_role]:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"Requires {minimum_role.value} role or higher",
)
return current_user
return role_checker
# Common dependencies for different permission levels
RequireViewer = Depends(require_role(Role.VIEWER))
RequireOperator = Depends(require_role(Role.OPERATOR))
RequireEditor = Depends(require_role(Role.EDITOR))
RequireAdmin = Depends(require_role(Role.ADMIN))

View File

@@ -114,13 +114,16 @@ async def find(
sort_by: Optional[dict[str, SortOrder]] = None,
limit: Optional[int] = None,
offset: int = 0,
) -> tuple[list[models.Filament], int]:
) -> tuple[list[models.Filament], int, dict[int, tuple[int, float]]]:
"""Find a list of filament objects by search criteria.
Sort by a field by passing a dict with the field name as key and the sort order as value.
The field name can contain nested fields, e.g. vendor.name.
Returns a tuple containing the list of items and the total count of matching items.
Returns a tuple containing:
- list of filaments
- total count of matching items
- dict mapping filament_id to (spool_count, total_remaining_weight)
"""
stmt = (
select(models.Filament)
@@ -160,7 +163,25 @@ async def find(
if total_count is None:
total_count = len(result)
return result, total_count
# Fetch spool statistics for all filaments in the result
spool_stats: dict[int, tuple[int, float]] = {}
if result:
filament_ids = [f.id for f in result]
stats_stmt = (
select(
models.Spool.filament_id,
func.count(models.Spool.id).label("spool_count"),
func.coalesce(func.sum(models.Spool.initial_weight - models.Spool.used_weight), 0).label("total_remaining"),
)
.where(models.Spool.filament_id.in_(filament_ids))
.where((models.Spool.archived == False) | (models.Spool.archived == None)) # noqa: E712, E711
.group_by(models.Spool.filament_id)
)
stats_rows = await db.execute(stats_stmt)
for row in stats_rows.all():
spool_stats[row.filament_id] = (row.spool_count, float(row.total_remaining or 0))
return result, total_count, spool_stats
async def update(
@@ -220,6 +241,16 @@ async def find_materials(
return [row[0] for row in rows.all() if row[0] is not None]
async def find_colors(
*,
db: AsyncSession,
) -> list[str]:
"""Find a list of unique filament colors (hex codes) from the filament table."""
stmt = select(models.Filament.color_hex).distinct()
rows = await db.execute(stmt)
return sorted([row[0] for row in rows.all() if row[0] is not None])
async def find_article_numbers(
*,
db: AsyncSession,

View File

@@ -0,0 +1,148 @@
"""Database operations for Location model."""
from typing import Optional
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from spoolman.database import models
from spoolman.exceptions import ItemNotFoundError
async def create(
*,
db: AsyncSession,
name: str,
parent_id: Optional[int] = None,
location_type: Optional[str] = None,
description: Optional[str] = None,
) -> models.Location:
"""Create a new location."""
# Verify parent exists if specified
if parent_id is not None:
parent = await db.get(models.Location, parent_id)
if parent is None:
raise ItemNotFoundError(f"Parent location with ID {parent_id} not found.")
db_item = models.Location(
name=name,
parent_id=parent_id,
location_type=location_type,
description=description,
)
db.add(db_item)
await db.commit()
return db_item
async def get_by_id(db: AsyncSession, location_id: int) -> models.Location:
"""Get a location by ID."""
stmt = select(models.Location).where(models.Location.id == location_id)
result = await db.execute(stmt)
db_item = result.scalar_one_or_none()
if db_item is None:
raise ItemNotFoundError(f"Location with ID {location_id} not found.")
return db_item
async def find(
*,
db: AsyncSession,
parent_id: Optional[int] = None,
) -> list[models.Location]:
"""Find locations, optionally filtered by parent."""
stmt = select(models.Location).order_by(models.Location.name)
if parent_id is not None:
stmt = stmt.where(models.Location.parent_id == parent_id)
result = await db.execute(stmt)
return list(result.scalars().all())
async def find_all(db: AsyncSession) -> list[models.Location]:
"""Get all locations."""
stmt = select(models.Location).order_by(models.Location.name)
result = await db.execute(stmt)
return list(result.scalars().all())
async def update(
*,
db: AsyncSession,
location_id: int,
data: dict,
) -> models.Location:
"""Update a location."""
db_item = await get_by_id(db, location_id)
# Verify parent exists if changing parent
if "parent_id" in data and data["parent_id"] is not None:
# Prevent setting parent to self
if data["parent_id"] == location_id:
raise ValueError("Location cannot be its own parent.")
parent = await db.get(models.Location, data["parent_id"])
if parent is None:
raise ItemNotFoundError(f"Parent location with ID {data['parent_id']} not found.")
for key, value in data.items():
setattr(db_item, key, value)
await db.commit()
return db_item
async def delete(db: AsyncSession, location_id: int) -> None:
"""Delete a location."""
db_item = await get_by_id(db, location_id)
await db.delete(db_item)
await db.commit()
async def get_full_path(db: AsyncSession, location_id: int) -> str:
"""Get the full path of a location as a string (e.g., 'Room > Shelf > Bin')."""
parts = []
current_id: Optional[int] = location_id
while current_id is not None:
location = await get_by_id(db, current_id)
parts.insert(0, location.name)
current_id = location.parent_id
return " > ".join(parts)
async def get_tree(db: AsyncSession) -> list[dict]:
"""Get all locations as a nested tree structure."""
all_locations = await find_all(db)
# Build lookup dict
by_id = {loc.id: loc for loc in all_locations}
# Build tree
def build_node(loc: models.Location) -> dict:
return {
"id": loc.id,
"name": loc.name,
"location_type": loc.location_type,
"description": loc.description,
"children": [
build_node(by_id[child.id])
for child in all_locations
if child.parent_id == loc.id
],
}
# Get root nodes (no parent)
roots = [loc for loc in all_locations if loc.parent_id is None]
return [build_node(loc) for loc in roots]
async def get_spool_count(db: AsyncSession, location_id: int) -> int:
"""Get the number of spools at this location (not including children)."""
stmt = (
select(func.count(models.Spool.id))
.where(models.Spool.location_id == location_id)
.where((models.Spool.archived == False) | (models.Spool.archived == None)) # noqa: E712
)
result = await db.execute(stmt)
return result.scalar() or 0

View File

@@ -12,6 +12,30 @@ class Base(AsyncAttrs, DeclarativeBase):
pass
class Location(Base):
"""Hierarchical storage location (room, shelf, bin, etc.)."""
__tablename__ = "location"
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
name: Mapped[str] = mapped_column(String(64))
parent_id: Mapped[Optional[int]] = mapped_column(ForeignKey("location.id", ondelete="SET NULL"), index=True)
location_type: Mapped[Optional[str]] = mapped_column(String(32), comment="Type: room, shelf, bin, etc.")
description: Mapped[Optional[str]] = mapped_column(String(256))
# Self-referential relationships
parent: Mapped[Optional["Location"]] = relationship(
"Location",
remote_side="Location.id",
back_populates="children",
)
children: Mapped[list["Location"]] = relationship(
"Location",
back_populates="parent",
)
spools: Mapped[list["Spool"]] = relationship(back_populates="location_obj")
class Vendor(Base):
__tablename__ = "vendor"
@@ -76,10 +100,29 @@ class Spool(Base):
initial_weight: Mapped[Optional[float]] = mapped_column()
spool_weight: Mapped[Optional[float]] = mapped_column()
used_weight: Mapped[float] = mapped_column()
location: Mapped[Optional[str]] = mapped_column(String(64))
extra_weight: Mapped[Optional[float]] = mapped_column(
default=None,
comment="Extra weight to account for (DryPods, custom holders, etc.).",
)
color_hex: Mapped[Optional[str]] = mapped_column(
String(8),
default=None,
comment="Spool-level color override (hex without #).",
)
location: Mapped[Optional[str]] = mapped_column(String(64), comment="Legacy flat location string.")
location_id: Mapped[Optional[int]] = mapped_column(
ForeignKey("location.id", ondelete="SET NULL"),
index=True,
comment="FK to hierarchical location.",
)
location_obj: Mapped[Optional["Location"]] = relationship(back_populates="spools")
lot_nr: Mapped[Optional[str]] = mapped_column(String(64))
comment: Mapped[Optional[str]] = mapped_column(String(1024))
archived: Mapped[Optional[bool]] = mapped_column()
needs_weighing: Mapped[Optional[bool]] = mapped_column(
default=False,
comment="Flag indicating spool needs manual weigh-in (e.g., after cancelled print).",
)
extra: Mapped[list["SpoolField"]] = relationship(
back_populates="spool",
cascade="save-update, merge, delete, delete-orphan",
@@ -106,6 +149,26 @@ class SpoolAdjustment(Base):
comment: Mapped[Optional[str]] = mapped_column(String(256), comment="Optional user comment.")
class PrintJob(Base):
"""Track pending/completed print jobs from slicer."""
__tablename__ = "print_job"
id: Mapped[int] = mapped_column(primary_key=True, index=True)
spool_id: Mapped[int] = mapped_column(ForeignKey("spool.id", ondelete="CASCADE"), index=True)
spool: Mapped["Spool"] = relationship()
created: Mapped[datetime] = mapped_column(comment="When the job was created (slice time).")
finished: Mapped[Optional[datetime]] = mapped_column(comment="When the job was completed/cancelled.")
filename: Mapped[str] = mapped_column(String(256), comment="G-code filename.")
filament_used_g: Mapped[float] = mapped_column(comment="Estimated filament usage in grams.")
filament_used_mm: Mapped[Optional[float]] = mapped_column(comment="Estimated filament usage in mm.")
status: Mapped[str] = mapped_column(
String(16),
default="pending",
comment="Status: 'pending', 'completed', 'cancelled'.",
)
class Setting(Base):
__tablename__ = "setting"
@@ -139,3 +202,20 @@ class SpoolField(Base):
spool: Mapped["Spool"] = relationship(back_populates="extra")
key: Mapped[str] = mapped_column(String(64), primary_key=True, index=True)
value: Mapped[str] = mapped_column(Text())
class User(Base):
"""User account for authentication."""
__tablename__ = "user"
id: Mapped[int] = mapped_column(primary_key=True, index=True)
username: Mapped[str] = mapped_column(String(64), unique=True, index=True)
hashed_password: Mapped[str] = mapped_column(String(128))
role: Mapped[str] = mapped_column(
String(16),
default="viewer",
comment="Role: 'admin', 'editor', 'operator', 'viewer'.",
)
is_active: Mapped[bool] = mapped_column(default=True)
created_at: Mapped[datetime] = mapped_column()

View File

@@ -0,0 +1,157 @@
"""Database operations for print jobs."""
from datetime import datetime
from typing import Optional
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import joinedload
from spoolman.database.models import PrintJob, Spool, SpoolAdjustment
from spoolman.exceptions import ItemNotFoundError
async def create(
*,
db: AsyncSession,
spool_id: int,
filename: str,
filament_used_g: float,
filament_used_mm: Optional[float] = None,
) -> PrintJob:
"""Create a new pending print job."""
# Verify spool exists
spool = await db.get(Spool, spool_id)
if spool is None:
raise ItemNotFoundError(f"Spool with ID {spool_id} not found.")
job = PrintJob(
spool_id=spool_id,
created=datetime.utcnow(),
filename=filename,
filament_used_g=filament_used_g,
filament_used_mm=filament_used_mm,
status="pending",
)
db.add(job)
await db.commit()
await db.refresh(job)
# Load spool relationship
result = await db.execute(
select(PrintJob).where(PrintJob.id == job.id).options(joinedload(PrintJob.spool))
)
return result.scalar_one()
async def get_by_id(db: AsyncSession, job_id: int) -> PrintJob:
"""Get a print job by ID."""
result = await db.execute(
select(PrintJob).where(PrintJob.id == job_id).options(joinedload(PrintJob.spool))
)
job = result.scalar_one_or_none()
if job is None:
raise ItemNotFoundError(f"Print job with ID {job_id} not found.")
return job
async def find(
*,
db: AsyncSession,
status: Optional[str] = None,
spool_id: Optional[int] = None,
limit: Optional[int] = None,
offset: int = 0,
) -> tuple[list[PrintJob], int]:
"""Find print jobs with optional filters."""
query = select(PrintJob).options(joinedload(PrintJob.spool))
if status:
query = query.where(PrintJob.status == status)
if spool_id:
query = query.where(PrintJob.spool_id == spool_id)
# Get total count
count_query = select(PrintJob)
if status:
count_query = count_query.where(PrintJob.status == status)
if spool_id:
count_query = count_query.where(PrintJob.spool_id == spool_id)
count_result = await db.execute(count_query)
total = len(count_result.all())
# Apply ordering and pagination
query = query.order_by(PrintJob.created.desc())
if offset:
query = query.offset(offset)
if limit:
query = query.limit(limit)
result = await db.execute(query)
return list(result.scalars().all()), total
async def complete(db: AsyncSession, job_id: int, comment: Optional[str] = None) -> PrintJob:
"""Complete a print job - deduct filament from spool."""
job = await get_by_id(db, job_id)
if job.status != "pending":
raise ValueError(f"Job {job_id} is not pending (status: {job.status}).")
# Deduct filament from spool
spool = await db.get(Spool, job.spool_id)
if spool is None:
raise ItemNotFoundError(f"Spool with ID {job.spool_id} not found.")
spool.used_weight += job.filament_used_g
if spool.first_used is None:
spool.first_used = datetime.utcnow()
spool.last_used = datetime.utcnow()
# Record adjustment
adjustment = SpoolAdjustment(
spool_id=spool.id,
timestamp=datetime.utcnow(),
adjustment_type="weight",
value=-job.filament_used_g,
comment=comment or f"Print job: {job.filename}",
)
db.add(adjustment)
# Update job status
job.status = "completed"
job.finished = datetime.utcnow()
await db.commit()
# Reload with relationships
return await get_by_id(db, job_id)
async def cancel(db: AsyncSession, job_id: int) -> PrintJob:
"""Cancel a print job - flag spool for weighing."""
job = await get_by_id(db, job_id)
if job.status != "pending":
raise ValueError(f"Job {job_id} is not pending (status: {job.status}).")
# Flag spool for weighing
spool = await db.get(Spool, job.spool_id)
if spool is None:
raise ItemNotFoundError(f"Spool with ID {job.spool_id} not found.")
spool.needs_weighing = True
# Update job status
job.status = "cancelled"
job.finished = datetime.utcnow()
await db.commit()
# Reload with relationships
return await get_by_id(db, job_id)
async def delete(db: AsyncSession, job_id: int) -> None:
"""Delete a print job."""
job = await get_by_id(db, job_id)
await db.delete(job)
await db.commit()

View File

@@ -41,6 +41,8 @@ async def create(
remaining_weight: Optional[float] = None,
initial_weight: Optional[float] = None,
spool_weight: Optional[float] = None,
extra_weight: Optional[float] = None,
color_hex: Optional[str] = None,
used_weight: Optional[float] = None,
first_used: Optional[datetime] = None,
last_used: Optional[datetime] = None,
@@ -69,7 +71,10 @@ async def create(
"remaining_weight can only be used if the initial_weight is "
"defined or the filament has a weight set.",
)
used_weight = max(initial_weight - remaining_weight, 0)
if remaining_weight > initial_weight:
# Spool is heavier than expected (manufacturing tolerance), adjust initial_weight
initial_weight = remaining_weight
used_weight = initial_weight - remaining_weight
else:
used_weight = 0
@@ -84,6 +89,8 @@ async def create(
registered=datetime.utcnow().replace(microsecond=0),
initial_weight=initial_weight,
spool_weight=spool_weight,
extra_weight=extra_weight,
color_hex=color_hex,
used_weight=used_weight,
price=price,
first_used=first_used,
@@ -115,14 +122,19 @@ async def get_by_id(db: AsyncSession, spool_id: int) -> models.Spool:
async def find( # noqa: C901, PLR0912
*,
db: AsyncSession,
q: Optional[str] = None,
filament_name: Optional[str] = None,
filament_id: Optional[Union[int, Sequence[int]]] = None,
filament_material: Optional[str] = None,
filament_color_hex: Optional[Union[str, Sequence[str]]] = None,
vendor_name: Optional[str] = None,
vendor_id: Optional[Union[int, Sequence[int]]] = None,
location: Optional[str] = None,
lot_nr: Optional[str] = None,
allow_archived: bool = False,
needs_weighing: Optional[bool] = None,
remaining_weight_lt: Optional[float] = None,
remaining_weight_gt: Optional[float] = None,
sort_by: Optional[dict[str, SortOrder]] = None,
limit: Optional[int] = None,
offset: int = 0,
@@ -149,6 +161,29 @@ async def find( # noqa: C901, PLR0912
stmt = add_where_clause_str_opt(stmt, models.Spool.location, location)
stmt = add_where_clause_str_opt(stmt, models.Spool.lot_nr, lot_nr)
# Global search: OR across multiple text fields
if q is not None and q.strip():
search_term = f"%{q.strip()}%"
stmt = stmt.where(
sqlalchemy.or_(
models.Filament.name.ilike(search_term),
models.Filament.material.ilike(search_term),
models.Vendor.name.ilike(search_term),
models.Spool.location.ilike(search_term),
models.Spool.lot_nr.ilike(search_term),
models.Spool.comment.ilike(search_term),
)
)
# Filter by color_hex (exact match, supports multiple comma-separated values)
if filament_color_hex is not None:
if isinstance(filament_color_hex, str):
colors = [c.strip() for c in filament_color_hex.split(",") if c.strip()]
else:
colors = list(filament_color_hex)
if colors:
stmt = stmt.where(models.Filament.color_hex.in_(colors))
if not allow_archived:
# Since the archived field is nullable, and default is false, we need to check for both false or null
stmt = stmt.where(
@@ -158,6 +193,24 @@ async def find( # noqa: C901, PLR0912
),
)
if needs_weighing is not None:
if needs_weighing:
stmt = stmt.where(models.Spool.needs_weighing.is_(True))
else:
stmt = stmt.where(
sqlalchemy.or_(
models.Spool.needs_weighing.is_(False),
models.Spool.needs_weighing.is_(None),
),
)
# Filter by remaining_weight (computed as initial_weight - used_weight, falling back to filament.weight)
remaining_weight_expr = coalesce(models.Spool.initial_weight, models.Filament.weight) - models.Spool.used_weight
if remaining_weight_lt is not None:
stmt = stmt.where(remaining_weight_expr < remaining_weight_lt)
if remaining_weight_gt is not None:
stmt = stmt.where(remaining_weight_expr > remaining_weight_gt)
total_count = None
if limit is not None:
@@ -190,6 +243,18 @@ async def find( # noqa: C901, PLR0912
sorts.append(models.Filament.name)
elif fieldstr == "price":
sorts.append(coalesce(models.Spool.price, models.Filament.price))
elif fieldstr.startswith("extra."):
# Sort by custom field value
extra_key = fieldstr[len("extra."):]
extra_alias = sqlalchemy.orm.aliased(models.SpoolField)
stmt = stmt.outerjoin(
extra_alias,
sqlalchemy.and_(
extra_alias.spool_id == models.Spool.id,
extra_alias.key == extra_key,
),
)
sorts.append(extra_alias.value)
else:
sorts.append(parse_nested_field(models.Spool, fieldstr))
@@ -227,7 +292,10 @@ async def update(
elif k == "remaining_weight":
if spool.initial_weight is None:
raise ItemCreateError("remaining_weight can only be used if initial_weight is set.")
spool.used_weight = max(spool.initial_weight - v, 0)
if v > spool.initial_weight:
# Spool is heavier than expected (manufacturing tolerance), adjust initial_weight
spool.initial_weight = v
spool.used_weight = spool.initial_weight - v
elif isinstance(v, datetime):
setattr(spool, k, utc_timezone_naive(v))
elif k == "extra":
@@ -409,7 +477,12 @@ async def measure(
"""
spool_result = await db.execute(
sqlalchemy.select(models.Spool.initial_weight, models.Spool.used_weight, models.Spool.spool_weight).where(
sqlalchemy.select(
models.Spool.initial_weight,
models.Spool.used_weight,
models.Spool.spool_weight,
models.Spool.extra_weight,
).where(
models.Spool.id == spool_id,
),
)
@@ -421,6 +494,7 @@ async def measure(
initial_weight = spool_info[0]
spool_weight = spool_info[2]
extra_weight = spool_info[3] or 0 # Default to 0 if not set
if initial_weight is None or initial_weight == 0 or spool_weight is None or spool_weight == 0:
# Get filament weight and spool_weight
result = await db.execute(
@@ -442,7 +516,7 @@ async def measure(
if initial_weight is None or initial_weight == 0:
raise SpoolMeasureError("Initial weight is not set.")
initial_gross_weight = initial_weight + spool_weight
initial_gross_weight = initial_weight + spool_weight + extra_weight
# if the measurement is greater than the initial weight, set the initial weight to the measurement
if weight > initial_gross_weight:
@@ -454,9 +528,10 @@ async def measure(
# Calculate the weight used since last measure
weight_to_use = current_use - weight
# If the measured weight is less than the empty weight, use the rest of the spool
if (initial_gross_weight - weight_to_use) < spool_weight:
weight_to_use = current_use - spool_weight
# If the measured weight is less than the empty weight (+ extra), use the rest of the spool
empty_gross_weight = spool_weight + extra_weight
if (initial_gross_weight - weight_to_use) < empty_gross_weight:
weight_to_use = current_use - empty_gross_weight
return await use_weight(db, spool_id, weight_to_use, comment=comment)

View File

@@ -6,7 +6,7 @@ import os
from collections.abc import Iterator
from enum import Enum
from pathlib import Path
from typing import Any, Optional
from typing import Optional
from urllib.parse import urljoin
import hishel
@@ -20,16 +20,8 @@ logger = logging.getLogger(__name__)
DEFAULT_EXTERNAL_DB_URL = "https://donkie.github.io/SpoolmanDB/"
DEFAULT_3DFP_URL = "https://raw.githubusercontent.com/jklewa/filament-profiles-data/main/"
DEFAULT_SYNC_INTERVAL = 3600
class ExternalSource(Enum):
"""External database sources."""
SPOOLMANDB = "spoolmandb"
FILAMENT_PROFILES_3D = "3dfilamentprofiles"
controller = hishel.Controller(allow_stale=True)
try:
cache_path = get_cache_dir() / "hishel"
@@ -97,10 +89,6 @@ class ExternalFilament(BaseModel):
pattern: Optional[Pattern] = Field(default=None, description="Pattern of the filament.", examples=[Pattern.MARBLE])
translucent: bool = Field(default=False, description="Whether the filament is translucent.")
glow: bool = Field(default=False, description="Whether the filament is glow-in-the-dark.")
source: ExternalSource = Field(
default=ExternalSource.SPOOLMANDB,
description="External database source this filament came from.",
)
class ExternalFilamentsFile(RootModel):
@@ -134,146 +122,11 @@ class ExternalMaterialsFile(RootModel):
return self.root[index]
# 3dfilamentprofiles.com data models
class FilamentProperties3DFP(BaseModel):
"""Properties object from 3dfilamentprofiles."""
temp_min: Optional[int] = None
temp_max: Optional[int] = None
bed_temp_min: Optional[int] = None
bed_temp_max: Optional[int] = None
spool_weight: Optional[float] = None
flow_ratio: Optional[float] = None
class Filament3DFP(BaseModel):
"""Raw filament data from 3dfilamentprofiles.com."""
id: int
brand_name: str
material: str
material_type: Optional[str] = None
color: str
rgb: Optional[str] = None
properties: Optional[dict[str, Any]] = None
class FilamentsFile3DFP(BaseModel):
"""The filaments.json file from 3dfilamentprofiles."""
filaments: list[Filament3DFP]
# Material density defaults (used when 3dfilamentprofiles doesn't provide density)
MATERIAL_DENSITIES: dict[str, float] = {
"PLA": 1.24,
"ABS": 1.04,
"PETG": 1.27,
"NYLON": 1.52,
"TPU": 1.21,
"PC": 1.3,
"WOOD": 1.28,
"CF": 1.3,
"PC/ABS": 1.19,
"HIPS": 1.03,
"PVA": 1.23,
"ASA": 1.05,
"PP": 0.9,
"POM": 1.4,
"PMMA": 1.18,
"FPE": 2.16,
}
def _get_density_for_material(material: str) -> float:
"""Get density for a material, defaulting to PLA's density if unknown."""
material_upper = material.upper()
for key, density in MATERIAL_DENSITIES.items():
if key in material_upper:
return density
return 1.24 # Default to PLA density
def _transform_3dfp_to_external(filament: Filament3DFP) -> ExternalFilament:
"""Transform a 3dfilamentprofiles filament to ExternalFilament format."""
# Build name from material_type and color
name_parts = []
if filament.material_type:
name_parts.append(filament.material_type)
name_parts.append(filament.color)
name = " ".join(name_parts)
# Handle color hex (3dfp includes # prefix, SpoolmanDB doesn't)
color_hex = None
if filament.rgb:
color_hex = filament.rgb.lstrip("#").lower()
# Extract properties
props = filament.properties or {}
extruder_temp = props.get("temp_min")
extruder_temp_max = props.get("temp_max")
bed_temp = props.get("bed_temp_min")
bed_temp_max = props.get("bed_temp_max")
spool_weight = props.get("spool_weight")
# Determine finish from material_type
finish = None
if filament.material_type:
material_type_lower = filament.material_type.lower()
if "matte" in material_type_lower:
finish = Finish.MATTE
elif "glossy" in material_type_lower or "silk" in material_type_lower:
finish = Finish.GLOSSY
# Determine pattern from material_type
pattern = None
if filament.material_type:
material_type_lower = filament.material_type.lower()
if "marble" in material_type_lower:
pattern = Pattern.MARBLE
elif "sparkle" in material_type_lower or "glitter" in material_type_lower:
pattern = Pattern.SPARKLE
return ExternalFilament(
id=f"3dfp_{filament.id}",
manufacturer=filament.brand_name,
name=name,
material=filament.material,
density=_get_density_for_material(filament.material),
weight=None, # 3dfp doesn't provide filament weight
spool_weight=spool_weight,
spool_type=None,
diameter=1.75, # Default, 3dfp doesn't provide diameter
color_hex=color_hex,
color_hexes=None,
extruder_temp=extruder_temp,
extruder_temp_max=extruder_temp_max,
bed_temp=bed_temp,
bed_temp_max=bed_temp_max,
finish=finish,
multi_color_direction=None,
pattern=pattern,
translucent=False,
glow=False,
source=ExternalSource.FILAMENT_PROFILES_3D,
)
def get_external_db_url() -> str:
"""Get the external database URL from environment variables. Defaults to DEFAULT_EXTERNAL_DB_URL."""
return os.getenv("EXTERNAL_DB_URL", DEFAULT_EXTERNAL_DB_URL)
def get_3dfp_url() -> str:
"""Get the 3dfilamentprofiles URL from environment variables."""
return os.getenv("EXTERNAL_DB_3DFP_URL", DEFAULT_3DFP_URL)
def is_3dfp_enabled() -> bool:
"""Check if 3dfilamentprofiles sync is enabled."""
return os.getenv("EXTERNAL_DB_3DFP_ENABLED", "true").lower() in ("true", "1", "yes")
def get_external_db_sync_interval() -> int:
"""Get the external database sync interval from environment variables. Defaults to DEFAULT_SYNC_INTERVAL."""
return int(os.getenv("EXTERNAL_DB_SYNC_INTERVAL", DEFAULT_SYNC_INTERVAL))
@@ -315,70 +168,36 @@ def get_materials_file() -> Path:
return filecache.get_file("materials.json")
def _parse_3dfp_filaments(data: bytes) -> list[Filament3DFP]:
"""Parse 3dfilamentprofiles JSON data."""
try:
parsed = FilamentsFile3DFP.model_validate_json(data)
return parsed.filaments
except Exception:
# Fallback: try parsing as raw list
import json
raw = json.loads(data)
if isinstance(raw, dict) and "filaments" in raw:
return [Filament3DFP.model_validate(f) for f in raw["filaments"]]
return []
async def _sync() -> None:
logger.info("Syncing external DB.")
"""Sync filaments and materials from SpoolmanDB."""
logger.info("Syncing external DB (SpoolmanDB).")
all_filaments: list[ExternalFilament] = []
# Sync SpoolmanDB
spoolmandb_url = get_external_db_url()
if spoolmandb_url.strip():
if not spoolmandb_url.strip():
logger.warning("External DB URL is empty. Skipping sync.")
return
# Sync filaments
try:
spoolmandb_filaments = _parse_filaments_from_bytes(
filaments = _parse_filaments_from_bytes(
await _download_file(urljoin(spoolmandb_url, "filaments.json"))
)
# Mark all SpoolmanDB filaments with source
for filament in spoolmandb_filaments.root:
filament.source = ExternalSource.SPOOLMANDB
all_filaments.extend(spoolmandb_filaments.root)
logger.info("SpoolmanDB synced: %d filaments", len(spoolmandb_filaments.root))
_write_to_local_cache("filaments.json", filaments.model_dump_json().encode())
logger.info("SpoolmanDB filaments synced: %d filaments", len(filaments.root))
except Exception as e:
logger.warning("Failed to sync SpoolmanDB: %s", e)
logger.warning("Failed to sync SpoolmanDB filaments: %s", e)
# Sync 3dfilamentprofiles
if is_3dfp_enabled():
url_3dfp = get_3dfp_url()
if url_3dfp.strip():
try:
raw_data = await _download_file(urljoin(url_3dfp, "sample-filaments.json"))
filaments_3dfp = _parse_3dfp_filaments(raw_data)
transformed = [_transform_3dfp_to_external(f) for f in filaments_3dfp]
all_filaments.extend(transformed)
logger.info("3dfilamentprofiles synced: %d filaments", len(transformed))
except Exception as e:
logger.warning("Failed to sync 3dfilamentprofiles: %s", e)
# Write combined filaments
combined = ExternalFilamentsFile(root=all_filaments)
_write_to_local_cache("filaments.json", combined.model_dump_json().encode())
# Materials only from SpoolmanDB
if spoolmandb_url.strip():
# Sync materials
try:
materials = _parse_materials_from_bytes(
await _download_file(urljoin(spoolmandb_url, "materials.json"))
)
_write_to_local_cache("materials.json", materials.model_dump_json().encode())
logger.info("Materials synced: %d", len(materials.root))
logger.info("SpoolmanDB materials synced: %d materials", len(materials.root))
except Exception as e:
logger.warning("Failed to sync materials: %s", e)
logger.warning("Failed to sync SpoolmanDB materials: %s", e)
logger.info("External DB sync complete. Total filaments: %d", len(all_filaments))
logger.info("External DB sync complete.")
def schedule_tasks(scheduler: Scheduler) -> None:

View File

@@ -13,7 +13,7 @@ from fastapi.responses import PlainTextResponse, RedirectResponse, Response
from prometheus_client import generate_latest
from scheduler.asyncio.scheduler import Scheduler
from spoolman import env, externaldb
from spoolman import auth, env, externaldb
from spoolman.api.v1.router import app as v1_app
from spoolman.client import SinglePageApplication
from spoolman.database import database
@@ -173,6 +173,12 @@ async def startup() -> None:
project_root = Path(__file__).parent.parent
subprocess.run(["alembic", "upgrade", "head"], check=True, cwd=project_root) # noqa: S603, S607, ASYNC221
# Load auth enabled setting from database
logger.info("Loading auth settings...")
async for db in database.get_db_session():
auth_enabled = await auth.load_auth_enabled_from_db(db)
logger.info("Authentication enabled: %s", auth_enabled)
# Setup scheduler
schedule = Scheduler()
database.schedule_tasks(schedule)

View File

@@ -72,3 +72,4 @@ register_setting("base_url", SettingType.STRING, json.dumps(""))
register_setting("locations", SettingType.ARRAY, json.dumps([]))
register_setting("locations_spoolorders", SettingType.OBJECT, json.dumps({}))
register_setting("auth_enabled", SettingType.BOOLEAN, json.dumps(False))