- Main panel is now blank at the top level ("Select a folder or asset
from the tree") instead of showing a dashboard - browsing happens in
the tree, the panel only shows something once you click into it.
Removed the now-dead DashboardStats/Stats() machinery it depended on.
- Add/Edit Asset no longer takes over the main panel; it opens in
#assetModal instead; asset_form.html was trimmed down to just the form
(no more breadcrumb/panel wrapper) since the modal chrome provides the
title and footer buttons now. The dynamic item-type-fields and submit
listeners moved from #main-panel's delegation to the modal element's.
- Statuses split out of the General settings page into its own
/settings/statuses page and sub-nav entry, alongside General, Item
Types, and Users.
469 lines
13 KiB
Go
469 lines
13 KiB
Go
// 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
|
|
}
|