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,342 @@
|
||||
// Package folders implements the nested folder tree that organizes assets.
|
||||
package folders
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"inventree/internal/models"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrNotFound = errors.New("folder not found")
|
||||
ErrInvalidParent = errors.New("cannot move a folder into itself or one of its own descendants")
|
||||
ErrNameRequired = errors.New("folder name is required")
|
||||
ErrNotEmpty = errors.New("folder still contains child folders or assets")
|
||||
)
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
func scanFolder(row interface{ Scan(...any) error }) (*models.Folder, error) {
|
||||
var f models.Folder
|
||||
var parentID sql.NullInt64
|
||||
var created, updated string
|
||||
if err := row.Scan(&f.ID, &f.Name, &parentID, &f.Description, &f.Icon, &f.SortOrder, &created, &updated); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if parentID.Valid {
|
||||
f.ParentID = &parentID.Int64
|
||||
}
|
||||
f.CreatedAt = parseTime(created)
|
||||
f.UpdatedAt = parseTime(updated)
|
||||
return &f, nil
|
||||
}
|
||||
|
||||
func (s *Store) Get(ctx context.Context, id int64) (*models.Folder, error) {
|
||||
row := s.db.QueryRowContext(ctx, `SELECT id, name, parent_id, description, icon, sort_order, created_at, updated_at FROM folders WHERE id = ?`, id)
|
||||
f, err := scanFolder(row)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return f, nil
|
||||
}
|
||||
|
||||
// Children returns the direct child folders of parentID (nil for top level),
|
||||
// ordered by sort_order then name, annotated with folder/asset counts.
|
||||
func (s *Store) Children(ctx context.Context, parentID *int64) ([]models.Folder, error) {
|
||||
query := `
|
||||
SELECT f.id, f.name, f.parent_id, f.description, f.icon, f.sort_order, f.created_at, f.updated_at,
|
||||
(SELECT COUNT(*) FROM folders c WHERE c.parent_id = f.id) AS folder_count,
|
||||
(SELECT COUNT(*) FROM assets a WHERE a.folder_id = f.id) AS asset_count
|
||||
FROM folders f
|
||||
WHERE f.parent_id IS ?
|
||||
ORDER BY f.sort_order ASC, f.name COLLATE NOCASE ASC`
|
||||
rows, err := s.db.QueryContext(ctx, query, parentID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []models.Folder
|
||||
for rows.Next() {
|
||||
var f models.Folder
|
||||
var pid sql.NullInt64
|
||||
var created, updated string
|
||||
if err := rows.Scan(&f.ID, &f.Name, &pid, &f.Description, &f.Icon, &f.SortOrder, &created, &updated, &f.FolderCount, &f.AssetCount); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if pid.Valid {
|
||||
f.ParentID = &pid.Int64
|
||||
}
|
||||
f.CreatedAt = parseTime(created)
|
||||
f.UpdatedAt = parseTime(updated)
|
||||
out = append(out, f)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// All returns every folder in the system (id, name, parent_id only) for
|
||||
// building the full in-memory tree or search indexes.
|
||||
func (s *Store) All(ctx context.Context) ([]models.Folder, error) {
|
||||
rows, err := s.db.QueryContext(ctx, `SELECT id, name, parent_id, description, icon, sort_order, created_at, updated_at FROM folders ORDER BY sort_order ASC, name COLLATE NOCASE ASC`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []models.Folder
|
||||
for rows.Next() {
|
||||
f, err := scanFolder(rowsScanner{rows})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, *f)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
type rowsScanner struct{ r *sql.Rows }
|
||||
|
||||
func (rs rowsScanner) Scan(dest ...any) error { return rs.r.Scan(dest...) }
|
||||
|
||||
func (s *Store) Create(ctx context.Context, f *models.Folder) error {
|
||||
if f.Name == "" {
|
||||
return ErrNameRequired
|
||||
}
|
||||
if f.ParentID != nil {
|
||||
if _, err := s.Get(ctx, *f.ParentID); err != nil {
|
||||
return fmt.Errorf("parent folder: %w", err)
|
||||
}
|
||||
}
|
||||
res, err := s.db.ExecContext(ctx,
|
||||
`INSERT INTO folders (name, parent_id, description, icon, sort_order) VALUES (?, ?, ?, ?, ?)`,
|
||||
f.Name, f.ParentID, f.Description, f.Icon, f.SortOrder)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
id, err := res.LastInsertId()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
f.ID = id
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) Update(ctx context.Context, f *models.Folder) error {
|
||||
if f.Name == "" {
|
||||
return ErrNameRequired
|
||||
}
|
||||
_, err := s.db.ExecContext(ctx,
|
||||
`UPDATE folders SET name = ?, description = ?, icon = ?, sort_order = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?`,
|
||||
f.Name, f.Description, f.Icon, f.SortOrder, f.ID)
|
||||
return err
|
||||
}
|
||||
|
||||
// IsDescendant reports whether candidateID is descendantOf ancestorID (or
|
||||
// equal to it), by walking parent pointers.
|
||||
func (s *Store) isSelfOrDescendant(ctx context.Context, folderID int64, targetParentID *int64) (bool, error) {
|
||||
if targetParentID == nil {
|
||||
return false, nil
|
||||
}
|
||||
cur := *targetParentID
|
||||
for {
|
||||
if cur == folderID {
|
||||
return true, nil
|
||||
}
|
||||
var parentID sql.NullInt64
|
||||
err := s.db.QueryRowContext(ctx, `SELECT parent_id FROM folders WHERE id = ?`, cur).Scan(&parentID)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if !parentID.Valid {
|
||||
return false, nil
|
||||
}
|
||||
cur = parentID.Int64
|
||||
}
|
||||
}
|
||||
|
||||
// Move relocates a folder to a new parent (nil = top level), rejecting moves
|
||||
// into itself or one of its own descendants.
|
||||
func (s *Store) Move(ctx context.Context, id int64, newParentID *int64) error {
|
||||
if newParentID != nil && *newParentID == id {
|
||||
return ErrInvalidParent
|
||||
}
|
||||
bad, err := s.isSelfOrDescendant(ctx, id, newParentID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if bad {
|
||||
return ErrInvalidParent
|
||||
}
|
||||
if newParentID != nil {
|
||||
if _, err := s.Get(ctx, *newParentID); err != nil {
|
||||
return fmt.Errorf("parent folder: %w", err)
|
||||
}
|
||||
}
|
||||
_, err = s.db.ExecContext(ctx,
|
||||
`UPDATE folders SET parent_id = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?`,
|
||||
newParentID, id)
|
||||
return err
|
||||
}
|
||||
|
||||
// Counts returns the number of direct child folders and direct assets in a
|
||||
// folder, used to decide whether deletion needs confirmation.
|
||||
func (s *Store) Counts(ctx context.Context, id int64) (folderCount, assetCount int, err error) {
|
||||
err = s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM folders WHERE parent_id = ?`, id).Scan(&folderCount)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
err = s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM assets WHERE folder_id = ?`, id).Scan(&assetCount)
|
||||
return
|
||||
}
|
||||
|
||||
// Delete removes an empty folder. It fails if the folder still has children
|
||||
// or assets (enforced both here and by the FK constraint).
|
||||
func (s *Store) Delete(ctx context.Context, id int64) error {
|
||||
fc, ac, err := s.Counts(ctx, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if fc > 0 || ac > 0 {
|
||||
return ErrNotEmpty
|
||||
}
|
||||
_, err = s.db.ExecContext(ctx, `DELETE FROM folders WHERE id = ?`, id)
|
||||
return err
|
||||
}
|
||||
|
||||
// DeleteAndRelocate moves all direct child folders and assets of id to
|
||||
// destParentID/destFolderID (nil = top level / unfiled) and then deletes the
|
||||
// now-empty folder id. Runs in a single transaction.
|
||||
func (s *Store) DeleteAndRelocate(ctx context.Context, id int64, dest *int64) error {
|
||||
if dest != nil {
|
||||
bad, err := s.isSelfOrDescendant(ctx, id, dest)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if bad || *dest == id {
|
||||
return ErrInvalidParent
|
||||
}
|
||||
}
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
if _, err := tx.ExecContext(ctx, `UPDATE folders SET parent_id = ? WHERE parent_id = ?`, dest, id); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `UPDATE assets SET folder_id = ? WHERE folder_id = ?`, dest, id); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `DELETE FROM folders WHERE id = ?`, id); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// CascadeDelete permanently deletes a folder along with every descendant
|
||||
// folder and asset inside it. Descendant folders are removed deepest-first
|
||||
// so the ON DELETE RESTRICT foreign key is always satisfied.
|
||||
func (s *Store) CascadeDelete(ctx context.Context, id int64) error {
|
||||
all, err := s.All(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
childrenOf := map[int64][]int64{}
|
||||
for _, f := range all {
|
||||
if f.ParentID != nil {
|
||||
childrenOf[*f.ParentID] = append(childrenOf[*f.ParentID], f.ID)
|
||||
}
|
||||
}
|
||||
// Depth-first order, id last, so children are deleted before parents.
|
||||
var order []int64
|
||||
var walk func(int64)
|
||||
walk = func(fid int64) {
|
||||
for _, c := range childrenOf[fid] {
|
||||
walk(c)
|
||||
}
|
||||
order = append(order, fid)
|
||||
}
|
||||
walk(id)
|
||||
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
for _, fid := range order {
|
||||
if _, err := tx.ExecContext(ctx, `DELETE FROM assets WHERE folder_id = ?`, fid); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `DELETE FROM folders WHERE id = ?`, fid); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// Path returns breadcrumb items from the root down to (and including) id.
|
||||
func (s *Store) Path(ctx context.Context, id int64) ([]models.BreadcrumbItem, error) {
|
||||
var path []models.BreadcrumbItem
|
||||
cur := &id
|
||||
for cur != nil {
|
||||
var name string
|
||||
var parentID sql.NullInt64
|
||||
err := s.db.QueryRowContext(ctx, `SELECT name, parent_id FROM folders WHERE id = ?`, *cur).Scan(&name, &parentID)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
path = append([]models.BreadcrumbItem{{ID: *cur, Name: name}}, path...)
|
||||
if parentID.Valid {
|
||||
cur = &parentID.Int64
|
||||
} else {
|
||||
cur = nil
|
||||
}
|
||||
}
|
||||
return path, nil
|
||||
}
|
||||
|
||||
// Search returns folders whose name matches the query (case-insensitive
|
||||
// substring match).
|
||||
func (s *Store) Search(ctx context.Context, query string) ([]models.Folder, error) {
|
||||
rows, err := s.db.QueryContext(ctx,
|
||||
`SELECT id, name, parent_id, description, icon, sort_order, created_at, updated_at
|
||||
FROM folders WHERE name LIKE '%' || ? || '%' COLLATE NOCASE ORDER BY name COLLATE NOCASE ASC LIMIT 50`, query)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []models.Folder
|
||||
for rows.Next() {
|
||||
f, err := scanFolder(rowsScanner{rows})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, *f)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
package folders_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"inventree/internal/database"
|
||||
"inventree/internal/folders"
|
||||
"inventree/internal/models"
|
||||
)
|
||||
|
||||
func testDB(t *testing.T) *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() })
|
||||
return db
|
||||
}
|
||||
|
||||
func TestFolderCreateAndChildren(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s := folders.NewStore(testDB(t))
|
||||
|
||||
root := &models.Folder{Name: "Main Office"}
|
||||
if err := s.Create(ctx, root); err != nil {
|
||||
t.Fatalf("create root: %v", err)
|
||||
}
|
||||
if root.ID == 0 {
|
||||
t.Fatal("expected assigned ID")
|
||||
}
|
||||
|
||||
child := &models.Folder{Name: "Network", ParentID: &root.ID}
|
||||
if err := s.Create(ctx, child); err != nil {
|
||||
t.Fatalf("create child: %v", err)
|
||||
}
|
||||
|
||||
children, err := s.Children(ctx, &root.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("children: %v", err)
|
||||
}
|
||||
if len(children) != 1 || children[0].Name != "Network" {
|
||||
t.Fatalf("unexpected children: %+v", children)
|
||||
}
|
||||
if children[0].FolderCount != 0 {
|
||||
t.Fatalf("expected 0 sub-folders, got %d", children[0].FolderCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFolderCreateRequiresName(t *testing.T) {
|
||||
s := folders.NewStore(testDB(t))
|
||||
err := s.Create(context.Background(), &models.Folder{})
|
||||
if err != folders.ErrNameRequired {
|
||||
t.Fatalf("expected ErrNameRequired, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFolderMovePreventsCycles(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s := folders.NewStore(testDB(t))
|
||||
|
||||
a := &models.Folder{Name: "A"}
|
||||
must(t, s.Create(ctx, a))
|
||||
b := &models.Folder{Name: "B", ParentID: &a.ID}
|
||||
must(t, s.Create(ctx, b))
|
||||
c := &models.Folder{Name: "C", ParentID: &b.ID}
|
||||
must(t, s.Create(ctx, c))
|
||||
|
||||
// Moving A under its own descendant C must fail.
|
||||
if err := s.Move(ctx, a.ID, &c.ID); err != folders.ErrInvalidParent {
|
||||
t.Fatalf("expected ErrInvalidParent, got %v", err)
|
||||
}
|
||||
// Moving A under itself must fail.
|
||||
if err := s.Move(ctx, a.ID, &a.ID); err != folders.ErrInvalidParent {
|
||||
t.Fatalf("expected ErrInvalidParent for self-move, got %v", err)
|
||||
}
|
||||
// A legitimate move should succeed.
|
||||
if err := s.Move(ctx, c.ID, &a.ID); err != nil {
|
||||
t.Fatalf("expected valid move to succeed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFolderDeleteRequiresEmpty(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s := folders.NewStore(testDB(t))
|
||||
|
||||
a := &models.Folder{Name: "A"}
|
||||
must(t, s.Create(ctx, a))
|
||||
b := &models.Folder{Name: "B", ParentID: &a.ID}
|
||||
must(t, s.Create(ctx, b))
|
||||
|
||||
if err := s.Delete(ctx, a.ID); err != folders.ErrNotEmpty {
|
||||
t.Fatalf("expected ErrNotEmpty, got %v", err)
|
||||
}
|
||||
if err := s.Delete(ctx, b.ID); err != nil {
|
||||
t.Fatalf("delete empty folder: %v", err)
|
||||
}
|
||||
if err := s.Delete(ctx, a.ID); err != nil {
|
||||
t.Fatalf("delete now-empty folder: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFolderDeleteAndRelocate(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s := folders.NewStore(testDB(t))
|
||||
|
||||
a := &models.Folder{Name: "A"}
|
||||
must(t, s.Create(ctx, a))
|
||||
b := &models.Folder{Name: "B", ParentID: &a.ID}
|
||||
must(t, s.Create(ctx, b))
|
||||
dest := &models.Folder{Name: "Dest"}
|
||||
must(t, s.Create(ctx, dest))
|
||||
|
||||
if err := s.DeleteAndRelocate(ctx, a.ID, &dest.ID); err != nil {
|
||||
t.Fatalf("delete and relocate: %v", err)
|
||||
}
|
||||
children, err := s.Children(ctx, &dest.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("children: %v", err)
|
||||
}
|
||||
if len(children) != 1 || children[0].ID != b.ID {
|
||||
t.Fatalf("expected B relocated under Dest, got %+v", children)
|
||||
}
|
||||
if _, err := s.Get(ctx, a.ID); err != folders.ErrNotFound {
|
||||
t.Fatalf("expected A to be deleted, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFolderCascadeDelete(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s := folders.NewStore(testDB(t))
|
||||
|
||||
a := &models.Folder{Name: "A"}
|
||||
must(t, s.Create(ctx, a))
|
||||
b := &models.Folder{Name: "B", ParentID: &a.ID}
|
||||
must(t, s.Create(ctx, b))
|
||||
c := &models.Folder{Name: "C", ParentID: &b.ID}
|
||||
must(t, s.Create(ctx, c))
|
||||
|
||||
if err := s.CascadeDelete(ctx, a.ID); err != nil {
|
||||
t.Fatalf("cascade delete: %v", err)
|
||||
}
|
||||
for _, id := range []int64{a.ID, b.ID, c.ID} {
|
||||
if _, err := s.Get(ctx, id); err != folders.ErrNotFound {
|
||||
t.Fatalf("expected folder %d deleted, got %v", id, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func must(t *testing.T, err error) {
|
||||
t.Helper()
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user