Add asset checkout, quantity/uniqueness, contacts, and QR labels

Checkout:
- assets.AssignUser + POST /app/assets/{id}/checkout assign/unassign an
  asset's user independent of the full edit form. New shared
  #checkoutModal, wired from a "Check Out..." context-menu action, backed
  by GET /app/assets/{id} and GET /app/users/options JSON endpoints.

Quantity / uniqueness:
- Item types gain an IsUnique flag (checked by default, matching prior
  behavior). Assets gain a Quantity column, fixed at 1 for unique types
  and adjustable via +/- steppers (POST /app/assets/{id}/quantity) for
  non-unique ones. Flipping a type back to unique clamps existing assets'
  quantity back to 1. CSV import/export gained a Quantity column.
  Migration 0003_quantity.sql.

Contacts:
- New models.RoleContact: an assignable person who cannot sign in
  (User.CanLogin). Migration 0004_contacts.sql rebuilds the users table
  (SQLite can't alter a CHECK constraint in place) to allow the role.
  Users form/list updated; contact creation auto-generates an unusable
  password since the login path is blocked by role regardless.

QR asset tags and printable labels:
- Each asset's QR code (via github.com/skip2/go-qrcode, pure Go) encodes
  an absolute /scan/<tag> URL; GET /scan/{tag} resolves it and redirects
  into /inventory?asset=<id>, which app.js picks up on load to
  auto-select that asset - works from a phone camera or a USB
  scanner-as-keyboard-wedge pointed at the address bar.
- GET /labels renders a print-ready sheet (right-click an asset for one
  label, a folder for every asset in it) with a template switcher between
  Avery 5160 (verified dimensions) and 61526 asset tag labels (best-effort
  layout, flagged as such on the page).

Also refreshed README to reflect the right-click-first tree UX and all of
the above.
This commit is contained in:
Owen
2026-08-17 16:28:14 -05:00
parent 42e7b2a21c
commit fb85c9edbb
31 changed files with 975 additions and 77 deletions
+74 -9
View File
@@ -106,6 +106,24 @@ func (s *Store) tagExists(ctx context.Context, tag string, excludeID int64) (boo
return count > 0, err
}
// resolveQuantity clamps the requested quantity against the asset's item
// type: unique types are always exactly 1; non-unique types default to 1
// when the request is less than 1 (0 or unset), since AdjustQuantity is the
// intended path for deliberately going to 0 ("out of stock").
func (s *Store) resolveQuantity(ctx context.Context, itemTypeID int64, requested int) (int, error) {
var isUnique bool
if err := s.db.QueryRowContext(ctx, `SELECT is_unique FROM item_types WHERE id = ?`, itemTypeID).Scan(&isUnique); err != nil {
return 0, err
}
if isUnique {
return 1, nil
}
if requested < 1 {
return 1, nil
}
return requested, nil
}
// Create inserts a new asset. If a.AssetTag is empty, one is generated.
// fieldValues maps custom_field_defs.id to the submitted value.
func (s *Store) Create(ctx context.Context, a *models.Asset, fieldValues map[int64]string) error {
@@ -123,6 +141,11 @@ func (s *Store) Create(ctx context.Context, a *models.Asset, fieldValues map[int
} else if taken {
return ErrTagTaken
}
quantity, err := s.resolveQuantity(ctx, a.ItemTypeID, a.Quantity)
if err != nil {
return err
}
a.Quantity = quantity
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
@@ -131,9 +154,9 @@ func (s *Store) Create(ctx context.Context, a *models.Asset, fieldValues map[int
defer tx.Rollback()
res, err := tx.ExecContext(ctx,
`INSERT INTO assets (asset_id, name, item_type_id, folder_id, status_id, assigned_user_id, notes)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
a.AssetTag, a.Name, a.ItemTypeID, a.FolderID, a.StatusID, a.AssignedUserID, a.Notes)
`INSERT INTO assets (asset_id, name, item_type_id, folder_id, status_id, assigned_user_id, notes, quantity)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
a.AssetTag, a.Name, a.ItemTypeID, a.FolderID, a.StatusID, a.AssignedUserID, a.Notes, a.Quantity)
if err != nil {
return err
}
@@ -173,6 +196,11 @@ func (s *Store) Update(ctx context.Context, a *models.Asset, fieldValues map[int
} else if taken {
return ErrTagTaken
}
quantity, err := s.resolveQuantity(ctx, a.ItemTypeID, a.Quantity)
if err != nil {
return err
}
a.Quantity = quantity
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
@@ -181,9 +209,9 @@ func (s *Store) Update(ctx context.Context, a *models.Asset, fieldValues map[int
defer tx.Rollback()
_, err = tx.ExecContext(ctx,
`UPDATE assets SET asset_id = ?, name = ?, item_type_id = ?, folder_id = ?, status_id = ?, assigned_user_id = ?, notes = ?,
`UPDATE assets SET asset_id = ?, name = ?, item_type_id = ?, folder_id = ?, status_id = ?, assigned_user_id = ?, notes = ?, quantity = ?,
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?`,
a.AssetTag, a.Name, a.ItemTypeID, a.FolderID, a.StatusID, a.AssignedUserID, a.Notes, a.ID)
a.AssetTag, a.Name, a.ItemTypeID, a.FolderID, a.StatusID, a.AssignedUserID, a.Notes, a.Quantity, a.ID)
if err != nil {
return err
}
@@ -209,6 +237,42 @@ func (s *Store) Move(ctx context.Context, id int64, folderID *int64) error {
return err
}
// AssignUser checks an asset out to a user, or checks it in (unassigns)
// when userID is nil. It touches only the assignment, nothing else.
func (s *Store) AssignUser(ctx context.Context, id int64, userID *int64) error {
_, err := s.db.ExecContext(ctx,
`UPDATE assets SET assigned_user_id = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?`,
userID, id)
return err
}
var ErrQuantityNotTracked = errors.New("quantity is not tracked for unique item types")
// AdjustQuantity increments or decrements an asset's quantity by delta,
// floored at 0. It fails for assets whose item type is unique (quantity
// stays fixed at 1 for those; use duplicate/create for more distinct
// units instead).
func (s *Store) AdjustQuantity(ctx context.Context, id int64, delta int) (*models.Asset, error) {
a, err := s.Get(ctx, id)
if err != nil {
return nil, err
}
if a.ItemTypeIsUnique {
return nil, ErrQuantityNotTracked
}
newQty := a.Quantity + delta
if newQty < 0 {
newQty = 0
}
if _, err := s.db.ExecContext(ctx,
`UPDATE assets SET quantity = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?`,
newQty, id); err != nil {
return nil, err
}
a.Quantity = newQty
return a, nil
}
// Duplicate copies an asset's fields and custom field values into a new
// asset with a freshly generated asset tag.
func (s *Store) Duplicate(ctx context.Context, id int64) (*models.Asset, error) {
@@ -228,6 +292,7 @@ func (s *Store) Duplicate(ctx context.Context, id int64) (*models.Asset, error)
StatusID: orig.StatusID,
AssignedUserID: orig.AssignedUserID,
Notes: orig.Notes,
Quantity: orig.Quantity,
}
values := map[int64]string{}
for _, fv := range orig.CustomFields {
@@ -242,8 +307,8 @@ func (s *Store) Duplicate(ctx context.Context, id int64) (*models.Asset, error)
}
const assetSelect = `
SELECT a.id, a.asset_id, a.name, a.item_type_id, a.folder_id, a.status_id, a.assigned_user_id, a.notes, a.created_at, a.updated_at,
it.name, it.icon, st.name, st.color, COALESCE(u.username, '')
SELECT a.id, a.asset_id, a.name, a.item_type_id, a.folder_id, a.status_id, a.assigned_user_id, a.notes, a.quantity, a.created_at, a.updated_at,
it.name, it.icon, it.is_unique, st.name, st.color, COALESCE(u.username, '')
FROM assets a
JOIN item_types it ON it.id = a.item_type_id
JOIN statuses st ON st.id = a.status_id
@@ -253,8 +318,8 @@ func scanAsset(row interface{ Scan(...any) error }) (*models.Asset, error) {
var a models.Asset
var folderID, assignedUserID sql.NullInt64
var created, updated string
if err := row.Scan(&a.ID, &a.AssetTag, &a.Name, &a.ItemTypeID, &folderID, &a.StatusID, &assignedUserID, &a.Notes, &created, &updated,
&a.ItemTypeName, &a.ItemTypeIcon, &a.StatusName, &a.StatusColor, &a.AssignedUsername); err != nil {
if err := row.Scan(&a.ID, &a.AssetTag, &a.Name, &a.ItemTypeID, &folderID, &a.StatusID, &assignedUserID, &a.Notes, &a.Quantity, &created, &updated,
&a.ItemTypeName, &a.ItemTypeIcon, &a.ItemTypeIsUnique, &a.StatusName, &a.StatusColor, &a.AssignedUsername); err != nil {
return nil, err
}
if folderID.Valid {
+12 -1
View File
@@ -6,6 +6,7 @@ import (
"fmt"
"io"
"sort"
"strconv"
"strings"
"inventree/internal/models"
@@ -19,6 +20,7 @@ const (
colStatus = "status"
colAssignedUser = "assigned user"
colNotes = "notes"
colQuantity = "quantity"
cfColPrefix = "cf_"
)
@@ -201,6 +203,13 @@ func (s *Store) Import(ctx context.Context, r io.Reader, opts ImportOptions,
}
}
quantity := 1
if qtyStr := get(row, colQuantity); qtyStr != "" {
if n, err := strconv.Atoi(qtyStr); err == nil {
quantity = n
}
}
asset := &models.Asset{
AssetTag: get(row, colAssetID),
Name: name,
@@ -209,6 +218,7 @@ func (s *Store) Import(ctx context.Context, r io.Reader, opts ImportOptions,
StatusID: statusID,
AssignedUserID: assignedUserID,
Notes: get(row, colNotes),
Quantity: quantity,
}
if asset.AssetTag != "" {
@@ -282,7 +292,7 @@ func (s *Store) Export(ctx context.Context, w io.Writer, f ListFilter) error {
}
cw := csv.NewWriter(w)
header := []string{"Asset ID", "Name", "Item Type", "Folder", "Status", "Assigned User", "Notes", "Created At", "Updated At"}
header := []string{"Asset ID", "Name", "Item Type", "Folder", "Status", "Assigned User", "Notes", "Quantity", "Created At", "Updated At"}
for _, k := range fieldDefs {
header = append(header, cfColPrefix+k)
}
@@ -304,6 +314,7 @@ func (s *Store) Export(ctx context.Context, w io.Writer, f ListFilter) error {
}
row := []string{
a.AssetTag, a.Name, a.ItemTypeName, folderPathString(a.FolderPath), a.StatusName, a.AssignedUsername, a.Notes,
strconv.Itoa(a.Quantity),
a.CreatedAt.Format("2006-01-02 15:04:05"), a.UpdatedAt.Format("2006-01-02 15:04:05"),
}
values := map[string]string{}
+123
View File
@@ -0,0 +1,123 @@
package assets_test
import (
"context"
"testing"
"inventree/internal/assets"
"inventree/internal/itemtypes"
"inventree/internal/models"
)
func TestQuantityFixedAtOneForUniqueTypes(t *testing.T) {
ctx := context.Background()
as, _, _ := testStores(t)
a := &models.Asset{Name: "Laptop 1", ItemTypeID: itemTypeLaptop, StatusID: statusInUse, Quantity: 7}
if err := as.Create(ctx, a, nil); err != nil {
t.Fatalf("create: %v", err)
}
if a.Quantity != 1 {
t.Fatalf("expected quantity forced to 1 for a unique item type, got %d", a.Quantity)
}
if _, err := as.AdjustQuantity(ctx, a.ID, 1); err != assets.ErrQuantityNotTracked {
t.Fatalf("expected ErrQuantityNotTracked, got %v", err)
}
}
func TestQuantityTrackedForNonUniqueTypes(t *testing.T) {
ctx := context.Background()
as, _, db := testStores(t)
itStore := itemtypes.NewStore(db)
cable := &models.ItemType{Name: "Cable", Enabled: true, IsUnique: false}
if err := itStore.Create(ctx, cable); err != nil {
t.Fatalf("create item type: %v", err)
}
a := &models.Asset{Name: "USB-A Cable", ItemTypeID: cable.ID, StatusID: statusInUse, Quantity: 10}
if err := as.Create(ctx, a, nil); err != nil {
t.Fatalf("create: %v", err)
}
if a.Quantity != 10 {
t.Fatalf("expected quantity 10, got %d", a.Quantity)
}
updated, err := as.AdjustQuantity(ctx, a.ID, 5)
if err != nil {
t.Fatalf("adjust +5: %v", err)
}
if updated.Quantity != 15 {
t.Fatalf("expected 15, got %d", updated.Quantity)
}
updated, err = as.AdjustQuantity(ctx, a.ID, -100)
if err != nil {
t.Fatalf("adjust -100: %v", err)
}
if updated.Quantity != 0 {
t.Fatalf("expected quantity floored at 0, got %d", updated.Quantity)
}
}
func TestItemTypeBecomingUniqueClampsExistingQuantities(t *testing.T) {
ctx := context.Background()
as, _, db := testStores(t)
itStore := itemtypes.NewStore(db)
cable := &models.ItemType{Name: "Cable", Enabled: true, IsUnique: false}
if err := itStore.Create(ctx, cable); err != nil {
t.Fatalf("create item type: %v", err)
}
a := &models.Asset{Name: "USB-A Cable", ItemTypeID: cable.ID, StatusID: statusInUse, Quantity: 20}
if err := as.Create(ctx, a, nil); err != nil {
t.Fatalf("create asset: %v", err)
}
cable.IsUnique = true
if err := itStore.Update(ctx, cable); err != nil {
t.Fatalf("update item type: %v", err)
}
got, err := as.Get(ctx, a.ID)
if err != nil {
t.Fatalf("get: %v", err)
}
if got.Quantity != 1 {
t.Fatalf("expected quantity clamped to 1 after type became unique, got %d", got.Quantity)
}
}
func TestAssignUserChecksInAndOut(t *testing.T) {
ctx := context.Background()
as, _, _ := testStores(t)
a := &models.Asset{Name: "Laptop 1", ItemTypeID: itemTypeLaptop, StatusID: statusInUse}
if err := as.Create(ctx, a, nil); err != nil {
t.Fatalf("create: %v", err)
}
userID := int64(1) // seeded admin
if err := as.AssignUser(ctx, a.ID, &userID); err != nil {
t.Fatalf("assign user: %v", err)
}
got, err := as.Get(ctx, a.ID)
if err != nil {
t.Fatalf("get: %v", err)
}
if got.AssignedUserID == nil || *got.AssignedUserID != userID {
t.Fatalf("expected asset checked out to user %d, got %+v", userID, got.AssignedUserID)
}
if err := as.AssignUser(ctx, a.ID, nil); err != nil {
t.Fatalf("check in: %v", err)
}
got, err = as.Get(ctx, a.ID)
if err != nil {
t.Fatalf("get: %v", err)
}
if got.AssignedUserID != nil {
t.Fatalf("expected asset checked in (unassigned), got %+v", got.AssignedUserID)
}
}
@@ -0,0 +1,5 @@
-- Track quantity for non-unique (bulk/consumable) item types, e.g. cables,
-- while leaving serialized items (laptops, servers) fixed at quantity 1.
ALTER TABLE item_types ADD COLUMN is_unique INTEGER NOT NULL DEFAULT 1;
ALTER TABLE assets ADD COLUMN quantity INTEGER NOT NULL DEFAULT 1;
@@ -0,0 +1,19 @@
-- Add a "contact" role: an assignable person (end user) who cannot sign
-- into the application, as distinct from admin/user login accounts.
-- SQLite can't alter a CHECK constraint in place, so the table is rebuilt.
CREATE TABLE users_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
role TEXT NOT NULL CHECK (role IN ('admin', 'user', 'contact')),
enabled INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
);
INSERT INTO users_new (id, username, password_hash, role, enabled, created_at, updated_at)
SELECT id, username, password_hash, role, enabled, created_at, updated_at FROM users;
DROP TABLE users;
ALTER TABLE users_new RENAME TO users;
+17 -8
View File
@@ -58,7 +58,7 @@ func validFieldType(t models.FieldType) bool {
// List returns all item types, optionally including disabled ones, each with
// its custom field definitions loaded.
func (s *Store) List(ctx context.Context, includeDisabled bool) ([]models.ItemType, error) {
q := `SELECT id, name, description, icon, enabled, sort_order, created_at, updated_at FROM item_types`
q := `SELECT id, name, description, icon, enabled, sort_order, is_unique, created_at, updated_at FROM item_types`
if !includeDisabled {
q += ` WHERE enabled = 1`
}
@@ -72,7 +72,7 @@ func (s *Store) List(ctx context.Context, includeDisabled bool) ([]models.ItemTy
for rows.Next() {
var it models.ItemType
var created, updated string
if err := rows.Scan(&it.ID, &it.Name, &it.Description, &it.Icon, &it.Enabled, &it.SortOrder, &created, &updated); err != nil {
if err := rows.Scan(&it.ID, &it.Name, &it.Description, &it.Icon, &it.Enabled, &it.SortOrder, &it.IsUnique, &created, &updated); err != nil {
return nil, err
}
it.CreatedAt = parseTime(created)
@@ -93,10 +93,10 @@ func (s *Store) List(ctx context.Context, includeDisabled bool) ([]models.ItemTy
}
func (s *Store) Get(ctx context.Context, id int64) (*models.ItemType, error) {
row := s.db.QueryRowContext(ctx, `SELECT id, name, description, icon, enabled, sort_order, created_at, updated_at FROM item_types WHERE id = ?`, id)
row := s.db.QueryRowContext(ctx, `SELECT id, name, description, icon, enabled, sort_order, is_unique, created_at, updated_at FROM item_types WHERE id = ?`, id)
var it models.ItemType
var created, updated string
err := row.Scan(&it.ID, &it.Name, &it.Description, &it.Icon, &it.Enabled, &it.SortOrder, &created, &updated)
err := row.Scan(&it.ID, &it.Name, &it.Description, &it.Icon, &it.Enabled, &it.SortOrder, &it.IsUnique, &created, &updated)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
@@ -146,8 +146,8 @@ func (s *Store) Create(ctx context.Context, it *models.ItemType) error {
it.Icon = "bi-box-seam"
}
res, err := s.db.ExecContext(ctx,
`INSERT INTO item_types (name, description, icon, enabled, sort_order) VALUES (?, ?, ?, ?, ?)`,
it.Name, it.Description, it.Icon, it.Enabled, it.SortOrder)
`INSERT INTO item_types (name, description, icon, enabled, sort_order, is_unique) VALUES (?, ?, ?, ?, ?, ?)`,
it.Name, it.Description, it.Icon, it.Enabled, it.SortOrder, it.IsUnique)
if err != nil {
return err
}
@@ -164,8 +164,17 @@ func (s *Store) Update(ctx context.Context, it *models.ItemType) error {
return ErrNameRequired
}
_, err := s.db.ExecContext(ctx,
`UPDATE item_types SET name = ?, description = ?, icon = ?, enabled = ?, sort_order = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?`,
it.Name, it.Description, it.Icon, it.Enabled, it.SortOrder, it.ID)
`UPDATE item_types SET name = ?, description = ?, icon = ?, enabled = ?, sort_order = ?, is_unique = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?`,
it.Name, it.Description, it.Icon, it.Enabled, it.SortOrder, it.IsUnique, it.ID)
if err != nil {
return err
}
if it.IsUnique {
// Item type just became (or remained) unique: bring every asset
// of this type back to quantity 1 so the two concepts can't drift
// out of sync.
_, err = s.db.ExecContext(ctx, `UPDATE assets SET quantity = 1 WHERE item_type_id = ? AND quantity != 1`, it.ID)
}
return err
}
+21 -2
View File
@@ -8,6 +8,11 @@ type Role string
const (
RoleAdmin Role = "admin"
RoleUser Role = "user"
// RoleContact is an assignable person (end user) with no ability to
// sign in - useful for tracking who a checked-out asset belongs to
// without needing to create a login account for everyone in a
// building.
RoleContact Role = "contact"
)
type User struct {
@@ -22,6 +27,10 @@ type User struct {
func (u User) IsAdmin() bool { return u.Role == RoleAdmin }
// CanLogin reports whether this account is allowed to sign in at all;
// contacts exist only to be assigned assets.
func (u User) CanLogin() bool { return u.Role == RoleAdmin || u.Role == RoleUser }
type Folder struct {
ID int64
Name string
@@ -96,6 +105,12 @@ type ItemType struct {
CreatedAt time.Time
UpdatedAt time.Time
// IsUnique means each asset of this type is its own distinct item
// (a laptop with its own serial number, tracked as one row per unit).
// When false, assets of this type are bulk/consumable line items
// (e.g. "USB-A Cable") tracked by Quantity instead of one row each.
IsUnique bool
Fields []CustomFieldDef
}
@@ -129,12 +144,16 @@ type Asset struct {
StatusID int64
AssignedUserID *int64
Notes string
CreatedAt time.Time
UpdatedAt time.Time
// Quantity is meaningful only when the asset's item type is not
// IsUnique; unique-type assets are always kept at 1.
Quantity int
CreatedAt time.Time
UpdatedAt time.Time
// Populated by joined list/detail queries.
ItemTypeName string
ItemTypeIcon string
ItemTypeIsUnique bool
StatusName string
StatusColor string
AssignedUsername string
+10 -3
View File
@@ -16,10 +16,14 @@ var (
ErrNotFound = errors.New("user not found")
ErrUsernameRequired = errors.New("username is required")
ErrUsernameTaken = errors.New("username is already taken")
ErrInvalidRole = errors.New("role must be admin or user")
ErrInvalidRole = errors.New("role must be admin, user, or contact")
ErrLastAdmin = errors.New("cannot remove the last enabled admin")
)
func validRole(r models.Role) bool {
return r == models.RoleAdmin || r == models.RoleUser || r == models.RoleContact
}
type Store struct{ db *sql.DB }
func NewStore(db *sql.DB) *Store { return &Store{db: db} }
@@ -82,7 +86,7 @@ func (s *Store) Create(ctx context.Context, u *models.User, plaintextPassword st
if u.Username == "" {
return ErrUsernameRequired
}
if u.Role != models.RoleAdmin && u.Role != models.RoleUser {
if !validRole(u.Role) {
return ErrInvalidRole
}
if _, err := s.GetByUsername(ctx, u.Username); err == nil {
@@ -113,7 +117,7 @@ func (s *Store) Update(ctx context.Context, u *models.User) error {
if u.Username == "" {
return ErrUsernameRequired
}
if u.Role != models.RoleAdmin && u.Role != models.RoleUser {
if !validRole(u.Role) {
return ErrInvalidRole
}
if existing, err := s.GetByUsername(ctx, u.Username); err == nil && existing.ID != u.ID {
@@ -179,6 +183,9 @@ func (s *Store) Authenticate(ctx context.Context, username, password string) (*m
if !u.Enabled {
return nil, errors.New("account is disabled")
}
if !u.CanLogin() {
return nil, errors.New("this account cannot sign in")
}
if err := bcrypt.CompareHashAndPassword([]byte(u.PasswordHash), []byte(password)); err != nil {
return nil, errors.New("invalid credentials")
}
+69 -2
View File
@@ -15,6 +15,7 @@ type assetRequest struct {
StatusID int64 `json:"status_id"`
AssignedUserID *int64 `json:"assigned_user_id"`
Notes string `json:"notes"`
Quantity int `json:"quantity"`
Fields map[string]string `json:"fields"`
}
@@ -38,7 +39,7 @@ func (a *App) handleAssetCreate(w http.ResponseWriter, r *http.Request) {
}
asset := &models.Asset{
AssetTag: req.AssetTag, Name: req.Name, ItemTypeID: req.ItemTypeID, FolderID: req.FolderID,
StatusID: req.StatusID, AssignedUserID: req.AssignedUserID, Notes: req.Notes,
StatusID: req.StatusID, AssignedUserID: req.AssignedUserID, Notes: req.Notes, Quantity: req.Quantity,
}
if err := a.Assets.Create(r.Context(), asset, req.fieldValues()); err != nil {
jsonErr(w, http.StatusBadRequest, err)
@@ -65,7 +66,7 @@ func (a *App) handleAssetUpdate(w http.ResponseWriter, r *http.Request) {
}
asset := &models.Asset{
ID: id, AssetTag: req.AssetTag, Name: req.Name, ItemTypeID: existing.ItemTypeID, FolderID: req.FolderID,
StatusID: req.StatusID, AssignedUserID: req.AssignedUserID, Notes: req.Notes,
StatusID: req.StatusID, AssignedUserID: req.AssignedUserID, Notes: req.Notes, Quantity: req.Quantity,
}
if err := a.Assets.Update(r.Context(), asset, req.fieldValues()); err != nil {
jsonErr(w, http.StatusBadRequest, err)
@@ -127,3 +128,69 @@ func (a *App) handleAssetMove(w http.ResponseWriter, r *http.Request) {
}
jsonOK(w, map[string]any{"id": id, "folder_id": req.FolderID})
}
// handleAssetGet returns a single asset as JSON, for lightweight lookups
// (e.g. the checkout modal) that don't need the full HTML detail panel.
func (a *App) handleAssetGet(w http.ResponseWriter, r *http.Request) {
id, err := pathID(r, "id")
if err != nil {
jsonErr(w, http.StatusBadRequest, err)
return
}
asset, err := a.Assets.Get(r.Context(), id)
if err != nil {
jsonErr(w, http.StatusNotFound, err)
return
}
jsonOK(w, asset)
}
type assetCheckoutRequest struct {
AssignedUserID *int64 `json:"assigned_user_id"`
}
// handleAssetCheckout assigns (checks out) or unassigns (checks in) an
// asset's user without touching any other field.
func (a *App) handleAssetCheckout(w http.ResponseWriter, r *http.Request) {
id, err := pathID(r, "id")
if err != nil {
jsonErr(w, http.StatusBadRequest, err)
return
}
var req assetCheckoutRequest
if err := readJSON(r, &req); err != nil {
jsonErr(w, http.StatusBadRequest, err)
return
}
if err := a.Assets.AssignUser(r.Context(), id, req.AssignedUserID); err != nil {
jsonErr(w, http.StatusInternalServerError, err)
return
}
jsonOK(w, map[string]any{"id": id, "assigned_user_id": req.AssignedUserID})
}
type assetQuantityRequest struct {
Delta int `json:"delta"`
}
// handleAssetQuantity increments or decrements a non-unique asset's
// quantity in place, for the +/- steppers on the asset panel and folder
// tables.
func (a *App) handleAssetQuantity(w http.ResponseWriter, r *http.Request) {
id, err := pathID(r, "id")
if err != nil {
jsonErr(w, http.StatusBadRequest, err)
return
}
var req assetQuantityRequest
if err := readJSON(r, &req); err != nil {
jsonErr(w, http.StatusBadRequest, err)
return
}
asset, err := a.Assets.AdjustQuantity(r.Context(), id, req.Delta)
if err != nil {
jsonErr(w, http.StatusBadRequest, err)
return
}
jsonOK(w, map[string]any{"id": id, "quantity": asset.Quantity})
}
+37 -14
View File
@@ -13,6 +13,12 @@ type InventoryPageData struct {
Tree []models.TreeNode
ItemTypes []models.ItemType
Statuses []models.Status
// AutoOpenAssetID and ScanError come from a redirect out of
// /scan/<tag> (a QR/barcode label scan): either the asset to select
// on load, or the tag that didn't match anything.
AutoOpenAssetID string
ScanError string
}
func (a *App) handleInventoryPage(w http.ResponseWriter, r *http.Request) {
@@ -42,6 +48,7 @@ func (a *App) handleInventoryPage(w http.ResponseWriter, r *http.Request) {
a.Render(w, http.StatusOK, "inventory", InventoryPageData{
User: u, Tree: tree, ItemTypes: itemTypes, Statuses: statusList,
AutoOpenAssetID: r.URL.Query().Get("asset"), ScanError: r.URL.Query().Get("scan_error"),
})
}
@@ -208,14 +215,16 @@ func (a *App) handlePanelAsset(w http.ResponseWriter, r *http.Request) {
// AssetFormData backs both the "new asset" and "edit asset" forms.
type AssetFormData struct {
Asset *models.Asset // nil when creating
FolderID *int64
ItemTypes []models.ItemType
Statuses []models.Status
Users []models.User
FolderOptions []FolderOption
SelectedIT int64
Fields []models.AssetFieldValue
Asset *models.Asset // nil when creating
FolderID *int64
ItemTypes []models.ItemType
Statuses []models.Status
Users []models.User
FolderOptions []FolderOption
SelectedIT int64
SelectedITUnique bool
Quantity int
Fields []models.AssetFieldValue
}
func (a *App) handlePanelAssetNew(w http.ResponseWriter, r *http.Request) {
@@ -247,15 +256,17 @@ func (a *App) handlePanelAssetNew(w http.ResponseWriter, r *http.Request) {
}
var selectedIT int64
var selectedITUnique bool
var fields []models.AssetFieldValue
if len(itemTypes) > 0 {
selectedIT = itemTypes[0].ID
selectedITUnique = itemTypes[0].IsUnique
fields = fieldValuesForCreate(itemTypes[0].Fields)
}
a.RenderFragment(w, http.StatusOK, "asset_form", AssetFormData{
FolderID: folderID, ItemTypes: itemTypes, Statuses: statusList, Users: userList, FolderOptions: folderOpts,
SelectedIT: selectedIT, Fields: fields,
SelectedIT: selectedIT, SelectedITUnique: selectedITUnique, Quantity: 1, Fields: fields,
})
}
@@ -294,13 +305,23 @@ func (a *App) handlePanelAssetEdit(w http.ResponseWriter, r *http.Request) {
a.RenderFragment(w, http.StatusOK, "asset_form", AssetFormData{
Asset: asset, ItemTypes: itemTypes, Statuses: statusList, Users: userList, FolderOptions: folderOpts,
SelectedIT: asset.ItemTypeID, Fields: asset.CustomFields,
SelectedIT: asset.ItemTypeID, SelectedITUnique: asset.ItemTypeIsUnique, Quantity: asset.Quantity, Fields: asset.CustomFields,
})
}
// handleAssetFieldsFragment renders just the dynamic custom-field inputs for
// a chosen item type, used when the item type dropdown changes on the
// "new asset" form.
// AssetDynamicFieldsData backs the "asset_fields_only" partial: the
// quantity input (only relevant for non-unique item types) plus the
// type's custom fields. It's swapped in whenever the item type dropdown
// changes on the "new asset" form, and also used for the initial render.
type AssetDynamicFieldsData struct {
IsUnique bool
Quantity int
Fields []models.AssetFieldValue
}
// handleAssetFieldsFragment renders just the dynamic quantity/custom-field
// inputs for a chosen item type, used when the item type dropdown changes
// on the "new asset" form.
func (a *App) handleAssetFieldsFragment(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
itemTypeID := queryInt64(r, "item_type_id")
@@ -309,5 +330,7 @@ func (a *App) handleAssetFieldsFragment(w http.ResponseWriter, r *http.Request)
writeErr(w, http.StatusNotFound, err.Error())
return
}
a.RenderFragment(w, http.StatusOK, "asset_fields_only", fieldValuesForCreate(it.Fields))
a.RenderFragment(w, http.StatusOK, "asset_fields_only", AssetDynamicFieldsData{
IsUnique: it.IsUnique, Quantity: 1, Fields: fieldValuesForCreate(it.Fields),
})
}
+2
View File
@@ -41,6 +41,7 @@ func (a *App) handleItemTypeCreate(w http.ResponseWriter, r *http.Request) {
it := &models.ItemType{
Name: r.FormValue("name"), Description: r.FormValue("description"),
Icon: r.FormValue("icon"), Enabled: r.FormValue("enabled") == "on", SortOrder: atoiDefault(r.FormValue("sort_order"), 0),
IsUnique: r.FormValue("is_unique") == "on",
}
if err := a.ItemTypes.Create(r.Context(), it); err != nil {
a.Render(w, http.StatusBadRequest, "itemtypes_form", itemTypeFormData{User: currentUser(r), ItemType: it, FieldTypes: models.AllFieldTypes, Error: err.Error()})
@@ -83,6 +84,7 @@ func (a *App) handleItemTypeUpdate(w http.ResponseWriter, r *http.Request) {
it.Icon = r.FormValue("icon")
it.Enabled = r.FormValue("enabled") == "on"
it.SortOrder = atoiDefault(r.FormValue("sort_order"), it.SortOrder)
it.IsUnique = r.FormValue("is_unique") == "on"
if err := a.ItemTypes.Update(r.Context(), it); err != nil {
a.Render(w, http.StatusBadRequest, "itemtypes_form", itemTypeFormData{User: currentUser(r), ItemType: it, FieldTypes: models.AllFieldTypes, Error: err.Error()})
return
+158
View File
@@ -0,0 +1,158 @@
package web
import (
"fmt"
"html/template"
"net/http"
"inventree/internal/assets"
"inventree/internal/models"
)
// LabelTemplate describes a sheet of adhesive labels: cell size, grid, and
// page margins, all in inches, used to lay out printable asset tags.
type LabelTemplate struct {
Key string
DisplayName string
LabelWidthIn float64
LabelHeightIn float64
Cols int
Rows int
MarginTopIn float64
MarginLeftIn float64
ColGapIn float64
RowGapIn float64
QRSizeIn float64
// Approximate is true for templates whose margins/grid aren't from a
// verified manufacturer spec sheet - print one test page on plain
// paper and hold it up to a real label sheet before committing a
// full (often expensive/waterproof) sheet to the printer.
Approximate bool
}
var labelTemplates = map[string]LabelTemplate{
"5160": {
Key: "5160", DisplayName: `Avery 5160 - 1" x 2-5/8" (30/sheet)`,
LabelWidthIn: 2.625, LabelHeightIn: 1, Cols: 3, Rows: 10,
MarginTopIn: 0.5, MarginLeftIn: 0.1875, ColGapIn: 0.125, RowGapIn: 0,
QRSizeIn: 0.85,
},
"61526": {
Key: "61526", DisplayName: `Asset Tag Labels 61526 - 3/4" x 2" (~48/sheet)`,
LabelWidthIn: 2, LabelHeightIn: 0.75, Cols: 4, Rows: 12,
MarginTopIn: 1, MarginLeftIn: 0.25, ColGapIn: 0, RowGapIn: 0,
QRSizeIn: 0.62, Approximate: true,
},
}
const defaultLabelTemplate = "5160"
func (t LabelTemplate) css() template.CSS {
return template.CSS(fmt.Sprintf(`
.label-sheet {
width: 8.5in;
box-sizing: border-box;
padding: %.4fin %.4fin;
margin: 0 auto;
display: grid;
grid-template-columns: repeat(%d, %.4fin);
column-gap: %.4fin;
row-gap: %.4fin;
justify-content: center;
}
.label {
width: %.4fin;
height: %.4fin;
box-sizing: border-box;
padding: 0.06in 0.08in;
display: flex;
align-items: center;
gap: 0.08in;
overflow: hidden;
page-break-inside: avoid;
}
.label img { width: %.4fin; height: %.4fin; flex-shrink: 0; }
`,
t.MarginTopIn, t.MarginLeftIn, t.Cols, t.LabelWidthIn, t.ColGapIn, t.RowGapIn,
t.LabelWidthIn, t.LabelHeightIn, t.QRSizeIn, t.QRSizeIn))
}
type labelItem struct {
Asset models.Asset
// template.URL marks this as pre-vetted safe (it's server-generated,
// never user input), so html/template's URL filter doesn't replace it
// with the "#ZgotmplZ" safety placeholder the way it would a plain
// string containing a data: URI.
QRData template.URL
}
type labelsPageData struct {
User *models.User
Labels []labelItem
SheetCSS template.CSS
Templates map[string]LabelTemplate
TemplateKeys []string
Selected LabelTemplate
AssetID string
FolderID string
Recursive bool
}
// handleLabelsPage renders a print-ready sheet of QR-coded asset labels:
// either a single asset (?asset_id=) or every asset in a folder
// (?folder_id=&recursive=1), or the whole inventory with neither param.
// Each QR code encodes an absolute /scan/<tag> URL so a phone camera (or a
// USB barcode scanner focused on a browser address bar) opens straight to
// that asset.
func (a *App) handleLabelsPage(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
origin := requestOrigin(r)
tmpl, ok := labelTemplates[r.URL.Query().Get("template")]
if !ok {
tmpl = labelTemplates[defaultLabelTemplate]
}
var list []models.Asset
assetIDParam := r.URL.Query().Get("asset_id")
folderIDParam := r.URL.Query().Get("folder_id")
recursive := r.URL.Query().Get("recursive") == "1"
if assetID, err := queryOptionalID(r, "asset_id"); err == nil && assetID != nil {
asset, err := a.Assets.Get(ctx, *assetID)
if err != nil {
writeErr(w, http.StatusNotFound, err.Error())
return
}
list = []models.Asset{*asset}
} else {
filter := assets.ListFilter{Page: 1, PageSize: 2000}
if folderID, err := queryOptionalID(r, "folder_id"); err == nil && folderID != nil {
filter.FolderID = folderID
filter.FolderSet = true
filter.IncludeSub = recursive
}
batch, _, err := a.Assets.List(ctx, filter)
if err != nil {
writeErr(w, http.StatusInternalServerError, err.Error())
return
}
list = batch
}
labels := make([]labelItem, 0, len(list))
for _, as := range list {
qr, err := qrDataURI(origin+"/scan/"+as.AssetTag, 220)
if err != nil {
writeErr(w, http.StatusInternalServerError, err.Error())
return
}
labels = append(labels, labelItem{Asset: as, QRData: template.URL(qr)})
}
a.Render(w, http.StatusOK, "labels", labelsPageData{
User: currentUser(r), Labels: labels, SheetCSS: tmpl.css(),
Templates: labelTemplates, TemplateKeys: []string{"5160", "61526"},
Selected: tmpl, AssetID: assetIDParam, FolderID: folderIDParam, Recursive: recursive,
})
}
+21
View File
@@ -0,0 +1,21 @@
package web
import (
"fmt"
"net/http"
"net/url"
)
// handleScan resolves a scanned or printed asset tag (the payload of a
// label's QR code) and redirects into the inventory page with that asset
// selected. Scanning with a phone camera opens this URL directly; a USB
// barcode scanner focused on the browser's address bar works the same way.
func (a *App) handleScan(w http.ResponseWriter, r *http.Request) {
tag := r.PathValue("tag")
asset, err := a.Assets.GetByTag(r.Context(), tag)
if err != nil {
http.Redirect(w, r, "/inventory?scan_error="+url.QueryEscape(tag), http.StatusSeeOther)
return
}
http.Redirect(w, r, fmt.Sprintf("/inventory?asset=%d", asset.ID), http.StatusSeeOther)
}
+60 -10
View File
@@ -1,12 +1,38 @@
package web
import (
"crypto/rand"
"encoding/hex"
"net/http"
"strconv"
"inventree/internal/models"
)
// parseRole maps a form's role value to a models.Role, defaulting to
// RoleUser for anything unrecognized.
func parseRole(s string) models.Role {
switch s {
case "admin":
return models.RoleAdmin
case "contact":
return models.RoleContact
default:
return models.RoleUser
}
}
// randomPassword generates an unguessable password for contact accounts,
// which can never sign in (see models.User.CanLogin) but still need some
// non-empty password_hash in storage.
func randomPassword() (string, error) {
b := make([]byte, 24)
if _, err := rand.Read(b); err != nil {
return "", err
}
return hex.EncodeToString(b), nil
}
type usersListData struct {
User *models.User
Users []models.User
@@ -21,6 +47,28 @@ func (a *App) handleUsersList(w http.ResponseWriter, r *http.Request) {
a.Render(w, http.StatusOK, "users_list", usersListData{User: currentUser(r), Users: list})
}
// handleUserOptions returns enabled users as JSON, for pickers like the
// asset checkout modal (a currently-assigned but since-disabled user still
// displays correctly elsewhere; they just won't appear as a new choice).
func (a *App) handleUserOptions(w http.ResponseWriter, r *http.Request) {
list, err := a.Users.List(r.Context())
if err != nil {
jsonErr(w, http.StatusInternalServerError, err)
return
}
type option struct {
ID int64 `json:"id"`
Username string `json:"username"`
}
out := make([]option, 0, len(list))
for _, u := range list {
if u.Enabled {
out = append(out, option{ID: u.ID, Username: u.Username})
}
}
jsonOK(w, out)
}
type userFormData struct {
User *models.User
EditUser *models.User
@@ -36,15 +84,20 @@ func (a *App) handleUserCreate(w http.ResponseWriter, r *http.Request) {
writeErr(w, http.StatusBadRequest, err.Error())
return
}
role := models.RoleUser
if r.FormValue("role") == "admin" {
role = models.RoleAdmin
}
role := parseRole(r.FormValue("role"))
u := &models.User{Username: r.FormValue("username"), Role: role, Enabled: r.FormValue("enabled") == "on"}
password := r.FormValue("password")
if password == "" {
a.Render(w, http.StatusBadRequest, "users_form", userFormData{User: currentUser(r), EditUser: u, Error: "Password is required."})
return
if role != models.RoleContact {
a.Render(w, http.StatusBadRequest, "users_form", userFormData{User: currentUser(r), EditUser: u, Error: "Password is required."})
return
}
generated, err := randomPassword()
if err != nil {
writeErr(w, http.StatusInternalServerError, err.Error())
return
}
password = generated
}
if err := a.Users.Create(r.Context(), u, password); err != nil {
a.Render(w, http.StatusBadRequest, "users_form", userFormData{User: currentUser(r), EditUser: u, Error: err.Error()})
@@ -83,10 +136,7 @@ func (a *App) handleUserUpdate(w http.ResponseWriter, r *http.Request) {
return
}
u.Username = r.FormValue("username")
u.Role = models.RoleUser
if r.FormValue("role") == "admin" {
u.Role = models.RoleAdmin
}
u.Role = parseRole(r.FormValue("role"))
u.Enabled = r.FormValue("enabled") == "on"
if err := a.Users.Update(r.Context(), u); err != nil {
a.Render(w, http.StatusBadRequest, "users_form", userFormData{User: currentUser(r), EditUser: u, Error: err.Error()})
+31
View File
@@ -0,0 +1,31 @@
package web
import (
"encoding/base64"
"net/http"
qrcode "github.com/skip2/go-qrcode"
)
// qrDataURI renders content as a QR code PNG and returns it as a data: URI
// suitable for an <img src>, so labels need no separate image requests.
func qrDataURI(content string, size int) (string, error) {
png, err := qrcode.Encode(content, qrcode.Medium, size)
if err != nil {
return "", err
}
return "data:image/png;base64," + base64.StdEncoding.EncodeToString(png), nil
}
// requestOrigin reconstructs the scheme+host the request arrived on, for
// building absolute URLs (e.g. QR codes need a full URL, not a path).
func requestOrigin(r *http.Request) string {
scheme := "http"
if r.TLS != nil {
scheme = "https"
}
if proto := r.Header.Get("X-Forwarded-Proto"); proto != "" {
scheme = proto
}
return scheme + "://" + r.Host
}
+10
View File
@@ -49,16 +49,26 @@ func (a *App) Router() http.Handler {
mux.HandleFunc("DELETE /app/folders/{id}", a.requireAdmin(a.handleFolderDelete))
// Asset mutations (JSON in/out)
mux.HandleFunc("GET /app/assets/{id}", a.requireAdmin(a.handleAssetGet))
mux.HandleFunc("POST /app/assets", a.requireAdmin(a.handleAssetCreate))
mux.HandleFunc("PATCH /app/assets/{id}", a.requireAdmin(a.handleAssetUpdate))
mux.HandleFunc("DELETE /app/assets/{id}", a.requireAdmin(a.handleAssetDelete))
mux.HandleFunc("POST /app/assets/{id}/duplicate", a.requireAdmin(a.handleAssetDuplicate))
mux.HandleFunc("POST /app/assets/{id}/move", a.requireAdmin(a.handleAssetMove))
mux.HandleFunc("POST /app/assets/{id}/checkout", a.requireAdmin(a.handleAssetCheckout))
mux.HandleFunc("POST /app/assets/{id}/quantity", a.requireAdmin(a.handleAssetQuantity))
mux.HandleFunc("GET /app/users/options", a.requireAdmin(a.handleUserOptions))
// CSV import/export
mux.HandleFunc("GET /export/assets.csv", a.requireLogin(a.handleExportCSV))
mux.HandleFunc("POST /import/assets.csv", a.requireAdmin(a.handleImportCSV))
// QR asset tags: scan a printed label to jump to that asset, or print
// a sheet of labels for a folder/asset.
mux.HandleFunc("GET /scan/{tag}", a.requireLogin(a.handleScan))
mux.HandleFunc("GET /labels", a.requireLogin(a.handleLabelsPage))
// Settings (admin, traditional pages) - Item Types, Users, and general
// settings all live under one unified /settings section with shared
// sub-navigation, rather than as separate top-level nav items.