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.
257 lines
7.6 KiB
Go
257 lines
7.6 KiB
Go
// Package itemtypes manages admin-defined asset types and their custom fields.
|
|
package itemtypes
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"errors"
|
|
"regexp"
|
|
"strings"
|
|
"time"
|
|
|
|
"inventree/internal/models"
|
|
)
|
|
|
|
var (
|
|
ErrNotFound = errors.New("item type not found")
|
|
ErrNameRequired = errors.New("item type name is required")
|
|
ErrInUse = errors.New("item type is assigned to one or more assets")
|
|
|
|
ErrFieldNotFound = errors.New("custom field not found")
|
|
ErrFieldName = errors.New("field name is required")
|
|
ErrFieldType = errors.New("invalid field type")
|
|
)
|
|
|
|
type Store struct{ db *sql.DB }
|
|
|
|
func NewStore(db *sql.DB) *Store { return &Store{db: db} }
|
|
|
|
const timeLayout = "2006-01-02T15:04:05.000Z"
|
|
|
|
func parseTime(s string) time.Time {
|
|
t, _ := time.Parse(timeLayout, s)
|
|
return t
|
|
}
|
|
|
|
var slugRe = regexp.MustCompile(`[^a-z0-9_]+`)
|
|
|
|
// Slugify converts a field name into a snake_case key suitable as field_key.
|
|
func Slugify(name string) string {
|
|
s := strings.ToLower(strings.TrimSpace(name))
|
|
s = slugRe.ReplaceAllString(s, "_")
|
|
s = strings.Trim(s, "_")
|
|
if s == "" {
|
|
s = "field"
|
|
}
|
|
return s
|
|
}
|
|
|
|
func validFieldType(t models.FieldType) bool {
|
|
for _, ft := range models.AllFieldTypes {
|
|
if ft == t {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// 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, is_unique, created_at, updated_at FROM item_types`
|
|
if !includeDisabled {
|
|
q += ` WHERE enabled = 1`
|
|
}
|
|
q += ` ORDER BY sort_order ASC, name COLLATE NOCASE ASC`
|
|
rows, err := s.db.QueryContext(ctx, q)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
var out []models.ItemType
|
|
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, &it.IsUnique, &created, &updated); err != nil {
|
|
return nil, err
|
|
}
|
|
it.CreatedAt = parseTime(created)
|
|
it.UpdatedAt = parseTime(updated)
|
|
out = append(out, it)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
for i := range out {
|
|
fields, err := s.Fields(ctx, out[i].ID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out[i].Fields = fields
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
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, 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, &it.IsUnique, &created, &updated)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return nil, ErrNotFound
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
it.CreatedAt = parseTime(created)
|
|
it.UpdatedAt = parseTime(updated)
|
|
fields, err := s.Fields(ctx, it.ID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
it.Fields = fields
|
|
return &it, nil
|
|
}
|
|
|
|
func (s *Store) Fields(ctx context.Context, itemTypeID int64) ([]models.CustomFieldDef, error) {
|
|
rows, err := s.db.QueryContext(ctx,
|
|
`SELECT id, item_type_id, name, field_key, field_type, required, default_value, options, sort_order, created_at, updated_at
|
|
FROM custom_field_defs WHERE item_type_id = ? ORDER BY sort_order ASC, name COLLATE NOCASE ASC`, itemTypeID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
var out []models.CustomFieldDef
|
|
for rows.Next() {
|
|
var f models.CustomFieldDef
|
|
var created, updated, options string
|
|
if err := rows.Scan(&f.ID, &f.ItemTypeID, &f.Name, &f.FieldKey, &f.FieldType, &f.Required, &f.DefaultValue, &options, &f.SortOrder, &created, &updated); err != nil {
|
|
return nil, err
|
|
}
|
|
if options != "" {
|
|
f.Options = strings.Split(options, "\n")
|
|
}
|
|
f.CreatedAt = parseTime(created)
|
|
f.UpdatedAt = parseTime(updated)
|
|
out = append(out, f)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
func (s *Store) Create(ctx context.Context, it *models.ItemType) error {
|
|
if it.Name == "" {
|
|
return ErrNameRequired
|
|
}
|
|
if it.Icon == "" {
|
|
it.Icon = "bi-box-seam"
|
|
}
|
|
res, err := s.db.ExecContext(ctx,
|
|
`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
|
|
}
|
|
id, err := res.LastInsertId()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
it.ID = id
|
|
return nil
|
|
}
|
|
|
|
func (s *Store) Update(ctx context.Context, it *models.ItemType) error {
|
|
if it.Name == "" {
|
|
return ErrNameRequired
|
|
}
|
|
_, err := s.db.ExecContext(ctx,
|
|
`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
|
|
}
|
|
|
|
func (s *Store) Delete(ctx context.Context, id int64) error {
|
|
var count int
|
|
if err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM assets WHERE item_type_id = ?`, id).Scan(&count); err != nil {
|
|
return err
|
|
}
|
|
if count > 0 {
|
|
return ErrInUse
|
|
}
|
|
_, err := s.db.ExecContext(ctx, `DELETE FROM item_types WHERE id = ?`, id)
|
|
return err
|
|
}
|
|
|
|
func (s *Store) CreateField(ctx context.Context, f *models.CustomFieldDef) error {
|
|
if f.Name == "" {
|
|
return ErrFieldName
|
|
}
|
|
if !validFieldType(f.FieldType) {
|
|
return ErrFieldType
|
|
}
|
|
if f.FieldKey == "" {
|
|
f.FieldKey = Slugify(f.Name)
|
|
}
|
|
res, err := s.db.ExecContext(ctx,
|
|
`INSERT INTO custom_field_defs (item_type_id, name, field_key, field_type, required, default_value, options, sort_order)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
f.ItemTypeID, f.Name, f.FieldKey, f.FieldType, f.Required, f.DefaultValue, strings.Join(f.Options, "\n"), f.SortOrder)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
id, err := res.LastInsertId()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
f.ID = id
|
|
return nil
|
|
}
|
|
|
|
func (s *Store) UpdateField(ctx context.Context, f *models.CustomFieldDef) error {
|
|
if f.Name == "" {
|
|
return ErrFieldName
|
|
}
|
|
if !validFieldType(f.FieldType) {
|
|
return ErrFieldType
|
|
}
|
|
_, err := s.db.ExecContext(ctx,
|
|
`UPDATE custom_field_defs SET name = ?, field_type = ?, required = ?, default_value = ?, options = ?, sort_order = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now')
|
|
WHERE id = ?`,
|
|
f.Name, f.FieldType, f.Required, f.DefaultValue, strings.Join(f.Options, "\n"), f.SortOrder, f.ID)
|
|
return err
|
|
}
|
|
|
|
func (s *Store) DeleteField(ctx context.Context, id int64) error {
|
|
_, err := s.db.ExecContext(ctx, `DELETE FROM custom_field_defs WHERE id = ?`, id)
|
|
return err
|
|
}
|
|
|
|
func (s *Store) GetField(ctx context.Context, id int64) (*models.CustomFieldDef, error) {
|
|
row := s.db.QueryRowContext(ctx,
|
|
`SELECT id, item_type_id, name, field_key, field_type, required, default_value, options, sort_order, created_at, updated_at
|
|
FROM custom_field_defs WHERE id = ?`, id)
|
|
var f models.CustomFieldDef
|
|
var created, updated, options string
|
|
err := row.Scan(&f.ID, &f.ItemTypeID, &f.Name, &f.FieldKey, &f.FieldType, &f.Required, &f.DefaultValue, &options, &f.SortOrder, &created, &updated)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return nil, ErrFieldNotFound
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if options != "" {
|
|
f.Options = strings.Split(options, "\n")
|
|
}
|
|
f.CreatedAt = parseTime(created)
|
|
f.UpdatedAt = parseTime(updated)
|
|
return &f, nil
|
|
}
|