Initial implementation of inventree
A small IT asset inventory system built around a nested folder tree, matching the project brief: Go + html/template + Bootstrap 5 + vanilla JS, SQLite storage (WAL, FKs, migrations), embedded templates/static assets, single compiled binary. - AJAX-driven inventory tree: lazy-loaded folder expansion with per-user persisted expand state, drag-and-drop move for folders/assets, safe delete-with-relocate-or-cascade confirmation, search with breadcrumbs. - Assets: CRUD, duplicate, move, auto-generated IT-000001-style IDs, CSV import (with optional folder auto-create) and export. - Admin-defined item types with custom fields (text/long_text/number/date/ boolean/dropdown/url/ip_address). - Configurable statuses, session-based auth (bcrypt, secure cookies), admin/user roles, user management. - CLI: serve, version, database migrate, database backup; /healthz; graceful shutdown; Dockerfile/docker-compose/systemd unit; Go test suite.
This commit is contained in:
@@ -0,0 +1,549 @@
|
||||
// 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()
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
package assets_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"database/sql"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"inventree/internal/assets"
|
||||
"inventree/internal/database"
|
||||
"inventree/internal/folders"
|
||||
"inventree/internal/models"
|
||||
"inventree/internal/settings"
|
||||
)
|
||||
|
||||
// Seed data from migrations/0002_seed.sql gives us stable IDs to rely on:
|
||||
// item types 1=Laptop 2=Desktop 3=Server 4=Switch ... statuses 1=In Use 2=Spare ...
|
||||
const (
|
||||
itemTypeLaptop = 1
|
||||
itemTypeSwitch = 4
|
||||
statusInUse = 1
|
||||
statusSpare = 2
|
||||
)
|
||||
|
||||
func testStores(t *testing.T) (*assets.Store, *folders.Store, *sql.DB) {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
db, err := database.Open(filepath.Join(dir, "test.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open db: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { db.Close() })
|
||||
fs := folders.NewStore(db)
|
||||
st := settings.NewStore(db)
|
||||
return assets.NewStore(db, st, fs), fs, db
|
||||
}
|
||||
|
||||
func TestAssetCreateAutoGeneratesID(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
as, _, _ := testStores(t)
|
||||
|
||||
a1 := &models.Asset{Name: "Core Switch", ItemTypeID: itemTypeSwitch, StatusID: statusInUse}
|
||||
if err := as.Create(ctx, a1, nil); err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
if a1.AssetTag != "IT-000001" {
|
||||
t.Fatalf("expected IT-000001, got %s", a1.AssetTag)
|
||||
}
|
||||
|
||||
a2 := &models.Asset{Name: "Firewall", ItemTypeID: itemTypeSwitch, StatusID: statusInUse}
|
||||
if err := as.Create(ctx, a2, nil); err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
if a2.AssetTag != "IT-000002" {
|
||||
t.Fatalf("expected IT-000002, got %s", a2.AssetTag)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAssetCreateWithManualIDAndCollision(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
as, _, _ := testStores(t)
|
||||
|
||||
a1 := &models.Asset{AssetTag: "CUSTOM-1", Name: "Core Switch", ItemTypeID: itemTypeSwitch, StatusID: statusInUse}
|
||||
if err := as.Create(ctx, a1, nil); err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
|
||||
dup := &models.Asset{AssetTag: "CUSTOM-1", Name: "Other", ItemTypeID: itemTypeSwitch, StatusID: statusInUse}
|
||||
if err := as.Create(ctx, dup, nil); err != assets.ErrTagTaken {
|
||||
t.Fatalf("expected ErrTagTaken, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAssetDuplicateGeneratesNewID(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
as, _, _ := testStores(t)
|
||||
|
||||
orig := &models.Asset{Name: "Laptop 1", ItemTypeID: itemTypeLaptop, StatusID: statusInUse, Notes: "hello"}
|
||||
if err := as.Create(ctx, orig, nil); err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
|
||||
dup, err := as.Duplicate(ctx, orig.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("duplicate: %v", err)
|
||||
}
|
||||
if dup.AssetTag == orig.AssetTag {
|
||||
t.Fatalf("expected different asset tag, got same: %s", dup.AssetTag)
|
||||
}
|
||||
if dup.Notes != "hello" {
|
||||
t.Fatalf("expected notes copied, got %q", dup.Notes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAssetListFilterByFolder(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
as, fs, _ := testStores(t)
|
||||
|
||||
f1 := &models.Folder{Name: "Office"}
|
||||
must(t, fs.Create(ctx, f1))
|
||||
f2 := &models.Folder{Name: "Warehouse"}
|
||||
must(t, fs.Create(ctx, f2))
|
||||
|
||||
a1 := &models.Asset{Name: "A1", ItemTypeID: itemTypeLaptop, StatusID: statusInUse, FolderID: &f1.ID}
|
||||
must(t, as.Create(ctx, a1, nil))
|
||||
a2 := &models.Asset{Name: "A2", ItemTypeID: itemTypeLaptop, StatusID: statusInUse, FolderID: &f2.ID}
|
||||
must(t, as.Create(ctx, a2, nil))
|
||||
|
||||
list, total, err := as.List(ctx, assets.ListFilter{FolderID: &f1.ID, FolderSet: true, Page: 1, PageSize: 10})
|
||||
if err != nil {
|
||||
t.Fatalf("list: %v", err)
|
||||
}
|
||||
if total != 1 || len(list) != 1 || list[0].Name != "A1" {
|
||||
t.Fatalf("unexpected list result: total=%d list=%+v", total, list)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAssetCSVExportImportRoundTrip(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
as, fs, db := testStores(t)
|
||||
|
||||
office := &models.Folder{Name: "Main Office"}
|
||||
must(t, fs.Create(ctx, office))
|
||||
orig := &models.Asset{Name: "Core Switch", ItemTypeID: itemTypeSwitch, StatusID: statusInUse, FolderID: &office.ID}
|
||||
must(t, as.Create(ctx, orig, nil))
|
||||
|
||||
var buf bytes.Buffer
|
||||
if err := as.Export(ctx, &buf, assets.ListFilter{}); err != nil {
|
||||
t.Fatalf("export: %v", err)
|
||||
}
|
||||
if !strings.Contains(buf.String(), "Core Switch") {
|
||||
t.Fatalf("expected exported CSV to contain asset name, got: %s", buf.String())
|
||||
}
|
||||
|
||||
// Import a new asset referencing a not-yet-existing nested folder path,
|
||||
// with CreateMissingFolders enabled.
|
||||
csvData := "Name,Item Type,Folder,Status\nBackup Router,Switch,Main Office/Network,In Use\n"
|
||||
|
||||
itemTypes := seedItemTypes(t, db)
|
||||
statuses := seedStatuses(t, db)
|
||||
|
||||
result, err := as.Import(ctx, strings.NewReader(csvData), assets.ImportOptions{CreateMissingFolders: true}, itemTypes, statuses, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("import: %v", err)
|
||||
}
|
||||
if result.Created != 1 {
|
||||
t.Fatalf("expected 1 created, got %+v", result)
|
||||
}
|
||||
|
||||
all, total, err := as.List(ctx, assets.ListFilter{Page: 1, PageSize: 10})
|
||||
if err != nil {
|
||||
t.Fatalf("list: %v", err)
|
||||
}
|
||||
if total != 2 {
|
||||
t.Fatalf("expected 2 assets total after import, got %d (%+v)", total, all)
|
||||
}
|
||||
}
|
||||
|
||||
func seedItemTypes(t *testing.T, db *sql.DB) []models.ItemType {
|
||||
t.Helper()
|
||||
rows, err := db.Query(`SELECT id, name, icon, enabled, sort_order FROM item_types`)
|
||||
if err != nil {
|
||||
t.Fatalf("query item types: %v", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []models.ItemType
|
||||
for rows.Next() {
|
||||
var it models.ItemType
|
||||
if err := rows.Scan(&it.ID, &it.Name, &it.Icon, &it.Enabled, &it.SortOrder); err != nil {
|
||||
t.Fatalf("scan: %v", err)
|
||||
}
|
||||
out = append(out, it)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func seedStatuses(t *testing.T, db *sql.DB) []models.Status {
|
||||
t.Helper()
|
||||
rows, err := db.Query(`SELECT id, name, color, sort_order FROM statuses`)
|
||||
if err != nil {
|
||||
t.Fatalf("query statuses: %v", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []models.Status
|
||||
for rows.Next() {
|
||||
var st models.Status
|
||||
if err := rows.Scan(&st.ID, &st.Name, &st.Color, &st.SortOrder); err != nil {
|
||||
t.Fatalf("scan: %v", err)
|
||||
}
|
||||
out = append(out, st)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func must(t *testing.T, err error) {
|
||||
t.Helper()
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,349 @@
|
||||
package assets
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/csv"
|
||||
"fmt"
|
||||
"io"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"inventree/internal/models"
|
||||
)
|
||||
|
||||
const (
|
||||
colAssetID = "asset id"
|
||||
colName = "name"
|
||||
colItemType = "item type"
|
||||
colFolder = "folder"
|
||||
colStatus = "status"
|
||||
colAssignedUser = "assigned user"
|
||||
colNotes = "notes"
|
||||
cfColPrefix = "cf_"
|
||||
)
|
||||
|
||||
// ImportOptions controls CSV import behavior.
|
||||
type ImportOptions struct {
|
||||
CreateMissingFolders bool
|
||||
}
|
||||
|
||||
type ImportResult struct {
|
||||
Created int
|
||||
Updated int
|
||||
Errors []string
|
||||
}
|
||||
|
||||
// lookups bundles reference data needed to resolve CSV rows to foreign keys.
|
||||
type lookups struct {
|
||||
itemTypesByName map[string]*models.ItemType
|
||||
statusesByName map[string]int64
|
||||
usersByName map[string]int64
|
||||
folderPathToID map[string]int64
|
||||
}
|
||||
|
||||
func normalize(s string) string { return strings.ToLower(strings.TrimSpace(s)) }
|
||||
|
||||
// Import reads assets from CSV data, creating or updating rows. Matching is
|
||||
// done by Asset ID: if a row's Asset ID matches an existing asset, it is
|
||||
// updated in place; otherwise a new asset is created (auto-generating an ID
|
||||
// if the column is blank).
|
||||
func (s *Store) Import(ctx context.Context, r io.Reader, opts ImportOptions,
|
||||
itemTypesList []models.ItemType, statusesList []models.Status, usersList []models.User) (*ImportResult, error) {
|
||||
|
||||
lk := &lookups{
|
||||
itemTypesByName: map[string]*models.ItemType{},
|
||||
statusesByName: map[string]int64{},
|
||||
usersByName: map[string]int64{},
|
||||
folderPathToID: map[string]int64{},
|
||||
}
|
||||
for i := range itemTypesList {
|
||||
lk.itemTypesByName[normalize(itemTypesList[i].Name)] = &itemTypesList[i]
|
||||
}
|
||||
for _, st := range statusesList {
|
||||
lk.statusesByName[normalize(st.Name)] = st.ID
|
||||
}
|
||||
for _, u := range usersList {
|
||||
lk.usersByName[normalize(u.Username)] = u.ID
|
||||
}
|
||||
allFolders, err := s.folders.All(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
byID := map[int64]models.Folder{}
|
||||
for _, f := range allFolders {
|
||||
byID[f.ID] = f
|
||||
}
|
||||
var pathOf func(id int64) string
|
||||
pathOf = func(id int64) string {
|
||||
f, ok := byID[id]
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
if f.ParentID == nil {
|
||||
return f.Name
|
||||
}
|
||||
return pathOf(*f.ParentID) + "/" + f.Name
|
||||
}
|
||||
for _, f := range allFolders {
|
||||
lk.folderPathToID[normalize(pathOf(f.ID))] = f.ID
|
||||
}
|
||||
|
||||
cr := csv.NewReader(r)
|
||||
cr.FieldsPerRecord = -1
|
||||
header, err := cr.Read()
|
||||
if err != nil {
|
||||
if err == io.EOF {
|
||||
return &ImportResult{}, nil
|
||||
}
|
||||
return nil, fmt.Errorf("read CSV header: %w", err)
|
||||
}
|
||||
|
||||
colIdx := map[string]int{}
|
||||
var cfCols []struct {
|
||||
key string
|
||||
idx int
|
||||
}
|
||||
for i, h := range header {
|
||||
n := normalize(h)
|
||||
if strings.HasPrefix(n, cfColPrefix) {
|
||||
cfCols = append(cfCols, struct {
|
||||
key string
|
||||
idx int
|
||||
}{key: strings.TrimPrefix(n, cfColPrefix), idx: i})
|
||||
continue
|
||||
}
|
||||
colIdx[n] = i
|
||||
}
|
||||
|
||||
get := func(row []string, key string) string {
|
||||
idx, ok := colIdx[key]
|
||||
if !ok || idx >= len(row) {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(row[idx])
|
||||
}
|
||||
|
||||
result := &ImportResult{}
|
||||
rowNum := 1
|
||||
for {
|
||||
row, err := cr.Read()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read CSV row %d: %w", rowNum, err)
|
||||
}
|
||||
rowNum++
|
||||
|
||||
name := get(row, colName)
|
||||
if name == "" {
|
||||
result.Errors = append(result.Errors, fmt.Sprintf("row %d: name is required, skipped", rowNum))
|
||||
continue
|
||||
}
|
||||
itemTypeName := get(row, colItemType)
|
||||
it, ok := lk.itemTypesByName[normalize(itemTypeName)]
|
||||
if !ok {
|
||||
result.Errors = append(result.Errors, fmt.Sprintf("row %d: unknown item type %q, skipped", rowNum, itemTypeName))
|
||||
continue
|
||||
}
|
||||
statusName := get(row, colStatus)
|
||||
statusID, ok := lk.statusesByName[normalize(statusName)]
|
||||
if !ok {
|
||||
if len(statusesList) == 0 {
|
||||
result.Errors = append(result.Errors, fmt.Sprintf("row %d: no statuses configured, skipped", rowNum))
|
||||
continue
|
||||
}
|
||||
statusID = statusesList[0].ID
|
||||
}
|
||||
|
||||
var folderID *int64
|
||||
folderPath := get(row, colFolder)
|
||||
if folderPath != "" {
|
||||
id, err := s.resolveFolderPath(ctx, lk, folderPath, opts.CreateMissingFolders)
|
||||
if err != nil {
|
||||
result.Errors = append(result.Errors, fmt.Sprintf("row %d: folder %q: %v", rowNum, folderPath, err))
|
||||
continue
|
||||
}
|
||||
folderID = &id
|
||||
}
|
||||
|
||||
var assignedUserID *int64
|
||||
if uname := get(row, colAssignedUser); uname != "" {
|
||||
if id, ok := lk.usersByName[normalize(uname)]; ok {
|
||||
assignedUserID = &id
|
||||
} else {
|
||||
result.Errors = append(result.Errors, fmt.Sprintf("row %d: unknown assigned user %q, leaving unassigned", rowNum, uname))
|
||||
}
|
||||
}
|
||||
|
||||
fieldValues := map[int64]string{}
|
||||
for _, cf := range cfCols {
|
||||
if cf.idx >= len(row) {
|
||||
continue
|
||||
}
|
||||
val := strings.TrimSpace(row[cf.idx])
|
||||
if val == "" {
|
||||
continue
|
||||
}
|
||||
for _, def := range it.Fields {
|
||||
if normalize(def.FieldKey) == cf.key {
|
||||
fieldValues[def.ID] = val
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
asset := &models.Asset{
|
||||
AssetTag: get(row, colAssetID),
|
||||
Name: name,
|
||||
ItemTypeID: it.ID,
|
||||
FolderID: folderID,
|
||||
StatusID: statusID,
|
||||
AssignedUserID: assignedUserID,
|
||||
Notes: get(row, colNotes),
|
||||
}
|
||||
|
||||
if asset.AssetTag != "" {
|
||||
existing, err := s.GetByTag(ctx, asset.AssetTag)
|
||||
if err == nil {
|
||||
asset.ID = existing.ID
|
||||
if err := s.Update(ctx, asset, fieldValues); err != nil {
|
||||
result.Errors = append(result.Errors, fmt.Sprintf("row %d: update failed: %v", rowNum, err))
|
||||
continue
|
||||
}
|
||||
result.Updated++
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
if err := s.Create(ctx, asset, fieldValues); err != nil {
|
||||
result.Errors = append(result.Errors, fmt.Sprintf("row %d: create failed: %v", rowNum, err))
|
||||
continue
|
||||
}
|
||||
result.Created++
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *Store) resolveFolderPath(ctx context.Context, lk *lookups, path string, createMissing bool) (int64, error) {
|
||||
norm := normalize(path)
|
||||
if id, ok := lk.folderPathToID[norm]; ok {
|
||||
return id, nil
|
||||
}
|
||||
if !createMissing {
|
||||
return 0, fmt.Errorf("folder not found")
|
||||
}
|
||||
|
||||
segments := strings.Split(path, "/")
|
||||
var parentID *int64
|
||||
built := ""
|
||||
for _, seg := range segments {
|
||||
seg = strings.TrimSpace(seg)
|
||||
if seg == "" {
|
||||
continue
|
||||
}
|
||||
if built == "" {
|
||||
built = seg
|
||||
} else {
|
||||
built = built + "/" + seg
|
||||
}
|
||||
if id, ok := lk.folderPathToID[normalize(built)]; ok {
|
||||
parentID = &id
|
||||
continue
|
||||
}
|
||||
f := &models.Folder{Name: seg, ParentID: parentID}
|
||||
if err := s.folders.Create(ctx, f); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
lk.folderPathToID[normalize(built)] = f.ID
|
||||
parentID = &f.ID
|
||||
}
|
||||
if parentID == nil {
|
||||
return 0, fmt.Errorf("empty folder path")
|
||||
}
|
||||
return *parentID, nil
|
||||
}
|
||||
|
||||
// Export writes all assets (optionally filtered) to CSV, with one column per
|
||||
// distinct custom field key used across all item types.
|
||||
func (s *Store) Export(ctx context.Context, w io.Writer, f ListFilter) error {
|
||||
fieldDefs, err := s.allFieldKeys(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cw := csv.NewWriter(w)
|
||||
header := []string{"Asset ID", "Name", "Item Type", "Folder", "Status", "Assigned User", "Notes", "Created At", "Updated At"}
|
||||
for _, k := range fieldDefs {
|
||||
header = append(header, cfColPrefix+k)
|
||||
}
|
||||
if err := cw.Write(header); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
f.Page = 1
|
||||
f.PageSize = 1000
|
||||
for {
|
||||
batch, total, err := s.List(ctx, f)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for i := range batch {
|
||||
a, err := s.Get(ctx, batch[i].ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
row := []string{
|
||||
a.AssetTag, a.Name, a.ItemTypeName, folderPathString(a.FolderPath), a.StatusName, a.AssignedUsername, a.Notes,
|
||||
a.CreatedAt.Format("2006-01-02 15:04:05"), a.UpdatedAt.Format("2006-01-02 15:04:05"),
|
||||
}
|
||||
values := map[string]string{}
|
||||
for _, fv := range a.CustomFields {
|
||||
values[fv.Def.FieldKey] = fv.Value
|
||||
}
|
||||
for _, k := range fieldDefs {
|
||||
row = append(row, values[k])
|
||||
}
|
||||
if err := cw.Write(row); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if f.Page*f.PageSize >= total || len(batch) == 0 {
|
||||
break
|
||||
}
|
||||
f.Page++
|
||||
}
|
||||
cw.Flush()
|
||||
return cw.Error()
|
||||
}
|
||||
|
||||
func folderPathString(path []models.BreadcrumbItem) string {
|
||||
names := make([]string, len(path))
|
||||
for i, p := range path {
|
||||
names[i] = p.Name
|
||||
}
|
||||
return strings.Join(names, "/")
|
||||
}
|
||||
|
||||
func (s *Store) allFieldKeys(ctx context.Context) ([]string, error) {
|
||||
rows, err := s.db.QueryContext(ctx, `SELECT DISTINCT field_key FROM custom_field_defs`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
seen := map[string]bool{}
|
||||
var out []string
|
||||
for rows.Next() {
|
||||
var k string
|
||||
if err := rows.Scan(&k); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !seen[k] {
|
||||
seen[k] = true
|
||||
out = append(out, k)
|
||||
}
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out, rows.Err()
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package assets
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"inventree/internal/models"
|
||||
)
|
||||
|
||||
// Search performs a global, case-insensitive substring search across asset
|
||||
// ID, name, notes, folder name, item type name, assigned username, and
|
||||
// custom field values, returning fully-loaded assets (with folder path and
|
||||
// custom fields) ready for display with breadcrumbs.
|
||||
func (s *Store) Search(ctx context.Context, query string, limit int) ([]models.Asset, error) {
|
||||
if query == "" {
|
||||
return nil, nil
|
||||
}
|
||||
if limit <= 0 || limit > 200 {
|
||||
limit = 50
|
||||
}
|
||||
like := "%" + query + "%"
|
||||
|
||||
rows, err := s.db.QueryContext(ctx, `
|
||||
SELECT DISTINCT a.id
|
||||
FROM assets a
|
||||
LEFT JOIN folders fo ON fo.id = a.folder_id
|
||||
LEFT JOIN users au ON au.id = a.assigned_user_id
|
||||
JOIN item_types it ON it.id = a.item_type_id
|
||||
WHERE a.asset_id LIKE ? COLLATE NOCASE
|
||||
OR a.name LIKE ? COLLATE NOCASE
|
||||
OR a.notes LIKE ? COLLATE NOCASE
|
||||
OR fo.name LIKE ? COLLATE NOCASE
|
||||
OR it.name LIKE ? COLLATE NOCASE
|
||||
OR au.username LIKE ? COLLATE NOCASE
|
||||
OR EXISTS (SELECT 1 FROM custom_field_values v WHERE v.asset_id = a.id AND v.value LIKE ? COLLATE NOCASE)
|
||||
ORDER BY a.name COLLATE NOCASE ASC
|
||||
LIMIT ?`,
|
||||
like, like, like, like, like, like, like, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var ids []int64
|
||||
for rows.Next() {
|
||||
var id int64
|
||||
if err := rows.Scan(&id); err != nil {
|
||||
rows.Close()
|
||||
return nil, err
|
||||
}
|
||||
ids = append(ids, id)
|
||||
}
|
||||
rows.Close()
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var out []models.Asset
|
||||
for _, id := range ids {
|
||||
a, err := s.Get(ctx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, *a)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
Reference in New Issue
Block a user