// Package assets implements CRUD, search, and CSV import/export for IT // assets. package assets import ( "context" "database/sql" "errors" "fmt" "strconv" "strings" "time" "inventree/internal/folders" "inventree/internal/models" "inventree/internal/settings" ) var ( ErrNotFound = errors.New("asset not found") ErrNameRequired = errors.New("asset name is required") ErrTagRequired = errors.New("asset ID is required") ErrTagTaken = errors.New("asset ID is already in use") ErrFieldRequired = errors.New("a required custom field is missing a value") ) type Store struct { db *sql.DB settings *settings.Store folders *folders.Store } func NewStore(db *sql.DB, st *settings.Store, fs *folders.Store) *Store { return &Store{db: db, settings: st, folders: fs} } const timeLayout = "2006-01-02T15:04:05.000Z" func parseTime(s string) time.Time { t, _ := time.Parse(timeLayout, s) return t } // GenerateAssetTag produces the next available asset ID such as IT-000042, // based on the configured prefix/padding, retrying past any manually // assigned collisions. func (s *Store) GenerateAssetTag(ctx context.Context) (string, error) { prefix, err := s.settings.Get(ctx, "asset_id_prefix", "IT-") if err != nil { return "", err } paddingStr, err := s.settings.Get(ctx, "asset_id_padding", "6") if err != nil { return "", err } padding, err := strconv.Atoi(paddingStr) if err != nil || padding <= 0 { padding = 6 } tx, err := s.db.BeginTx(ctx, nil) if err != nil { return "", err } defer tx.Rollback() var seq int64 var seqStr string if err := tx.QueryRowContext(ctx, `SELECT value FROM settings WHERE key = 'asset_id_seq'`).Scan(&seqStr); err != nil && !errors.Is(err, sql.ErrNoRows) { return "", err } if seqStr != "" { if n, err := strconv.ParseInt(seqStr, 10, 64); err == nil { seq = n } } var candidate string for { seq++ candidate = fmt.Sprintf("%s%0*d", prefix, padding, seq) var exists int if err := tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM assets WHERE asset_id = ?`, candidate).Scan(&exists); err != nil { return "", err } if exists == 0 { break } } if _, err := tx.ExecContext(ctx, `INSERT INTO settings (key, value, updated_at) VALUES ('asset_id_seq', ?, strftime('%Y-%m-%dT%H:%M:%fZ','now')) ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at`, strconv.FormatInt(seq, 10)); err != nil { return "", err } if err := tx.Commit(); err != nil { return "", err } return candidate, nil } func (s *Store) tagExists(ctx context.Context, tag string, excludeID int64) (bool, error) { var count int err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM assets WHERE asset_id = ? AND id != ?`, tag, excludeID).Scan(&count) return count > 0, err } // 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 { if a.Name == "" { return ErrNameRequired } if a.AssetTag == "" { tag, err := s.GenerateAssetTag(ctx) if err != nil { return err } a.AssetTag = tag } else if taken, err := s.tagExists(ctx, a.AssetTag, 0); err != nil { return err } else if taken { return ErrTagTaken } tx, err := s.db.BeginTx(ctx, nil) if err != nil { return err } 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) if err != nil { return err } id, err := res.LastInsertId() if err != nil { return err } a.ID = id if err := saveFieldValues(ctx, tx, id, fieldValues); err != nil { return err } return tx.Commit() } func saveFieldValues(ctx context.Context, tx *sql.Tx, assetID int64, values map[int64]string) error { for defID, val := range values { if _, err := tx.ExecContext(ctx, `INSERT INTO custom_field_values (asset_id, field_def_id, value) VALUES (?, ?, ?) ON CONFLICT(asset_id, field_def_id) DO UPDATE SET value = excluded.value`, assetID, defID, val); err != nil { return err } } return nil } func (s *Store) Update(ctx context.Context, a *models.Asset, fieldValues map[int64]string) error { if a.Name == "" { return ErrNameRequired } if a.AssetTag == "" { return ErrTagRequired } if taken, err := s.tagExists(ctx, a.AssetTag, a.ID); err != nil { return err } else if taken { return ErrTagTaken } tx, err := s.db.BeginTx(ctx, nil) if err != nil { return err } defer tx.Rollback() _, err = tx.ExecContext(ctx, `UPDATE assets SET asset_id = ?, name = ?, item_type_id = ?, folder_id = ?, status_id = ?, assigned_user_id = ?, notes = ?, 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) if err != nil { return err } if _, err := tx.ExecContext(ctx, `DELETE FROM custom_field_values WHERE asset_id = ?`, a.ID); err != nil { return err } if err := saveFieldValues(ctx, tx, a.ID, fieldValues); err != nil { return err } return tx.Commit() } func (s *Store) Delete(ctx context.Context, id int64) error { _, err := s.db.ExecContext(ctx, `DELETE FROM assets WHERE id = ?`, id) return err } func (s *Store) Move(ctx context.Context, id int64, folderID *int64) error { _, err := s.db.ExecContext(ctx, `UPDATE assets SET folder_id = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?`, folderID, id) return err } // 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) { orig, err := s.Get(ctx, id) if err != nil { return nil, err } tag, err := s.GenerateAssetTag(ctx) if err != nil { return nil, err } copyAsset := &models.Asset{ AssetTag: tag, Name: orig.Name + " (Copy)", ItemTypeID: orig.ItemTypeID, FolderID: orig.FolderID, StatusID: orig.StatusID, AssignedUserID: orig.AssignedUserID, Notes: orig.Notes, } values := map[int64]string{} for _, fv := range orig.CustomFields { if fv.Value != "" { values[fv.Def.ID] = fv.Value } } if err := s.Create(ctx, copyAsset, values); err != nil { return nil, err } return copyAsset, nil } 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, '') FROM assets a JOIN item_types it ON it.id = a.item_type_id JOIN statuses st ON st.id = a.status_id LEFT JOIN users u ON u.id = a.assigned_user_id` 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 { return nil, err } if folderID.Valid { a.FolderID = &folderID.Int64 } if assignedUserID.Valid { a.AssignedUserID = &assignedUserID.Int64 } a.CreatedAt = parseTime(created) a.UpdatedAt = parseTime(updated) return &a, nil } // Get loads an asset with its custom field values (including empty values // for fields with no data yet) and folder breadcrumb path. func (s *Store) Get(ctx context.Context, id int64) (*models.Asset, error) { row := s.db.QueryRowContext(ctx, assetSelect+` WHERE a.id = ?`, id) a, err := scanAsset(row) if errors.Is(err, sql.ErrNoRows) { return nil, ErrNotFound } if err != nil { return nil, err } if err := s.loadExtras(ctx, a); err != nil { return nil, err } return a, nil } func (s *Store) GetByTag(ctx context.Context, tag string) (*models.Asset, error) { row := s.db.QueryRowContext(ctx, assetSelect+` WHERE a.asset_id = ?`, tag) a, err := scanAsset(row) if errors.Is(err, sql.ErrNoRows) { return nil, ErrNotFound } if err != nil { return nil, err } if err := s.loadExtras(ctx, a); err != nil { return nil, err } return a, nil } func (s *Store) loadExtras(ctx context.Context, a *models.Asset) error { fields, err := s.fieldValues(ctx, a.ID, a.ItemTypeID) if err != nil { return err } a.CustomFields = fields if a.FolderID != nil { path, err := s.folders.Path(ctx, *a.FolderID) if err != nil { return err } a.FolderPath = path } return nil } func (s *Store) fieldValues(ctx context.Context, assetID, itemTypeID int64) ([]models.AssetFieldValue, error) { rows, err := s.db.QueryContext(ctx, ` SELECT d.id, d.item_type_id, d.name, d.field_key, d.field_type, d.required, d.default_value, d.options, d.sort_order, COALESCE(v.value, '') FROM custom_field_defs d LEFT JOIN custom_field_values v ON v.field_def_id = d.id AND v.asset_id = ? WHERE d.item_type_id = ? ORDER BY d.sort_order ASC, d.name COLLATE NOCASE ASC`, assetID, itemTypeID) if err != nil { return nil, err } defer rows.Close() var out []models.AssetFieldValue for rows.Next() { var fv models.AssetFieldValue var options string if err := rows.Scan(&fv.Def.ID, &fv.Def.ItemTypeID, &fv.Def.Name, &fv.Def.FieldKey, &fv.Def.FieldType, &fv.Def.Required, &fv.Def.DefaultValue, &options, &fv.Def.SortOrder, &fv.Value); err != nil { return nil, err } if options != "" { fv.Def.Options = strings.Split(options, "\n") } out = append(out, fv) } return out, rows.Err() } // ListFilter narrows a List query. A nil pointer means "don't filter on // this field"; FolderID pointing at a zero value with FolderSet=true means // "top-level / unfiled only". type ListFilter struct { FolderID *int64 FolderSet bool IncludeSub bool // if true and FolderID set, include assets in descendant folders too ItemTypeID int64 StatusID int64 AssignedUserID int64 Query string Page int PageSize int } // List returns assets matching the filter plus the total matching row count // (for pagination), ordered by name. func (s *Store) List(ctx context.Context, f ListFilter) ([]models.Asset, int, error) { var where []string var args []any if f.FolderSet { if f.IncludeSub && f.FolderID != nil { ids, err := s.descendantFolderIDs(ctx, *f.FolderID) if err != nil { return nil, 0, err } ids = append(ids, *f.FolderID) placeholders := make([]string, len(ids)) for i, id := range ids { placeholders[i] = "?" args = append(args, id) } where = append(where, "a.folder_id IN ("+strings.Join(placeholders, ",")+")") } else if f.FolderID == nil { where = append(where, "a.folder_id IS NULL") } else { where = append(where, "a.folder_id = ?") args = append(args, *f.FolderID) } } if f.ItemTypeID != 0 { where = append(where, "a.item_type_id = ?") args = append(args, f.ItemTypeID) } if f.StatusID != 0 { where = append(where, "a.status_id = ?") args = append(args, f.StatusID) } if f.AssignedUserID != 0 { where = append(where, "a.assigned_user_id = ?") args = append(args, f.AssignedUserID) } if f.Query != "" { where = append(where, "(a.name LIKE '%'||?||'%' COLLATE NOCASE OR a.asset_id LIKE '%'||?||'%' COLLATE NOCASE)") args = append(args, f.Query, f.Query) } whereClause := "" if len(where) > 0 { whereClause = "WHERE " + strings.Join(where, " AND ") } var total int countQuery := `SELECT COUNT(*) FROM assets a ` + whereClause if err := s.db.QueryRowContext(ctx, countQuery, args...).Scan(&total); err != nil { return nil, 0, err } page, pageSize := f.Page, f.PageSize if page < 1 { page = 1 } if pageSize < 1 || pageSize > 500 { pageSize = 50 } offset := (page - 1) * pageSize query := assetSelect + " " + whereClause + " ORDER BY a.name COLLATE NOCASE ASC LIMIT ? OFFSET ?" args = append(args, pageSize, offset) rows, err := s.db.QueryContext(ctx, query, args...) if err != nil { return nil, 0, err } defer rows.Close() var out []models.Asset for rows.Next() { a, err := scanAsset(rows) if err != nil { return nil, 0, err } out = append(out, *a) } return out, total, rows.Err() } func (s *Store) descendantFolderIDs(ctx context.Context, rootID int64) ([]int64, error) { all, err := s.folders.All(ctx) if err != nil { return nil, err } childrenOf := map[int64][]int64{} for _, f := range all { if f.ParentID != nil { childrenOf[*f.ParentID] = append(childrenOf[*f.ParentID], f.ID) } } var out []int64 var walk func(int64) walk = func(id int64) { for _, c := range childrenOf[id] { out = append(out, c) walk(c) } } walk(rootID) return out, nil } // DashboardStats summarizes inventory counts for the small dashboard. type DashboardStats struct { Total int ByStatus []StatusCount ByType []TypeCount RecentlyAdded []models.Asset } type StatusCount struct { Name string Color string Count int } type TypeCount struct { Name string Icon string Count int } func (s *Store) Stats(ctx context.Context) (*DashboardStats, error) { stats := &DashboardStats{} if err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM assets`).Scan(&stats.Total); err != nil { return nil, err } rows, err := s.db.QueryContext(ctx, ` SELECT st.name, st.color, COUNT(a.id) FROM statuses st LEFT JOIN assets a ON a.status_id = st.id GROUP BY st.id ORDER BY st.sort_order ASC`) if err != nil { return nil, err } for rows.Next() { var sc StatusCount if err := rows.Scan(&sc.Name, &sc.Color, &sc.Count); err != nil { rows.Close() return nil, err } stats.ByStatus = append(stats.ByStatus, sc) } rows.Close() if err := rows.Err(); err != nil { return nil, err } rows, err = s.db.QueryContext(ctx, ` SELECT it.name, it.icon, COUNT(a.id) FROM item_types it LEFT JOIN assets a ON a.item_type_id = it.id GROUP BY it.id HAVING COUNT(a.id) > 0 ORDER BY COUNT(a.id) DESC`) if err != nil { return nil, err } for rows.Next() { var tc TypeCount if err := rows.Scan(&tc.Name, &tc.Icon, &tc.Count); err != nil { rows.Close() return nil, err } stats.ByType = append(stats.ByType, tc) } rows.Close() if err := rows.Err(); err != nil { return nil, err } recentRows, err := s.db.QueryContext(ctx, assetSelect+` ORDER BY a.created_at DESC LIMIT 8`) if err != nil { return nil, err } defer recentRows.Close() for recentRows.Next() { a, err := scanAsset(recentRows) if err != nil { return nil, err } stats.RecentlyAdded = append(stats.RecentlyAdded, *a) } return stats, recentRows.Err() }