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:
Owen
2026-08-17 13:34:40 -05:00
commit 218687a1ee
71 changed files with 8102 additions and 0 deletions
+549
View File
@@ -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()
}
+202
View File
@@ -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)
}
}
+349
View File
@@ -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()
}
+64
View File
@@ -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
}
+133
View File
@@ -0,0 +1,133 @@
// Package auth implements session-based authentication with secure cookies.
package auth
import (
"context"
"crypto/rand"
"database/sql"
"encoding/hex"
"errors"
"net/http"
"time"
"inventree/internal/models"
"inventree/internal/users"
)
const (
CookieName = "inventree_session"
SessionTTL = 7 * 24 * time.Hour
contextUserK = ctxKey("current_user")
)
type ctxKey string
var ErrNoSession = errors.New("no valid session")
type Store struct {
db *sql.DB
users *users.Store
}
func NewStore(db *sql.DB, u *users.Store) *Store { return &Store{db: db, users: u} }
func newToken() (string, error) {
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
return "", err
}
return hex.EncodeToString(b), nil
}
// CreateSession issues a new session for the given user and returns the
// opaque session token to be stored in a cookie.
func (s *Store) CreateSession(ctx context.Context, userID int64) (string, time.Time, error) {
token, err := newToken()
if err != nil {
return "", time.Time{}, err
}
expires := time.Now().Add(SessionTTL)
_, err = s.db.ExecContext(ctx, `INSERT INTO sessions (id, user_id, expires_at) VALUES (?, ?, ?)`,
token, userID, expires.UTC().Format("2006-01-02T15:04:05.000Z"))
if err != nil {
return "", time.Time{}, err
}
return token, expires, nil
}
func (s *Store) DestroySession(ctx context.Context, token string) error {
_, err := s.db.ExecContext(ctx, `DELETE FROM sessions WHERE id = ?`, token)
return err
}
// UserForToken resolves a session token to the current, enabled user. Expired
// sessions are treated as invalid (and lazily cleaned up).
func (s *Store) UserForToken(ctx context.Context, token string) (*models.User, error) {
if token == "" {
return nil, ErrNoSession
}
var userID int64
var expiresAt string
err := s.db.QueryRowContext(ctx, `SELECT user_id, expires_at FROM sessions WHERE id = ?`, token).Scan(&userID, &expiresAt)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNoSession
}
if err != nil {
return nil, err
}
exp, _ := time.Parse("2006-01-02T15:04:05.000Z", expiresAt)
if time.Now().After(exp) {
_, _ = s.db.ExecContext(ctx, `DELETE FROM sessions WHERE id = ?`, token)
return nil, ErrNoSession
}
u, err := s.users.Get(ctx, userID)
if err != nil {
return nil, ErrNoSession
}
if !u.Enabled {
return nil, ErrNoSession
}
return u, nil
}
// CleanExpired removes expired sessions; safe to call periodically.
func (s *Store) CleanExpired(ctx context.Context) error {
_, err := s.db.ExecContext(ctx, `DELETE FROM sessions WHERE expires_at < ?`, time.Now().UTC().Format("2006-01-02T15:04:05.000Z"))
return err
}
func SetCookie(w http.ResponseWriter, token string, expires time.Time, secure bool) {
http.SetCookie(w, &http.Cookie{
Name: CookieName,
Value: token,
Path: "/",
Expires: expires,
HttpOnly: true,
Secure: secure,
SameSite: http.SameSiteLaxMode,
})
}
func ClearCookie(w http.ResponseWriter, secure bool) {
http.SetCookie(w, &http.Cookie{
Name: CookieName,
Value: "",
Path: "/",
Expires: time.Unix(0, 0),
MaxAge: -1,
HttpOnly: true,
Secure: secure,
SameSite: http.SameSiteLaxMode,
})
}
// WithUser stores the current user in the request context.
func WithUser(ctx context.Context, u *models.User) context.Context {
return context.WithValue(ctx, contextUserK, u)
}
// UserFromContext returns the current authenticated user, if any.
func UserFromContext(ctx context.Context) (*models.User, bool) {
u, ok := ctx.Value(contextUserK).(*models.User)
return u, ok && u != nil
}
+63
View File
@@ -0,0 +1,63 @@
package auth_test
import (
"context"
"database/sql"
"path/filepath"
"testing"
"inventree/internal/auth"
"inventree/internal/database"
"inventree/internal/users"
)
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 TestSessionLifecycle(t *testing.T) {
ctx := context.Background()
db := testDB(t)
userStore := users.NewStore(db)
authStore := auth.NewStore(db, userStore)
admin, err := userStore.GetByUsername(ctx, "admin")
if err != nil {
t.Fatalf("get admin: %v", err)
}
token, _, err := authStore.CreateSession(ctx, admin.ID)
if err != nil {
t.Fatalf("create session: %v", err)
}
u, err := authStore.UserForToken(ctx, token)
if err != nil {
t.Fatalf("resolve session: %v", err)
}
if u.Username != "admin" {
t.Fatalf("expected admin, got %s", u.Username)
}
if err := authStore.DestroySession(ctx, token); err != nil {
t.Fatalf("destroy session: %v", err)
}
if _, err := authStore.UserForToken(ctx, token); err != auth.ErrNoSession {
t.Fatalf("expected ErrNoSession after destroy, got %v", err)
}
}
func TestUserForTokenRejectsUnknownToken(t *testing.T) {
db := testDB(t)
authStore := auth.NewStore(db, users.NewStore(db))
if _, err := authStore.UserForToken(context.Background(), "does-not-exist"); err != auth.ErrNoSession {
t.Fatalf("expected ErrNoSession, got %v", err)
}
}
+124
View File
@@ -0,0 +1,124 @@
// Package database manages the SQLite connection and schema migrations.
package database
import (
"database/sql"
"embed"
"fmt"
"io/fs"
"log/slog"
"path/filepath"
"sort"
"strings"
_ "modernc.org/sqlite"
)
//go:embed migrations/*.sql
var migrationsFS embed.FS
// Open opens the SQLite database at path, applies pragmas suited for a
// concurrent web application, and runs any pending migrations.
func Open(path string) (*sql.DB, error) {
dsn := fmt.Sprintf("file:%s?_pragma=busy_timeout(5000)", path)
db, err := sql.Open("sqlite", dsn)
if err != nil {
return nil, fmt.Errorf("open database: %w", err)
}
// SQLite only supports one writer at a time; a single connection avoids
// SQLITE_BUSY under concurrent goroutines while WAL still allows
// concurrent readers.
db.SetMaxOpenConns(1)
pragmas := []string{
"PRAGMA journal_mode = WAL",
"PRAGMA foreign_keys = ON",
"PRAGMA synchronous = NORMAL",
"PRAGMA busy_timeout = 5000",
}
for _, p := range pragmas {
if _, err := db.Exec(p); err != nil {
db.Close()
return nil, fmt.Errorf("set pragma %q: %w", p, err)
}
}
if err := Migrate(db); err != nil {
db.Close()
return nil, fmt.Errorf("migrate: %w", err)
}
return db, nil
}
// Migrate applies any migrations that have not yet been recorded in the
// schema_migrations table, in filename order, each inside its own
// transaction.
func Migrate(db *sql.DB) error {
if _, err := db.Exec(`CREATE TABLE IF NOT EXISTS schema_migrations (
version TEXT PRIMARY KEY,
applied_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
)`); err != nil {
return fmt.Errorf("create schema_migrations: %w", err)
}
applied := map[string]bool{}
rows, err := db.Query(`SELECT version FROM schema_migrations`)
if err != nil {
return fmt.Errorf("query schema_migrations: %w", err)
}
for rows.Next() {
var v string
if err := rows.Scan(&v); err != nil {
rows.Close()
return err
}
applied[v] = true
}
if err := rows.Err(); err != nil {
return err
}
rows.Close()
entries, err := fs.ReadDir(migrationsFS, "migrations")
if err != nil {
return fmt.Errorf("read migrations dir: %w", err)
}
var names []string
for _, e := range entries {
if !e.IsDir() && strings.HasSuffix(e.Name(), ".sql") {
names = append(names, e.Name())
}
}
sort.Strings(names)
for _, name := range names {
if applied[name] {
continue
}
content, err := fs.ReadFile(migrationsFS, filepath.Join("migrations", name))
if err != nil {
return fmt.Errorf("read migration %s: %w", name, err)
}
tx, err := db.Begin()
if err != nil {
return fmt.Errorf("begin tx for %s: %w", name, err)
}
if _, err := tx.Exec(string(content)); err != nil {
tx.Rollback()
return fmt.Errorf("apply migration %s: %w", name, err)
}
if _, err := tx.Exec(`INSERT INTO schema_migrations (version) VALUES (?)`, name); err != nil {
tx.Rollback()
return fmt.Errorf("record migration %s: %w", name, err)
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("commit migration %s: %w", name, err)
}
slog.Info("applied migration", "name", name)
}
return nil
}
+76
View File
@@ -0,0 +1,76 @@
package database_test
import (
"path/filepath"
"testing"
"inventree/internal/database"
)
func TestOpenSetsPragmasAndMigrates(t *testing.T) {
dir := t.TempDir()
db, err := database.Open(filepath.Join(dir, "test.db"))
if err != nil {
t.Fatalf("open: %v", err)
}
defer db.Close()
var journalMode string
if err := db.QueryRow(`PRAGMA journal_mode`).Scan(&journalMode); err != nil {
t.Fatalf("query journal_mode: %v", err)
}
if journalMode != "wal" {
t.Fatalf("expected WAL journal mode, got %q", journalMode)
}
var fk int
if err := db.QueryRow(`PRAGMA foreign_keys`).Scan(&fk); err != nil {
t.Fatalf("query foreign_keys: %v", err)
}
if fk != 1 {
t.Fatalf("expected foreign_keys=1, got %d", fk)
}
var userCount int
if err := db.QueryRow(`SELECT COUNT(*) FROM users`).Scan(&userCount); err != nil {
t.Fatalf("query users: %v", err)
}
if userCount != 1 {
t.Fatalf("expected 1 seeded admin user, got %d", userCount)
}
var statusCount int
if err := db.QueryRow(`SELECT COUNT(*) FROM statuses`).Scan(&statusCount); err != nil {
t.Fatalf("query statuses: %v", err)
}
if statusCount != 5 {
t.Fatalf("expected 5 seeded statuses, got %d", statusCount)
}
}
func TestMigrateIsIdempotent(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "test.db")
db1, err := database.Open(path)
if err != nil {
t.Fatalf("first open: %v", err)
}
db1.Close()
// Re-opening (and re-migrating) an already-migrated database must not
// error or duplicate seed data.
db2, err := database.Open(path)
if err != nil {
t.Fatalf("second open: %v", err)
}
defer db2.Close()
var userCount int
if err := db2.QueryRow(`SELECT COUNT(*) FROM users`).Scan(&userCount); err != nil {
t.Fatalf("query users: %v", err)
}
if userCount != 1 {
t.Fatalf("expected still 1 seeded admin user after reopen, got %d", userCount)
}
}
+108
View File
@@ -0,0 +1,108 @@
-- Initial schema for inventree
CREATE TABLE users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
role TEXT NOT NULL CHECK (role IN ('admin', 'user')),
enabled INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
);
CREATE TABLE sessions (
id TEXT PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
expires_at TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
);
CREATE INDEX idx_sessions_user_id ON sessions(user_id);
CREATE INDEX idx_sessions_expires_at ON sessions(expires_at);
CREATE TABLE folders (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
parent_id INTEGER REFERENCES folders(id) ON DELETE RESTRICT,
description TEXT NOT NULL DEFAULT '',
icon TEXT NOT NULL DEFAULT '',
sort_order INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
);
CREATE INDEX idx_folders_parent_id ON folders(parent_id);
CREATE TABLE statuses (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
color TEXT NOT NULL DEFAULT 'secondary',
sort_order INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
);
CREATE TABLE item_types (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
description TEXT NOT NULL DEFAULT '',
icon TEXT NOT NULL DEFAULT 'bi-box-seam',
enabled INTEGER NOT NULL DEFAULT 1,
sort_order INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
);
CREATE TABLE custom_field_defs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
item_type_id INTEGER NOT NULL REFERENCES item_types(id) ON DELETE CASCADE,
name TEXT NOT NULL,
field_key TEXT NOT NULL,
field_type TEXT NOT NULL CHECK (field_type IN ('text','long_text','number','date','boolean','dropdown','url','ip_address')),
required INTEGER NOT NULL DEFAULT 0,
default_value TEXT NOT NULL DEFAULT '',
options TEXT NOT NULL DEFAULT '',
sort_order INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
UNIQUE(item_type_id, field_key)
);
CREATE INDEX idx_custom_field_defs_item_type_id ON custom_field_defs(item_type_id);
CREATE TABLE assets (
id INTEGER PRIMARY KEY AUTOINCREMENT,
asset_id TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
item_type_id INTEGER NOT NULL REFERENCES item_types(id) ON DELETE RESTRICT,
folder_id INTEGER REFERENCES folders(id) ON DELETE RESTRICT,
status_id INTEGER NOT NULL REFERENCES statuses(id) ON DELETE RESTRICT,
assigned_user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
notes TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
);
CREATE INDEX idx_assets_folder_id ON assets(folder_id);
CREATE INDEX idx_assets_item_type_id ON assets(item_type_id);
CREATE INDEX idx_assets_status_id ON assets(status_id);
CREATE INDEX idx_assets_assigned_user_id ON assets(assigned_user_id);
CREATE INDEX idx_assets_name ON assets(name);
CREATE TABLE custom_field_values (
id INTEGER PRIMARY KEY AUTOINCREMENT,
asset_id INTEGER NOT NULL REFERENCES assets(id) ON DELETE CASCADE,
field_def_id INTEGER NOT NULL REFERENCES custom_field_defs(id) ON DELETE CASCADE,
value TEXT NOT NULL DEFAULT '',
UNIQUE(asset_id, field_def_id)
);
CREATE INDEX idx_custom_field_values_asset_id ON custom_field_values(asset_id);
CREATE INDEX idx_custom_field_values_field_def_id ON custom_field_values(field_def_id);
CREATE TABLE settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL DEFAULT '',
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
);
CREATE TABLE user_tree_state (
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
folder_id INTEGER NOT NULL REFERENCES folders(id) ON DELETE CASCADE,
PRIMARY KEY (user_id, folder_id)
);
+172
View File
@@ -0,0 +1,172 @@
-- Seed data: default admin user, statuses, item types with custom fields, settings
INSERT INTO users (username, password_hash, role, enabled) VALUES
('admin', '$2a$10$AdcPKrFFnDcKh4QVagYcNOKlyyGYYLarBPSFzbKHvVyNaotVjLl0C', 'admin', 1);
INSERT INTO statuses (name, color, sort_order) VALUES
('In Use', 'success', 1),
('Spare', 'info', 2),
('Repair', 'warning', 3),
('Retired', 'secondary', 4),
('Lost', 'danger', 5);
INSERT INTO settings (key, value) VALUES
('asset_id_prefix', 'IT-'),
('asset_id_padding', '6'),
('asset_id_seq', '0'),
('site_name', 'inventree');
INSERT INTO item_types (name, description, icon, enabled, sort_order) VALUES
('Laptop', 'Portable computer', 'bi-laptop', 1, 1),
('Desktop', 'Desktop workstation', 'bi-pc-display', 1, 2),
('Server', 'Physical or virtual server', 'bi-hdd-rack', 1, 3),
('Switch', 'Network switch', 'bi-diagram-3', 1, 4),
('Router', 'Network router', 'bi-router', 1, 5),
('Firewall', 'Network firewall appliance', 'bi-shield-lock', 1, 6),
('Access Point', 'Wireless access point', 'bi-wifi', 1, 7),
('Printer', 'Printer or MFP', 'bi-printer', 1, 8),
('Monitor', 'Display monitor', 'bi-display', 1, 9),
('Phone', 'Desk or mobile phone', 'bi-telephone', 1, 10),
('UPS', 'Uninterruptible power supply', 'bi-battery-charging', 1, 11);
-- Laptop
INSERT INTO custom_field_defs (item_type_id, name, field_key, field_type, required, default_value, options, sort_order)
SELECT id, 'Manufacturer', 'manufacturer', 'text', 0, '', '', 1 FROM item_types WHERE name = 'Laptop';
INSERT INTO custom_field_defs (item_type_id, name, field_key, field_type, required, default_value, options, sort_order)
SELECT id, 'Model', 'model', 'text', 0, '', '', 2 FROM item_types WHERE name = 'Laptop';
INSERT INTO custom_field_defs (item_type_id, name, field_key, field_type, required, default_value, options, sort_order)
SELECT id, 'Serial Number', 'serial_number', 'text', 0, '', '', 3 FROM item_types WHERE name = 'Laptop';
INSERT INTO custom_field_defs (item_type_id, name, field_key, field_type, required, default_value, options, sort_order)
SELECT id, 'CPU', 'cpu', 'text', 0, '', '', 4 FROM item_types WHERE name = 'Laptop';
INSERT INTO custom_field_defs (item_type_id, name, field_key, field_type, required, default_value, options, sort_order)
SELECT id, 'RAM', 'ram', 'text', 0, '', '', 5 FROM item_types WHERE name = 'Laptop';
INSERT INTO custom_field_defs (item_type_id, name, field_key, field_type, required, default_value, options, sort_order)
SELECT id, 'Storage', 'storage', 'text', 0, '', '', 6 FROM item_types WHERE name = 'Laptop';
INSERT INTO custom_field_defs (item_type_id, name, field_key, field_type, required, default_value, options, sort_order)
SELECT id, 'Operating System', 'operating_system', 'text', 0, '', '', 7 FROM item_types WHERE name = 'Laptop';
-- Desktop
INSERT INTO custom_field_defs (item_type_id, name, field_key, field_type, required, default_value, options, sort_order)
SELECT id, 'Manufacturer', 'manufacturer', 'text', 0, '', '', 1 FROM item_types WHERE name = 'Desktop';
INSERT INTO custom_field_defs (item_type_id, name, field_key, field_type, required, default_value, options, sort_order)
SELECT id, 'Model', 'model', 'text', 0, '', '', 2 FROM item_types WHERE name = 'Desktop';
INSERT INTO custom_field_defs (item_type_id, name, field_key, field_type, required, default_value, options, sort_order)
SELECT id, 'Serial Number', 'serial_number', 'text', 0, '', '', 3 FROM item_types WHERE name = 'Desktop';
INSERT INTO custom_field_defs (item_type_id, name, field_key, field_type, required, default_value, options, sort_order)
SELECT id, 'CPU', 'cpu', 'text', 0, '', '', 4 FROM item_types WHERE name = 'Desktop';
INSERT INTO custom_field_defs (item_type_id, name, field_key, field_type, required, default_value, options, sort_order)
SELECT id, 'RAM', 'ram', 'text', 0, '', '', 5 FROM item_types WHERE name = 'Desktop';
INSERT INTO custom_field_defs (item_type_id, name, field_key, field_type, required, default_value, options, sort_order)
SELECT id, 'Storage', 'storage', 'text', 0, '', '', 6 FROM item_types WHERE name = 'Desktop';
INSERT INTO custom_field_defs (item_type_id, name, field_key, field_type, required, default_value, options, sort_order)
SELECT id, 'Operating System', 'operating_system', 'text', 0, '', '', 7 FROM item_types WHERE name = 'Desktop';
-- Server
INSERT INTO custom_field_defs (item_type_id, name, field_key, field_type, required, default_value, options, sort_order)
SELECT id, 'Manufacturer', 'manufacturer', 'text', 0, '', '', 1 FROM item_types WHERE name = 'Server';
INSERT INTO custom_field_defs (item_type_id, name, field_key, field_type, required, default_value, options, sort_order)
SELECT id, 'Model', 'model', 'text', 0, '', '', 2 FROM item_types WHERE name = 'Server';
INSERT INTO custom_field_defs (item_type_id, name, field_key, field_type, required, default_value, options, sort_order)
SELECT id, 'Serial Number', 'serial_number', 'text', 0, '', '', 3 FROM item_types WHERE name = 'Server';
INSERT INTO custom_field_defs (item_type_id, name, field_key, field_type, required, default_value, options, sort_order)
SELECT id, 'CPU', 'cpu', 'text', 0, '', '', 4 FROM item_types WHERE name = 'Server';
INSERT INTO custom_field_defs (item_type_id, name, field_key, field_type, required, default_value, options, sort_order)
SELECT id, 'RAM', 'ram', 'text', 0, '', '', 5 FROM item_types WHERE name = 'Server';
INSERT INTO custom_field_defs (item_type_id, name, field_key, field_type, required, default_value, options, sort_order)
SELECT id, 'Storage', 'storage', 'text', 0, '', '', 6 FROM item_types WHERE name = 'Server';
INSERT INTO custom_field_defs (item_type_id, name, field_key, field_type, required, default_value, options, sort_order)
SELECT id, 'Operating System', 'operating_system', 'text', 0, '', '', 7 FROM item_types WHERE name = 'Server';
INSERT INTO custom_field_defs (item_type_id, name, field_key, field_type, required, default_value, options, sort_order)
SELECT id, 'Management IP', 'management_ip', 'ip_address', 0, '', '', 8 FROM item_types WHERE name = 'Server';
-- Switch
INSERT INTO custom_field_defs (item_type_id, name, field_key, field_type, required, default_value, options, sort_order)
SELECT id, 'Manufacturer', 'manufacturer', 'text', 0, '', '', 1 FROM item_types WHERE name = 'Switch';
INSERT INTO custom_field_defs (item_type_id, name, field_key, field_type, required, default_value, options, sort_order)
SELECT id, 'Model', 'model', 'text', 0, '', '', 2 FROM item_types WHERE name = 'Switch';
INSERT INTO custom_field_defs (item_type_id, name, field_key, field_type, required, default_value, options, sort_order)
SELECT id, 'Serial Number', 'serial_number', 'text', 0, '', '', 3 FROM item_types WHERE name = 'Switch';
INSERT INTO custom_field_defs (item_type_id, name, field_key, field_type, required, default_value, options, sort_order)
SELECT id, 'Management IP', 'management_ip', 'ip_address', 0, '', '', 4 FROM item_types WHERE name = 'Switch';
INSERT INTO custom_field_defs (item_type_id, name, field_key, field_type, required, default_value, options, sort_order)
SELECT id, 'Port Count', 'port_count', 'number', 0, '', '', 5 FROM item_types WHERE name = 'Switch';
INSERT INTO custom_field_defs (item_type_id, name, field_key, field_type, required, default_value, options, sort_order)
SELECT id, 'Firmware Version', 'firmware_version', 'text', 0, '', '', 6 FROM item_types WHERE name = 'Switch';
-- Router
INSERT INTO custom_field_defs (item_type_id, name, field_key, field_type, required, default_value, options, sort_order)
SELECT id, 'Manufacturer', 'manufacturer', 'text', 0, '', '', 1 FROM item_types WHERE name = 'Router';
INSERT INTO custom_field_defs (item_type_id, name, field_key, field_type, required, default_value, options, sort_order)
SELECT id, 'Model', 'model', 'text', 0, '', '', 2 FROM item_types WHERE name = 'Router';
INSERT INTO custom_field_defs (item_type_id, name, field_key, field_type, required, default_value, options, sort_order)
SELECT id, 'Serial Number', 'serial_number', 'text', 0, '', '', 3 FROM item_types WHERE name = 'Router';
INSERT INTO custom_field_defs (item_type_id, name, field_key, field_type, required, default_value, options, sort_order)
SELECT id, 'Management IP', 'management_ip', 'ip_address', 0, '', '', 4 FROM item_types WHERE name = 'Router';
INSERT INTO custom_field_defs (item_type_id, name, field_key, field_type, required, default_value, options, sort_order)
SELECT id, 'Firmware Version', 'firmware_version', 'text', 0, '', '', 5 FROM item_types WHERE name = 'Router';
-- Firewall
INSERT INTO custom_field_defs (item_type_id, name, field_key, field_type, required, default_value, options, sort_order)
SELECT id, 'Manufacturer', 'manufacturer', 'text', 0, '', '', 1 FROM item_types WHERE name = 'Firewall';
INSERT INTO custom_field_defs (item_type_id, name, field_key, field_type, required, default_value, options, sort_order)
SELECT id, 'Model', 'model', 'text', 0, '', '', 2 FROM item_types WHERE name = 'Firewall';
INSERT INTO custom_field_defs (item_type_id, name, field_key, field_type, required, default_value, options, sort_order)
SELECT id, 'Serial Number', 'serial_number', 'text', 0, '', '', 3 FROM item_types WHERE name = 'Firewall';
INSERT INTO custom_field_defs (item_type_id, name, field_key, field_type, required, default_value, options, sort_order)
SELECT id, 'Management IP', 'management_ip', 'ip_address', 0, '', '', 4 FROM item_types WHERE name = 'Firewall';
INSERT INTO custom_field_defs (item_type_id, name, field_key, field_type, required, default_value, options, sort_order)
SELECT id, 'Firmware Version', 'firmware_version', 'text', 0, '', '', 5 FROM item_types WHERE name = 'Firewall';
-- Access Point
INSERT INTO custom_field_defs (item_type_id, name, field_key, field_type, required, default_value, options, sort_order)
SELECT id, 'Manufacturer', 'manufacturer', 'text', 0, '', '', 1 FROM item_types WHERE name = 'Access Point';
INSERT INTO custom_field_defs (item_type_id, name, field_key, field_type, required, default_value, options, sort_order)
SELECT id, 'Model', 'model', 'text', 0, '', '', 2 FROM item_types WHERE name = 'Access Point';
INSERT INTO custom_field_defs (item_type_id, name, field_key, field_type, required, default_value, options, sort_order)
SELECT id, 'Serial Number', 'serial_number', 'text', 0, '', '', 3 FROM item_types WHERE name = 'Access Point';
INSERT INTO custom_field_defs (item_type_id, name, field_key, field_type, required, default_value, options, sort_order)
SELECT id, 'Management IP', 'management_ip', 'ip_address', 0, '', '', 4 FROM item_types WHERE name = 'Access Point';
INSERT INTO custom_field_defs (item_type_id, name, field_key, field_type, required, default_value, options, sort_order)
SELECT id, 'Firmware Version', 'firmware_version', 'text', 0, '', '', 5 FROM item_types WHERE name = 'Access Point';
-- Printer
INSERT INTO custom_field_defs (item_type_id, name, field_key, field_type, required, default_value, options, sort_order)
SELECT id, 'Manufacturer', 'manufacturer', 'text', 0, '', '', 1 FROM item_types WHERE name = 'Printer';
INSERT INTO custom_field_defs (item_type_id, name, field_key, field_type, required, default_value, options, sort_order)
SELECT id, 'Model', 'model', 'text', 0, '', '', 2 FROM item_types WHERE name = 'Printer';
INSERT INTO custom_field_defs (item_type_id, name, field_key, field_type, required, default_value, options, sort_order)
SELECT id, 'Serial Number', 'serial_number', 'text', 0, '', '', 3 FROM item_types WHERE name = 'Printer';
INSERT INTO custom_field_defs (item_type_id, name, field_key, field_type, required, default_value, options, sort_order)
SELECT id, 'Management IP', 'management_ip', 'ip_address', 0, '', '', 4 FROM item_types WHERE name = 'Printer';
-- Monitor
INSERT INTO custom_field_defs (item_type_id, name, field_key, field_type, required, default_value, options, sort_order)
SELECT id, 'Manufacturer', 'manufacturer', 'text', 0, '', '', 1 FROM item_types WHERE name = 'Monitor';
INSERT INTO custom_field_defs (item_type_id, name, field_key, field_type, required, default_value, options, sort_order)
SELECT id, 'Model', 'model', 'text', 0, '', '', 2 FROM item_types WHERE name = 'Monitor';
INSERT INTO custom_field_defs (item_type_id, name, field_key, field_type, required, default_value, options, sort_order)
SELECT id, 'Serial Number', 'serial_number', 'text', 0, '', '', 3 FROM item_types WHERE name = 'Monitor';
INSERT INTO custom_field_defs (item_type_id, name, field_key, field_type, required, default_value, options, sort_order)
SELECT id, 'Screen Size', 'screen_size', 'text', 0, '', '', 4 FROM item_types WHERE name = 'Monitor';
-- Phone
INSERT INTO custom_field_defs (item_type_id, name, field_key, field_type, required, default_value, options, sort_order)
SELECT id, 'Manufacturer', 'manufacturer', 'text', 0, '', '', 1 FROM item_types WHERE name = 'Phone';
INSERT INTO custom_field_defs (item_type_id, name, field_key, field_type, required, default_value, options, sort_order)
SELECT id, 'Model', 'model', 'text', 0, '', '', 2 FROM item_types WHERE name = 'Phone';
INSERT INTO custom_field_defs (item_type_id, name, field_key, field_type, required, default_value, options, sort_order)
SELECT id, 'Serial Number', 'serial_number', 'text', 0, '', '', 3 FROM item_types WHERE name = 'Phone';
INSERT INTO custom_field_defs (item_type_id, name, field_key, field_type, required, default_value, options, sort_order)
SELECT id, 'Phone Number', 'phone_number', 'text', 0, '', '', 4 FROM item_types WHERE name = 'Phone';
-- UPS
INSERT INTO custom_field_defs (item_type_id, name, field_key, field_type, required, default_value, options, sort_order)
SELECT id, 'Manufacturer', 'manufacturer', 'text', 0, '', '', 1 FROM item_types WHERE name = 'UPS';
INSERT INTO custom_field_defs (item_type_id, name, field_key, field_type, required, default_value, options, sort_order)
SELECT id, 'Model', 'model', 'text', 0, '', '', 2 FROM item_types WHERE name = 'UPS';
INSERT INTO custom_field_defs (item_type_id, name, field_key, field_type, required, default_value, options, sort_order)
SELECT id, 'Serial Number', 'serial_number', 'text', 0, '', '', 3 FROM item_types WHERE name = 'UPS';
INSERT INTO custom_field_defs (item_type_id, name, field_key, field_type, required, default_value, options, sort_order)
SELECT id, 'VA Rating', 'va_rating', 'text', 0, '', '', 4 FROM item_types WHERE name = 'UPS';
INSERT INTO custom_field_defs (item_type_id, name, field_key, field_type, required, default_value, options, sort_order)
SELECT id, 'Battery Replacement Date', 'battery_replacement_date', 'date', 0, '', '', 5 FROM item_types WHERE name = 'UPS';
+342
View File
@@ -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()
}
+159
View File
@@ -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)
}
}
+247
View File
@@ -0,0 +1,247 @@
// 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, 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, &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, 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, &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) VALUES (?, ?, ?, ?, ?)`,
it.Name, it.Description, it.Icon, it.Enabled, it.SortOrder)
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 = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?`,
it.Name, it.Description, it.Icon, it.Enabled, it.SortOrder, 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
}
+107
View File
@@ -0,0 +1,107 @@
package itemtypes_test
import (
"context"
"database/sql"
"path/filepath"
"testing"
"inventree/internal/database"
"inventree/internal/itemtypes"
"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 TestSlugify(t *testing.T) {
cases := map[string]string{
"Management IP": "management_ip",
"CPU": "cpu",
" Serial Number ": "serial_number",
"": "field",
}
for in, want := range cases {
if got := itemtypes.Slugify(in); got != want {
t.Errorf("Slugify(%q) = %q, want %q", in, got, want)
}
}
}
func TestCreateItemTypeWithFields(t *testing.T) {
ctx := context.Background()
s := itemtypes.NewStore(testDB(t))
it := &models.ItemType{Name: "Tablet", Enabled: true}
if err := s.Create(ctx, it); err != nil {
t.Fatalf("create: %v", err)
}
field := &models.CustomFieldDef{ItemTypeID: it.ID, Name: "Screen Size", FieldType: models.FieldTypeText}
if err := s.CreateField(ctx, field); err != nil {
t.Fatalf("create field: %v", err)
}
if field.FieldKey != "screen_size" {
t.Fatalf("expected auto-slugified key, got %q", field.FieldKey)
}
loaded, err := s.Get(ctx, it.ID)
if err != nil {
t.Fatalf("get: %v", err)
}
if len(loaded.Fields) != 1 || loaded.Fields[0].Name != "Screen Size" {
t.Fatalf("expected 1 field, got %+v", loaded.Fields)
}
}
func TestDeleteItemTypeInUseFails(t *testing.T) {
ctx := context.Background()
db := testDB(t)
s := itemtypes.NewStore(db)
// item_type_id 1 (Laptop, seeded) is referenced by no assets yet, so
// deletion should succeed; insert a fake asset row to simulate usage.
if _, err := db.Exec(`INSERT INTO assets (asset_id, name, item_type_id, status_id) VALUES ('IT-1', 'x', 1, 1)`); err != nil {
t.Fatalf("seed asset: %v", err)
}
if err := s.Delete(ctx, 1); err != itemtypes.ErrInUse {
t.Fatalf("expected ErrInUse, got %v", err)
}
}
func TestDropdownFieldOptionsRoundTrip(t *testing.T) {
ctx := context.Background()
s := itemtypes.NewStore(testDB(t))
it := &models.ItemType{Name: "Printer2", Enabled: true}
must(t, s.Create(ctx, it))
field := &models.CustomFieldDef{
ItemTypeID: it.ID, Name: "Color Mode", FieldType: models.FieldTypeDropdown,
Options: []string{"Color", "Black & White"},
}
must(t, s.CreateField(ctx, field))
got, err := s.GetField(ctx, field.ID)
if err != nil {
t.Fatalf("get field: %v", err)
}
if len(got.Options) != 2 || got.Options[0] != "Color" || got.Options[1] != "Black & White" {
t.Fatalf("unexpected options: %+v", got.Options)
}
}
func must(t *testing.T, err error) {
t.Helper()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
+173
View File
@@ -0,0 +1,173 @@
// Package models defines the core domain types shared across inventree.
package models
import "time"
type Role string
const (
RoleAdmin Role = "admin"
RoleUser Role = "user"
)
type User struct {
ID int64
Username string
PasswordHash string
Role Role
Enabled bool
CreatedAt time.Time
UpdatedAt time.Time
}
func (u User) IsAdmin() bool { return u.Role == RoleAdmin }
type Folder struct {
ID int64
Name string
ParentID *int64
Description string
Icon string
SortOrder int
CreatedAt time.Time
UpdatedAt time.Time
// Populated by list/tree queries; not persisted directly on this table.
FolderCount int
AssetCount int
}
type Status struct {
ID int64
Name string
Color string
SortOrder int
CreatedAt time.Time
UpdatedAt time.Time
}
type FieldType string
const (
FieldTypeText FieldType = "text"
FieldTypeLongText FieldType = "long_text"
FieldTypeNumber FieldType = "number"
FieldTypeDate FieldType = "date"
FieldTypeBoolean FieldType = "boolean"
FieldTypeDropdown FieldType = "dropdown"
FieldTypeURL FieldType = "url"
FieldTypeIPAddress FieldType = "ip_address"
)
var AllFieldTypes = []FieldType{
FieldTypeText, FieldTypeLongText, FieldTypeNumber, FieldTypeDate,
FieldTypeBoolean, FieldTypeDropdown, FieldTypeURL, FieldTypeIPAddress,
}
func (t FieldType) Label() string {
switch t {
case FieldTypeText:
return "Text"
case FieldTypeLongText:
return "Long Text"
case FieldTypeNumber:
return "Number"
case FieldTypeDate:
return "Date"
case FieldTypeBoolean:
return "Boolean"
case FieldTypeDropdown:
return "Dropdown"
case FieldTypeURL:
return "URL"
case FieldTypeIPAddress:
return "IP Address"
}
return string(t)
}
type ItemType struct {
ID int64
Name string
Description string
Icon string
Enabled bool
SortOrder int
CreatedAt time.Time
UpdatedAt time.Time
Fields []CustomFieldDef
}
type CustomFieldDef struct {
ID int64
ItemTypeID int64
Name string
FieldKey string
FieldType FieldType
Required bool
DefaultValue string
Options []string
SortOrder int
CreatedAt time.Time
UpdatedAt time.Time
}
type CustomFieldValue struct {
ID int64
AssetID int64
FieldDefID int64
Value string
}
type Asset struct {
ID int64
AssetTag string
Name string
ItemTypeID int64
FolderID *int64
StatusID int64
AssignedUserID *int64
Notes string
CreatedAt time.Time
UpdatedAt time.Time
// Populated by joined list/detail queries.
ItemTypeName string
ItemTypeIcon string
StatusName string
StatusColor string
AssignedUsername string
FolderPath []BreadcrumbItem
CustomFields []AssetFieldValue
}
type AssetFieldValue struct {
Def CustomFieldDef
Value string
}
type BreadcrumbItem struct {
ID int64
Name string
}
// TreeNode is a folder-or-asset node used to render the inventory tree.
type TreeNode struct {
Type string // "folder" or "asset"
ID int64
Name string
Icon string
ParentID *int64
HasChildren bool
AssetTag string
StatusName string
StatusColor string
// Populated only when the node is pre-expanded for server-side
// recursive rendering (see web.buildTree); empty otherwise, in which
// case the client lazy-loads children via /app/tree/children.
Expanded bool
Children []TreeNode
MoreAssets int // count of additional assets beyond the tree display cap, if any
}
+49
View File
@@ -0,0 +1,49 @@
// Package settings provides access to the application's key/value settings.
package settings
import (
"context"
"database/sql"
"errors"
)
type Store struct{ db *sql.DB }
func NewStore(db *sql.DB) *Store { return &Store{db: db} }
func (s *Store) Get(ctx context.Context, key, fallback string) (string, error) {
var v string
err := s.db.QueryRowContext(ctx, `SELECT value FROM settings WHERE key = ?`, key).Scan(&v)
if errors.Is(err, sql.ErrNoRows) {
return fallback, nil
}
if err != nil {
return "", err
}
return v, nil
}
func (s *Store) Set(ctx context.Context, key, value string) error {
_, err := s.db.ExecContext(ctx,
`INSERT INTO settings (key, value, updated_at) VALUES (?, ?, strftime('%Y-%m-%dT%H:%M:%fZ','now'))
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at`,
key, value)
return err
}
func (s *Store) All(ctx context.Context) (map[string]string, error) {
rows, err := s.db.QueryContext(ctx, `SELECT key, value FROM settings`)
if err != nil {
return nil, err
}
defer rows.Close()
out := map[string]string{}
for rows.Next() {
var k, v string
if err := rows.Scan(&k, &v); err != nil {
return nil, err
}
out[k] = v
}
return out, rows.Err()
}
+121
View File
@@ -0,0 +1,121 @@
// Package statuses manages the configurable list of asset statuses.
package statuses
import (
"context"
"database/sql"
"errors"
"time"
"inventree/internal/models"
)
var (
ErrNotFound = errors.New("status not found")
ErrNameRequired = errors.New("status name is required")
ErrInUse = errors.New("status is assigned to one or more 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 scan(row interface{ Scan(...any) error }) (*models.Status, error) {
var st models.Status
var created, updated string
if err := row.Scan(&st.ID, &st.Name, &st.Color, &st.SortOrder, &created, &updated); err != nil {
return nil, err
}
st.CreatedAt = parseTime(created)
st.UpdatedAt = parseTime(updated)
return &st, nil
}
func (s *Store) List(ctx context.Context) ([]models.Status, error) {
rows, err := s.db.QueryContext(ctx, `SELECT id, name, color, sort_order, created_at, updated_at FROM statuses ORDER BY sort_order ASC, name ASC`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []models.Status
for rows.Next() {
st, err := scan(rows)
if err != nil {
return nil, err
}
out = append(out, *st)
}
return out, rows.Err()
}
func (s *Store) Get(ctx context.Context, id int64) (*models.Status, error) {
row := s.db.QueryRowContext(ctx, `SELECT id, name, color, sort_order, created_at, updated_at FROM statuses WHERE id = ?`, id)
st, err := scan(row)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
return st, err
}
func (s *Store) Create(ctx context.Context, st *models.Status) error {
if st.Name == "" {
return ErrNameRequired
}
if st.Color == "" {
st.Color = "secondary"
}
res, err := s.db.ExecContext(ctx, `INSERT INTO statuses (name, color, sort_order) VALUES (?, ?, ?)`, st.Name, st.Color, st.SortOrder)
if err != nil {
return err
}
id, err := res.LastInsertId()
if err != nil {
return err
}
st.ID = id
return nil
}
func (s *Store) Update(ctx context.Context, st *models.Status) error {
if st.Name == "" {
return ErrNameRequired
}
_, err := s.db.ExecContext(ctx,
`UPDATE statuses SET name = ?, color = ?, sort_order = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?`,
st.Name, st.Color, st.SortOrder, st.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 status_id = ?`, id).Scan(&count); err != nil {
return err
}
if count > 0 {
return ErrInUse
}
_, err := s.db.ExecContext(ctx, `DELETE FROM statuses WHERE id = ?`, id)
return err
}
// Reorder sets sort_order for each status ID in the given order (1-indexed).
func (s *Store) Reorder(ctx context.Context, orderedIDs []int64) error {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
for i, id := range orderedIDs {
if _, err := tx.ExecContext(ctx, `UPDATE statuses SET sort_order = ? WHERE id = ?`, i+1, id); err != nil {
return err
}
}
return tx.Commit()
}
+186
View File
@@ -0,0 +1,186 @@
// Package users manages application accounts.
package users
import (
"context"
"database/sql"
"errors"
"time"
"golang.org/x/crypto/bcrypt"
"inventree/internal/models"
)
var (
ErrNotFound = errors.New("user not found")
ErrUsernameRequired = errors.New("username is required")
ErrUsernameTaken = errors.New("username is already taken")
ErrInvalidRole = errors.New("role must be admin or user")
ErrLastAdmin = errors.New("cannot remove the last enabled admin")
)
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 scan(row interface{ Scan(...any) error }) (*models.User, error) {
var u models.User
var created, updated string
if err := row.Scan(&u.ID, &u.Username, &u.PasswordHash, &u.Role, &u.Enabled, &created, &updated); err != nil {
return nil, err
}
u.CreatedAt = parseTime(created)
u.UpdatedAt = parseTime(updated)
return &u, nil
}
func (s *Store) List(ctx context.Context) ([]models.User, error) {
rows, err := s.db.QueryContext(ctx, `SELECT id, username, password_hash, role, enabled, created_at, updated_at FROM users ORDER BY username COLLATE NOCASE ASC`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []models.User
for rows.Next() {
u, err := scan(rows)
if err != nil {
return nil, err
}
out = append(out, *u)
}
return out, rows.Err()
}
func (s *Store) Get(ctx context.Context, id int64) (*models.User, error) {
row := s.db.QueryRowContext(ctx, `SELECT id, username, password_hash, role, enabled, created_at, updated_at FROM users WHERE id = ?`, id)
u, err := scan(row)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
return u, err
}
func (s *Store) GetByUsername(ctx context.Context, username string) (*models.User, error) {
row := s.db.QueryRowContext(ctx, `SELECT id, username, password_hash, role, enabled, created_at, updated_at FROM users WHERE username = ?`, username)
u, err := scan(row)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
return u, err
}
// Create adds a user with the given plaintext password (hashed with bcrypt).
func (s *Store) Create(ctx context.Context, u *models.User, plaintextPassword string) error {
if u.Username == "" {
return ErrUsernameRequired
}
if u.Role != models.RoleAdmin && u.Role != models.RoleUser {
return ErrInvalidRole
}
if _, err := s.GetByUsername(ctx, u.Username); err == nil {
return ErrUsernameTaken
} else if !errors.Is(err, ErrNotFound) {
return err
}
hash, err := bcrypt.GenerateFromPassword([]byte(plaintextPassword), bcrypt.DefaultCost)
if err != nil {
return err
}
res, err := s.db.ExecContext(ctx,
`INSERT INTO users (username, password_hash, role, enabled) VALUES (?, ?, ?, ?)`,
u.Username, string(hash), u.Role, u.Enabled)
if err != nil {
return err
}
id, err := res.LastInsertId()
if err != nil {
return err
}
u.ID = id
return nil
}
// Update changes username, role, and enabled state (not the password).
func (s *Store) Update(ctx context.Context, u *models.User) error {
if u.Username == "" {
return ErrUsernameRequired
}
if u.Role != models.RoleAdmin && u.Role != models.RoleUser {
return ErrInvalidRole
}
if existing, err := s.GetByUsername(ctx, u.Username); err == nil && existing.ID != u.ID {
return ErrUsernameTaken
}
if u.Role != models.RoleAdmin || !u.Enabled {
if err := s.assertNotLastAdmin(ctx, u.ID); err != nil {
return err
}
}
_, err := s.db.ExecContext(ctx,
`UPDATE users SET username = ?, role = ?, enabled = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?`,
u.Username, u.Role, u.Enabled, u.ID)
return err
}
func (s *Store) SetPassword(ctx context.Context, id int64, plaintextPassword string) error {
hash, err := bcrypt.GenerateFromPassword([]byte(plaintextPassword), bcrypt.DefaultCost)
if err != nil {
return err
}
_, err = s.db.ExecContext(ctx,
`UPDATE users SET password_hash = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?`,
string(hash), id)
return err
}
func (s *Store) Delete(ctx context.Context, id int64) error {
if err := s.assertNotLastAdmin(ctx, id); err != nil {
return err
}
_, err := s.db.ExecContext(ctx, `DELETE FROM users WHERE id = ?`, id)
return err
}
// assertNotLastAdmin returns ErrLastAdmin if id is currently an enabled
// admin and is the only one remaining.
func (s *Store) assertNotLastAdmin(ctx context.Context, id int64) error {
u, err := s.Get(ctx, id)
if err != nil {
return err
}
if u.Role != models.RoleAdmin || !u.Enabled {
return nil
}
var count int
if err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM users WHERE role = 'admin' AND enabled = 1`).Scan(&count); err != nil {
return err
}
if count <= 1 {
return ErrLastAdmin
}
return nil
}
// Authenticate verifies a username/password pair and returns the user if
// valid, enabled, and the password matches.
func (s *Store) Authenticate(ctx context.Context, username, password string) (*models.User, error) {
u, err := s.GetByUsername(ctx, username)
if err != nil {
return nil, err
}
if !u.Enabled {
return nil, errors.New("account is disabled")
}
if err := bcrypt.CompareHashAndPassword([]byte(u.PasswordHash), []byte(password)); err != nil {
return nil, errors.New("invalid credentials")
}
return u, nil
}
+96
View File
@@ -0,0 +1,96 @@
package users_test
import (
"context"
"database/sql"
"path/filepath"
"testing"
"inventree/internal/database"
"inventree/internal/models"
"inventree/internal/users"
)
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 TestDefaultAdminSeeded(t *testing.T) {
ctx := context.Background()
s := users.NewStore(testDB(t))
u, err := s.Authenticate(ctx, "admin", "admin")
if err != nil {
t.Fatalf("authenticate default admin: %v", err)
}
if !u.IsAdmin() {
t.Fatal("expected default admin to have admin role")
}
}
func TestAuthenticateRejectsBadPassword(t *testing.T) {
s := users.NewStore(testDB(t))
if _, err := s.Authenticate(context.Background(), "admin", "wrong"); err == nil {
t.Fatal("expected authentication to fail")
}
}
func TestCreateUserRejectsDuplicateUsername(t *testing.T) {
ctx := context.Background()
s := users.NewStore(testDB(t))
u := &models.User{Username: "admin", Role: models.RoleUser, Enabled: true}
if err := s.Create(ctx, u, "whatever"); err != users.ErrUsernameTaken {
t.Fatalf("expected ErrUsernameTaken, got %v", err)
}
}
func TestCannotRemoveLastAdmin(t *testing.T) {
ctx := context.Background()
s := users.NewStore(testDB(t))
admin, err := s.GetByUsername(ctx, "admin")
if err != nil {
t.Fatalf("get admin: %v", err)
}
if err := s.Delete(ctx, admin.ID); err != users.ErrLastAdmin {
t.Fatalf("expected ErrLastAdmin on delete, got %v", err)
}
admin.Enabled = false
if err := s.Update(ctx, admin); err != users.ErrLastAdmin {
t.Fatalf("expected ErrLastAdmin on disable, got %v", err)
}
admin.Role = models.RoleUser
admin.Enabled = true
if err := s.Update(ctx, admin); err != users.ErrLastAdmin {
t.Fatalf("expected ErrLastAdmin on demote, got %v", err)
}
}
func TestSecondAdminAllowsRemovingFirst(t *testing.T) {
ctx := context.Background()
s := users.NewStore(testDB(t))
second := &models.User{Username: "second-admin", Role: models.RoleAdmin, Enabled: true}
if err := s.Create(ctx, second, "password123"); err != nil {
t.Fatalf("create second admin: %v", err)
}
admin, err := s.GetByUsername(ctx, "admin")
if err != nil {
t.Fatalf("get admin: %v", err)
}
if err := s.Delete(ctx, admin.ID); err != nil {
t.Fatalf("expected delete to succeed with a second admin present: %v", err)
}
}
+91
View File
@@ -0,0 +1,91 @@
// Package web implements the HTTP server: routing, session middleware,
// template rendering, and all page/API handlers for inventree.
package web
import (
"context"
"database/sql"
"log/slog"
"net/http"
"time"
"inventree/internal/assets"
"inventree/internal/auth"
"inventree/internal/folders"
"inventree/internal/itemtypes"
"inventree/internal/settings"
"inventree/internal/statuses"
"inventree/internal/users"
)
// App holds every dependency HTTP handlers need: data stores, templates, and
// server-wide configuration.
type App struct {
DB *sql.DB
Auth *auth.Store
Users *users.Store
Folders *folders.Store
Assets *assets.Store
ItemTypes *itemtypes.Store
Statuses *statuses.Store
Settings *settings.Store
templates *templateSet
Log *slog.Logger
// SecureCookies controls the Secure flag on the session cookie; set
// true when serving over HTTPS.
SecureCookies bool
}
// NewApp wires all stores together from a shared database handle.
func NewApp(db *sql.DB, log *slog.Logger, secureCookies bool) (*App, error) {
userStore := users.NewStore(db)
folderStore := folders.NewStore(db)
settingsStore := settings.NewStore(db)
app := &App{
DB: db,
Users: userStore,
Folders: folderStore,
ItemTypes: itemtypes.NewStore(db),
Statuses: statuses.NewStore(db),
Settings: settingsStore,
Assets: assets.NewStore(db, settingsStore, folderStore),
Auth: auth.NewStore(db, userStore),
Log: log,
SecureCookies: secureCookies,
}
tmpl, err := loadTemplates()
if err != nil {
return nil, err
}
app.templates = tmpl
return app, nil
}
// StartSessionJanitor periodically removes expired sessions until ctx (via
// the returned stop channel) signals shutdown.
func (a *App) StartSessionJanitor(stop <-chan struct{}) {
ticker := time.NewTicker(1 * time.Hour)
go func() {
defer ticker.Stop()
for {
select {
case <-ticker.C:
if err := a.Auth.CleanExpired(context.Background()); err != nil {
a.Log.Warn("session cleanup failed", "error", err)
}
case <-stop:
return
}
}
}()
}
func writeErr(w http.ResponseWriter, status int, msg string) {
http.Error(w, msg, status)
}
+56
View File
@@ -0,0 +1,56 @@
package web
import "context"
// FolderOption is a flattened, indented folder choice for <select> dropdowns
// (asset create/edit, move-to pickers).
type FolderOption struct {
ID int64
Path string
Depth int
}
// folderOptions returns every folder as a flat, depth-first ordered list
// with indentable depth, suitable for a folder-picker <select>.
func (a *App) folderOptions(ctx context.Context) ([]FolderOption, error) {
all, err := a.Folders.All(ctx)
if err != nil {
return nil, err
}
byParent := map[int64][]struct {
ID int64
Name string
}{}
var top []struct {
ID int64
Name string
}
for _, f := range all {
item := struct {
ID int64
Name string
}{f.ID, f.Name}
if f.ParentID == nil {
top = append(top, item)
} else {
byParent[*f.ParentID] = append(byParent[*f.ParentID], item)
}
}
var out []FolderOption
var walk func(id int64, name, prefix string, depth int)
walk = func(id int64, name, prefix string, depth int) {
path := name
if prefix != "" {
path = prefix + " / " + name
}
out = append(out, FolderOption{ID: id, Path: path, Depth: depth})
for _, child := range byParent[id] {
walk(child.ID, child.Name, path, depth+1)
}
}
for _, t := range top {
walk(t.ID, t.Name, "", 0)
}
return out, nil
}
+129
View File
@@ -0,0 +1,129 @@
package web
import (
"net/http"
"strconv"
"inventree/internal/models"
)
type assetRequest struct {
AssetTag string `json:"asset_id"`
Name string `json:"name"`
ItemTypeID int64 `json:"item_type_id"`
FolderID *int64 `json:"folder_id"`
StatusID int64 `json:"status_id"`
AssignedUserID *int64 `json:"assigned_user_id"`
Notes string `json:"notes"`
Fields map[string]string `json:"fields"`
}
func (req assetRequest) fieldValues() map[int64]string {
out := make(map[int64]string, len(req.Fields))
for k, v := range req.Fields {
id, err := strconv.ParseInt(k, 10, 64)
if err != nil {
continue
}
out[id] = v
}
return out
}
func (a *App) handleAssetCreate(w http.ResponseWriter, r *http.Request) {
var req assetRequest
if err := readJSON(r, &req); err != nil {
jsonErr(w, http.StatusBadRequest, err)
return
}
asset := &models.Asset{
AssetTag: req.AssetTag, Name: req.Name, ItemTypeID: req.ItemTypeID, FolderID: req.FolderID,
StatusID: req.StatusID, AssignedUserID: req.AssignedUserID, Notes: req.Notes,
}
if err := a.Assets.Create(r.Context(), asset, req.fieldValues()); err != nil {
jsonErr(w, http.StatusBadRequest, err)
return
}
jsonOK(w, map[string]any{"id": asset.ID, "asset_id": asset.AssetTag, "folder_id": asset.FolderID})
}
func (a *App) handleAssetUpdate(w http.ResponseWriter, r *http.Request) {
id, err := pathID(r, "id")
if err != nil {
jsonErr(w, http.StatusBadRequest, err)
return
}
var req assetRequest
if err := readJSON(r, &req); err != nil {
jsonErr(w, http.StatusBadRequest, err)
return
}
existing, err := a.Assets.Get(r.Context(), id)
if err != nil {
jsonErr(w, http.StatusNotFound, err)
return
}
asset := &models.Asset{
ID: id, AssetTag: req.AssetTag, Name: req.Name, ItemTypeID: existing.ItemTypeID, FolderID: req.FolderID,
StatusID: req.StatusID, AssignedUserID: req.AssignedUserID, Notes: req.Notes,
}
if err := a.Assets.Update(r.Context(), asset, req.fieldValues()); err != nil {
jsonErr(w, http.StatusBadRequest, err)
return
}
jsonOK(w, map[string]any{"id": asset.ID, "asset_id": asset.AssetTag, "folder_id": asset.FolderID})
}
func (a *App) handleAssetDelete(w http.ResponseWriter, r *http.Request) {
id, err := pathID(r, "id")
if err != nil {
jsonErr(w, http.StatusBadRequest, err)
return
}
asset, err := a.Assets.Get(r.Context(), id)
if err != nil {
jsonErr(w, http.StatusNotFound, err)
return
}
if err := a.Assets.Delete(r.Context(), id); err != nil {
jsonErr(w, http.StatusInternalServerError, err)
return
}
jsonOK(w, map[string]any{"id": id, "folder_id": asset.FolderID})
}
func (a *App) handleAssetDuplicate(w http.ResponseWriter, r *http.Request) {
id, err := pathID(r, "id")
if err != nil {
jsonErr(w, http.StatusBadRequest, err)
return
}
copyAsset, err := a.Assets.Duplicate(r.Context(), id)
if err != nil {
jsonErr(w, http.StatusBadRequest, err)
return
}
jsonOK(w, map[string]any{"id": copyAsset.ID, "asset_id": copyAsset.AssetTag, "folder_id": copyAsset.FolderID})
}
type assetMoveRequest struct {
FolderID *int64 `json:"folder_id"`
}
func (a *App) handleAssetMove(w http.ResponseWriter, r *http.Request) {
id, err := pathID(r, "id")
if err != nil {
jsonErr(w, http.StatusBadRequest, err)
return
}
var req assetMoveRequest
if err := readJSON(r, &req); err != nil {
jsonErr(w, http.StatusBadRequest, err)
return
}
if err := a.Assets.Move(r.Context(), id, req.FolderID); err != nil {
jsonErr(w, http.StatusInternalServerError, err)
return
}
jsonOK(w, map[string]any{"id": id, "folder_id": req.FolderID})
}
+57
View File
@@ -0,0 +1,57 @@
package web
import (
"net/http"
"inventree/internal/auth"
)
type loginPageData struct {
Error string
Next string
}
func (a *App) handleLoginForm(w http.ResponseWriter, r *http.Request) {
if _, ok := auth.UserFromContext(r.Context()); ok {
http.Redirect(w, r, "/inventory", http.StatusSeeOther)
return
}
a.Render(w, http.StatusOK, "login", loginPageData{Next: r.URL.Query().Get("next")})
}
func (a *App) handleLoginSubmit(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
a.Render(w, http.StatusBadRequest, "login", loginPageData{Error: "Invalid form submission."})
return
}
username := r.FormValue("username")
password := r.FormValue("password")
next := r.FormValue("next")
u, err := a.Users.Authenticate(r.Context(), username, password)
if err != nil {
a.Render(w, http.StatusUnauthorized, "login", loginPageData{Error: "Invalid username or password.", Next: next})
return
}
token, expires, err := a.Auth.CreateSession(r.Context(), u.ID)
if err != nil {
a.Render(w, http.StatusInternalServerError, "login", loginPageData{Error: "Could not start a session. Please try again."})
return
}
auth.SetCookie(w, token, expires, a.SecureCookies)
dest := "/inventory"
if next != "" {
dest = next
}
http.Redirect(w, r, dest, http.StatusSeeOther)
}
func (a *App) handleLogout(w http.ResponseWriter, r *http.Request) {
if cookie, err := r.Cookie(auth.CookieName); err == nil {
_ = a.Auth.DestroySession(r.Context(), cookie.Value)
}
auth.ClearCookie(w, a.SecureCookies)
http.Redirect(w, r, "/login", http.StatusSeeOther)
}
+70
View File
@@ -0,0 +1,70 @@
package web
import (
"fmt"
"net/http"
"time"
"inventree/internal/assets"
)
func (a *App) handleExportCSV(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
filter := assets.ListFilter{
ItemTypeID: queryInt64(r, "item_type_id"),
StatusID: queryInt64(r, "status_id"),
AssignedUserID: queryInt64(r, "assigned_user_id"),
Query: r.URL.Query().Get("q"),
}
if folderID, err := queryOptionalID(r, "folder_id"); err == nil && folderID != nil {
filter.FolderID = folderID
filter.FolderSet = true
filter.IncludeSub = r.URL.Query().Get("recursive") == "1"
}
filename := fmt.Sprintf("inventree-assets-%s.csv", time.Now().Format("2006-01-02"))
w.Header().Set("Content-Type", "text/csv; charset=utf-8")
w.Header().Set("Content-Disposition", "attachment; filename=\""+filename+"\"")
if err := a.Assets.Export(ctx, w, filter); err != nil {
a.Log.Error("csv export failed", "error", err)
}
}
func (a *App) handleImportCSV(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
if err := r.ParseMultipartForm(32 << 20); err != nil {
jsonErr(w, http.StatusBadRequest, err)
return
}
file, _, err := r.FormFile("file")
if err != nil {
jsonErr(w, http.StatusBadRequest, err)
return
}
defer file.Close()
createMissing := r.FormValue("create_missing_folders") == "1"
itemTypes, err := a.ItemTypes.List(ctx, true)
if err != nil {
jsonErr(w, http.StatusInternalServerError, err)
return
}
statusList, err := a.Statuses.List(ctx)
if err != nil {
jsonErr(w, http.StatusInternalServerError, err)
return
}
userList, err := a.Users.List(ctx)
if err != nil {
jsonErr(w, http.StatusInternalServerError, err)
return
}
result, err := a.Assets.Import(ctx, file, assets.ImportOptions{CreateMissingFolders: createMissing}, itemTypes, statusList, userList)
if err != nil {
jsonErr(w, http.StatusBadRequest, err)
return
}
jsonOK(w, result)
}
+214
View File
@@ -0,0 +1,214 @@
package web
import (
"errors"
"net/http"
"inventree/internal/folders"
"inventree/internal/models"
)
func (a *App) handleFolderGet(w http.ResponseWriter, r *http.Request) {
id, err := pathID(r, "id")
if err != nil {
jsonErr(w, http.StatusBadRequest, err)
return
}
f, err := a.Folders.Get(r.Context(), id)
if err != nil {
jsonErr(w, http.StatusNotFound, err)
return
}
jsonOK(w, f)
}
type folderCreateRequest struct {
Name string `json:"name"`
ParentID *int64 `json:"parent_id"`
Description string `json:"description"`
Icon string `json:"icon"`
SortOrder int `json:"sort_order"`
}
func (a *App) handleFolderCreate(w http.ResponseWriter, r *http.Request) {
var req folderCreateRequest
if err := readJSON(r, &req); err != nil {
jsonErr(w, http.StatusBadRequest, err)
return
}
f := &models.Folder{Name: req.Name, ParentID: req.ParentID, Description: req.Description, Icon: req.Icon, SortOrder: req.SortOrder}
if err := a.Folders.Create(r.Context(), f); err != nil {
jsonErr(w, http.StatusBadRequest, err)
return
}
jsonOK(w, f)
}
type folderUpdateRequest struct {
Name string `json:"name"`
Description string `json:"description"`
Icon string `json:"icon"`
SortOrder int `json:"sort_order"`
}
func (a *App) handleFolderUpdate(w http.ResponseWriter, r *http.Request) {
id, err := pathID(r, "id")
if err != nil {
jsonErr(w, http.StatusBadRequest, err)
return
}
var req folderUpdateRequest
if err := readJSON(r, &req); err != nil {
jsonErr(w, http.StatusBadRequest, err)
return
}
f, err := a.Folders.Get(r.Context(), id)
if err != nil {
jsonErr(w, http.StatusNotFound, err)
return
}
f.Name, f.Description, f.Icon, f.SortOrder = req.Name, req.Description, req.Icon, req.SortOrder
if err := a.Folders.Update(r.Context(), f); err != nil {
jsonErr(w, http.StatusBadRequest, err)
return
}
jsonOK(w, f)
}
type folderMoveRequest struct {
ParentID *int64 `json:"parent_id"`
}
func (a *App) handleFolderMove(w http.ResponseWriter, r *http.Request) {
id, err := pathID(r, "id")
if err != nil {
jsonErr(w, http.StatusBadRequest, err)
return
}
var req folderMoveRequest
if err := readJSON(r, &req); err != nil {
jsonErr(w, http.StatusBadRequest, err)
return
}
if err := a.Folders.Move(r.Context(), id, req.ParentID); err != nil {
status := http.StatusBadRequest
if errors.Is(err, folders.ErrInvalidParent) {
status = http.StatusConflict
}
jsonErr(w, status, err)
return
}
jsonOK(w, map[string]any{"id": id, "parent_id": req.ParentID})
}
func (a *App) handleFolderDeleteInfo(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
id, err := pathID(r, "id")
if err != nil {
jsonErr(w, http.StatusBadRequest, err)
return
}
folderCount, assetCount, err := a.Folders.Counts(ctx, id)
if err != nil {
jsonErr(w, http.StatusInternalServerError, err)
return
}
f, err := a.Folders.Get(ctx, id)
if err != nil {
jsonErr(w, http.StatusNotFound, err)
return
}
all, err := a.Folders.All(ctx)
if err != nil {
jsonErr(w, http.StatusInternalServerError, err)
return
}
descendants := map[int64]bool{id: true}
changed := true
for changed {
changed = false
for _, cand := range all {
if cand.ParentID != nil && descendants[*cand.ParentID] && !descendants[cand.ID] {
descendants[cand.ID] = true
changed = true
}
}
}
byID := map[int64]models.Folder{}
for _, cand := range all {
byID[cand.ID] = cand
}
var pathOf func(int64) string
pathOf = func(fid int64) string {
ff, ok := byID[fid]
if !ok {
return ""
}
if ff.ParentID == nil {
return ff.Name
}
return pathOf(*ff.ParentID) + " / " + ff.Name
}
type dest struct {
ID int64 `json:"id"`
Path string `json:"path"`
}
destinations := []dest{{ID: 0, Path: "Inventory (top level)"}}
for _, cand := range all {
if descendants[cand.ID] {
continue
}
destinations = append(destinations, dest{ID: cand.ID, Path: pathOf(cand.ID)})
}
jsonOK(w, map[string]any{
"folder_name": f.Name,
"folder_count": folderCount,
"asset_count": assetCount,
"destinations": destinations,
})
}
func (a *App) handleFolderDelete(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
id, err := pathID(r, "id")
if err != nil {
jsonErr(w, http.StatusBadRequest, err)
return
}
mode := r.URL.Query().Get("mode")
switch mode {
case "relocate":
var dest *int64
if raw := r.URL.Query().Get("dest"); raw != "" && raw != "0" {
d, err := queryOptionalID(r, "dest")
if err != nil {
jsonErr(w, http.StatusBadRequest, err)
return
}
dest = d
}
if err := a.Folders.DeleteAndRelocate(ctx, id, dest); err != nil {
jsonErr(w, http.StatusBadRequest, err)
return
}
case "cascade":
if err := a.Folders.CascadeDelete(ctx, id); err != nil {
jsonErr(w, http.StatusBadRequest, err)
return
}
default:
if err := a.Folders.Delete(ctx, id); err != nil {
status := http.StatusBadRequest
if errors.Is(err, folders.ErrNotEmpty) {
status = http.StatusConflict
}
jsonErr(w, status, err)
return
}
}
jsonOK(w, map[string]any{"id": id})
}
+353
View File
@@ -0,0 +1,353 @@
package web
import (
"context"
"net/http"
"inventree/internal/assets"
"inventree/internal/models"
)
// InventoryPageData is the top-level data passed to templates/pages/inventory.html.
type InventoryPageData struct {
User *models.User
Tree []models.TreeNode
Root *RootPanelData
ItemTypes []models.ItemType
Statuses []models.Status
}
func (a *App) handleInventoryPage(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
u := currentUser(r)
expanded, err := a.expandedFolderIDs(ctx, u.ID)
if err != nil {
writeErr(w, http.StatusInternalServerError, err.Error())
return
}
tree, err := a.buildTree(ctx, nil, expanded)
if err != nil {
writeErr(w, http.StatusInternalServerError, err.Error())
return
}
root, err := a.rootPanelData(ctx)
if err != nil {
writeErr(w, http.StatusInternalServerError, err.Error())
return
}
itemTypes, err := a.ItemTypes.List(ctx, false)
if err != nil {
writeErr(w, http.StatusInternalServerError, err.Error())
return
}
statusList, err := a.Statuses.List(ctx)
if err != nil {
writeErr(w, http.StatusInternalServerError, err.Error())
return
}
a.Render(w, http.StatusOK, "inventory", InventoryPageData{
User: u, Tree: tree, Root: root, ItemTypes: itemTypes, Statuses: statusList,
})
}
// handleTreeChildren serves one level of the tree lazily for AJAX expand.
// When persist=1, the expanded state is remembered for the current user.
func (a *App) handleTreeChildren(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
u := currentUser(r)
parentID, err := queryOptionalID(r, "parent_id")
if err != nil {
writeErr(w, http.StatusBadRequest, "invalid parent_id")
return
}
if r.URL.Query().Get("persist") == "1" && parentID != nil {
if err := a.persistExpand(ctx, u.ID, *parentID); err != nil {
a.Log.Warn("persist expand failed", "error", err)
}
}
// Honor already-persisted expand state for descendants so that
// re-fetching a branch (e.g. after a drag-and-drop move) doesn't
// visually collapse folders the user had already opened.
expanded, err := a.expandedFolderIDs(ctx, u.ID)
if err != nil {
writeErr(w, http.StatusInternalServerError, err.Error())
return
}
nodes, err := a.buildTree(ctx, parentID, expanded)
if err != nil {
writeErr(w, http.StatusInternalServerError, err.Error())
return
}
a.RenderFragment(w, http.StatusOK, "tree_children", nodes)
}
func (a *App) handleTreeCollapse(w http.ResponseWriter, r *http.Request) {
u := currentUser(r)
if err := r.ParseForm(); err != nil {
writeErr(w, http.StatusBadRequest, "invalid form")
return
}
folderID := queryInt64(r, "folder_id")
if folderID == 0 {
writeErr(w, http.StatusBadRequest, "folder_id is required")
return
}
if err := a.persistCollapse(r.Context(), u.ID, folderID); err != nil {
writeErr(w, http.StatusInternalServerError, err.Error())
return
}
w.WriteHeader(http.StatusNoContent)
}
func (a *App) handleTreeCollapseAll(w http.ResponseWriter, r *http.Request) {
u := currentUser(r)
if err := a.persistCollapseAll(r.Context(), u.ID); err != nil {
writeErr(w, http.StatusInternalServerError, err.Error())
return
}
w.WriteHeader(http.StatusNoContent)
}
// RootPanelData backs the main panel when nothing is selected: the virtual
// "Inventory" root, showing top-level folders, unfiled assets, and the
// small dashboard.
type RootPanelData struct {
Folders []models.Folder
UnfiledAssets []models.Asset
UnfiledTotal int
Stats *assets.DashboardStats
}
func (a *App) rootPanelData(ctx context.Context) (*RootPanelData, error) {
folderList, err := a.Folders.Children(ctx, nil)
if err != nil {
return nil, err
}
unfiled, total, err := a.Assets.List(ctx, assets.ListFilter{FolderSet: true, FolderID: nil, Page: 1, PageSize: 50})
if err != nil {
return nil, err
}
stats, err := a.Assets.Stats(ctx)
if err != nil {
return nil, err
}
return &RootPanelData{Folders: folderList, UnfiledAssets: unfiled, UnfiledTotal: total, Stats: stats}, nil
}
func (a *App) handlePanelRoot(w http.ResponseWriter, r *http.Request) {
data, err := a.rootPanelData(r.Context())
if err != nil {
writeErr(w, http.StatusInternalServerError, err.Error())
return
}
a.RenderFragment(w, http.StatusOK, "panel_root", data)
}
// FolderPanelData backs the main panel when a folder is selected.
type FolderPanelData struct {
Folder *models.Folder
Breadcrumb []models.BreadcrumbItem
Subfolders []models.Folder
Assets []models.Asset
Total int
Page int
PageSize int
ItemTypes []models.ItemType
Statuses []models.Status
FilterType int64
FilterStat int64
Query string
}
func (a *App) handlePanelFolder(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
id, err := pathID(r, "id")
if err != nil {
writeErr(w, http.StatusBadRequest, "invalid folder id")
return
}
folder, err := a.Folders.Get(ctx, id)
if err != nil {
writeErr(w, http.StatusNotFound, err.Error())
return
}
breadcrumb, err := a.Folders.Path(ctx, id)
if err != nil {
writeErr(w, http.StatusInternalServerError, err.Error())
return
}
subfolders, err := a.Folders.Children(ctx, &id)
if err != nil {
writeErr(w, http.StatusInternalServerError, err.Error())
return
}
page := queryInt(r, "page", 1)
filterType := queryInt64(r, "item_type_id")
filterStatus := queryInt64(r, "status_id")
query := r.URL.Query().Get("q")
assetList, total, err := a.Assets.List(ctx, assets.ListFilter{
FolderID: &id, FolderSet: true, ItemTypeID: filterType, StatusID: filterStatus,
Query: query, Page: page, PageSize: 50,
})
if err != nil {
writeErr(w, http.StatusInternalServerError, err.Error())
return
}
itemTypes, err := a.ItemTypes.List(ctx, false)
if err != nil {
writeErr(w, http.StatusInternalServerError, err.Error())
return
}
statusList, err := a.Statuses.List(ctx)
if err != nil {
writeErr(w, http.StatusInternalServerError, err.Error())
return
}
a.RenderFragment(w, http.StatusOK, "panel_folder", FolderPanelData{
Folder: folder, Breadcrumb: breadcrumb, Subfolders: subfolders,
Assets: assetList, Total: total, Page: page, PageSize: 50,
ItemTypes: itemTypes, Statuses: statusList,
FilterType: filterType, FilterStat: filterStatus, Query: query,
})
}
// AssetPanelData backs the main panel when an asset is selected (view mode).
type AssetPanelData struct {
Asset *models.Asset
CanEdit bool
FolderOptions []FolderOption
}
func (a *App) handlePanelAsset(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
id, err := pathID(r, "id")
if err != nil {
writeErr(w, http.StatusBadRequest, "invalid asset id")
return
}
asset, err := a.Assets.Get(ctx, id)
if err != nil {
writeErr(w, http.StatusNotFound, err.Error())
return
}
opts, err := a.folderOptions(ctx)
if err != nil {
writeErr(w, http.StatusInternalServerError, err.Error())
return
}
a.RenderFragment(w, http.StatusOK, "panel_asset", AssetPanelData{Asset: asset, CanEdit: currentUser(r).IsAdmin(), FolderOptions: opts})
}
// AssetFormData backs both the "new asset" and "edit asset" forms.
type AssetFormData struct {
Asset *models.Asset // nil when creating
FolderID *int64
ItemTypes []models.ItemType
Statuses []models.Status
Users []models.User
FolderOptions []FolderOption
SelectedIT int64
Fields []models.AssetFieldValue
}
func (a *App) handlePanelAssetNew(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
folderID, err := queryOptionalID(r, "folder_id")
if err != nil {
writeErr(w, http.StatusBadRequest, "invalid folder_id")
return
}
itemTypes, err := a.ItemTypes.List(ctx, false)
if err != nil {
writeErr(w, http.StatusInternalServerError, err.Error())
return
}
statusList, err := a.Statuses.List(ctx)
if err != nil {
writeErr(w, http.StatusInternalServerError, err.Error())
return
}
userList, err := a.Users.List(ctx)
if err != nil {
writeErr(w, http.StatusInternalServerError, err.Error())
return
}
folderOpts, err := a.folderOptions(ctx)
if err != nil {
writeErr(w, http.StatusInternalServerError, err.Error())
return
}
var selectedIT int64
var fields []models.AssetFieldValue
if len(itemTypes) > 0 {
selectedIT = itemTypes[0].ID
fields = fieldValuesForCreate(itemTypes[0].Fields)
}
a.RenderFragment(w, http.StatusOK, "asset_form", AssetFormData{
FolderID: folderID, ItemTypes: itemTypes, Statuses: statusList, Users: userList, FolderOptions: folderOpts,
SelectedIT: selectedIT, Fields: fields,
})
}
func (a *App) handlePanelAssetEdit(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
id, err := pathID(r, "id")
if err != nil {
writeErr(w, http.StatusBadRequest, "invalid asset id")
return
}
asset, err := a.Assets.Get(ctx, id)
if err != nil {
writeErr(w, http.StatusNotFound, err.Error())
return
}
itemTypes, err := a.ItemTypes.List(ctx, true)
if err != nil {
writeErr(w, http.StatusInternalServerError, err.Error())
return
}
statusList, err := a.Statuses.List(ctx)
if err != nil {
writeErr(w, http.StatusInternalServerError, err.Error())
return
}
userList, err := a.Users.List(ctx)
if err != nil {
writeErr(w, http.StatusInternalServerError, err.Error())
return
}
folderOpts, err := a.folderOptions(ctx)
if err != nil {
writeErr(w, http.StatusInternalServerError, err.Error())
return
}
a.RenderFragment(w, http.StatusOK, "asset_form", AssetFormData{
Asset: asset, ItemTypes: itemTypes, Statuses: statusList, Users: userList, FolderOptions: folderOpts,
SelectedIT: asset.ItemTypeID, Fields: asset.CustomFields,
})
}
// handleAssetFieldsFragment renders just the dynamic custom-field inputs for
// a chosen item type, used when the item type dropdown changes on the
// "new asset" form.
func (a *App) handleAssetFieldsFragment(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
itemTypeID := queryInt64(r, "item_type_id")
it, err := a.ItemTypes.Get(ctx, itemTypeID)
if err != nil {
writeErr(w, http.StatusNotFound, err.Error())
return
}
a.RenderFragment(w, http.StatusOK, "asset_fields_only", fieldValuesForCreate(it.Fields))
}
+202
View File
@@ -0,0 +1,202 @@
package web
import (
"net/http"
"strconv"
"strings"
"inventree/internal/models"
)
type itemTypesListData struct {
User *models.User
ItemTypes []models.ItemType
}
func (a *App) handleItemTypesList(w http.ResponseWriter, r *http.Request) {
types, err := a.ItemTypes.List(r.Context(), true)
if err != nil {
writeErr(w, http.StatusInternalServerError, err.Error())
return
}
a.Render(w, http.StatusOK, "itemtypes_list", itemTypesListData{User: currentUser(r), ItemTypes: types})
}
type itemTypeFormData struct {
User *models.User
ItemType *models.ItemType
FieldTypes []models.FieldType
Error string
}
func (a *App) handleItemTypeNewForm(w http.ResponseWriter, r *http.Request) {
a.Render(w, http.StatusOK, "itemtypes_form", itemTypeFormData{User: currentUser(r), FieldTypes: models.AllFieldTypes})
}
func (a *App) handleItemTypeCreate(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
writeErr(w, http.StatusBadRequest, err.Error())
return
}
it := &models.ItemType{
Name: r.FormValue("name"), Description: r.FormValue("description"),
Icon: r.FormValue("icon"), Enabled: r.FormValue("enabled") == "on", SortOrder: atoiDefault(r.FormValue("sort_order"), 0),
}
if err := a.ItemTypes.Create(r.Context(), it); err != nil {
a.Render(w, http.StatusBadRequest, "itemtypes_form", itemTypeFormData{User: currentUser(r), ItemType: it, FieldTypes: models.AllFieldTypes, Error: err.Error()})
return
}
http.Redirect(w, r, "/item-types/"+strconv.FormatInt(it.ID, 10)+"/edit", http.StatusSeeOther)
}
func (a *App) handleItemTypeEditForm(w http.ResponseWriter, r *http.Request) {
id, err := pathID(r, "id")
if err != nil {
writeErr(w, http.StatusBadRequest, err.Error())
return
}
it, err := a.ItemTypes.Get(r.Context(), id)
if err != nil {
writeErr(w, http.StatusNotFound, err.Error())
return
}
a.Render(w, http.StatusOK, "itemtypes_form", itemTypeFormData{User: currentUser(r), ItemType: it, FieldTypes: models.AllFieldTypes})
}
func (a *App) handleItemTypeUpdate(w http.ResponseWriter, r *http.Request) {
id, err := pathID(r, "id")
if err != nil {
writeErr(w, http.StatusBadRequest, err.Error())
return
}
if err := r.ParseForm(); err != nil {
writeErr(w, http.StatusBadRequest, err.Error())
return
}
it, err := a.ItemTypes.Get(r.Context(), id)
if err != nil {
writeErr(w, http.StatusNotFound, err.Error())
return
}
it.Name = r.FormValue("name")
it.Description = r.FormValue("description")
it.Icon = r.FormValue("icon")
it.Enabled = r.FormValue("enabled") == "on"
it.SortOrder = atoiDefault(r.FormValue("sort_order"), it.SortOrder)
if err := a.ItemTypes.Update(r.Context(), it); err != nil {
a.Render(w, http.StatusBadRequest, "itemtypes_form", itemTypeFormData{User: currentUser(r), ItemType: it, FieldTypes: models.AllFieldTypes, Error: err.Error()})
return
}
http.Redirect(w, r, "/item-types/"+strconv.FormatInt(id, 10)+"/edit", http.StatusSeeOther)
}
func (a *App) handleItemTypeDelete(w http.ResponseWriter, r *http.Request) {
id, err := pathID(r, "id")
if err != nil {
writeErr(w, http.StatusBadRequest, err.Error())
return
}
if err := a.ItemTypes.Delete(r.Context(), id); err != nil {
http.Redirect(w, r, "/item-types/"+strconv.FormatInt(id, 10)+"/edit?error="+err.Error(), http.StatusSeeOther)
return
}
http.Redirect(w, r, "/item-types", http.StatusSeeOther)
}
func (a *App) handleFieldCreate(w http.ResponseWriter, r *http.Request) {
id, err := pathID(r, "id")
if err != nil {
writeErr(w, http.StatusBadRequest, err.Error())
return
}
if err := r.ParseForm(); err != nil {
writeErr(w, http.StatusBadRequest, err.Error())
return
}
f := &models.CustomFieldDef{
ItemTypeID: id, Name: r.FormValue("name"), FieldType: models.FieldType(r.FormValue("field_type")),
Required: r.FormValue("required") == "on", DefaultValue: r.FormValue("default_value"),
SortOrder: atoiDefault(r.FormValue("sort_order"), 0),
}
if opts := r.FormValue("options"); opts != "" {
f.Options = splitLines(opts)
}
if err := a.ItemTypes.CreateField(r.Context(), f); err != nil {
a.Log.Warn("create field failed", "error", err)
}
http.Redirect(w, r, "/item-types/"+strconv.FormatInt(id, 10)+"/edit", http.StatusSeeOther)
}
func (a *App) handleFieldUpdate(w http.ResponseWriter, r *http.Request) {
itemTypeID, err := pathID(r, "id")
if err != nil {
writeErr(w, http.StatusBadRequest, err.Error())
return
}
fieldID, err := pathID(r, "fieldID")
if err != nil {
writeErr(w, http.StatusBadRequest, err.Error())
return
}
if err := r.ParseForm(); err != nil {
writeErr(w, http.StatusBadRequest, err.Error())
return
}
f, err := a.ItemTypes.GetField(r.Context(), fieldID)
if err != nil {
writeErr(w, http.StatusNotFound, err.Error())
return
}
f.Name = r.FormValue("name")
f.FieldType = models.FieldType(r.FormValue("field_type"))
f.Required = r.FormValue("required") == "on"
f.DefaultValue = r.FormValue("default_value")
f.SortOrder = atoiDefault(r.FormValue("sort_order"), f.SortOrder)
f.Options = nil
if opts := r.FormValue("options"); opts != "" {
f.Options = splitLines(opts)
}
if err := a.ItemTypes.UpdateField(r.Context(), f); err != nil {
a.Log.Warn("update field failed", "error", err)
}
http.Redirect(w, r, "/item-types/"+strconv.FormatInt(itemTypeID, 10)+"/edit", http.StatusSeeOther)
}
func (a *App) handleFieldDelete(w http.ResponseWriter, r *http.Request) {
itemTypeID, err := pathID(r, "id")
if err != nil {
writeErr(w, http.StatusBadRequest, err.Error())
return
}
fieldID, err := pathID(r, "fieldID")
if err != nil {
writeErr(w, http.StatusBadRequest, err.Error())
return
}
if err := a.ItemTypes.DeleteField(r.Context(), fieldID); err != nil {
a.Log.Warn("delete field failed", "error", err)
}
http.Redirect(w, r, "/item-types/"+strconv.FormatInt(itemTypeID, 10)+"/edit", http.StatusSeeOther)
}
func atoiDefault(s string, def int) int {
if s == "" {
return def
}
n, err := strconv.Atoi(s)
if err != nil {
return def
}
return n
}
func splitLines(s string) []string {
var out []string
for _, line := range strings.Split(strings.ReplaceAll(s, "\r\n", "\n"), "\n") {
line = strings.TrimSpace(line)
if line != "" {
out = append(out, line)
}
}
return out
}
+22
View File
@@ -0,0 +1,22 @@
package web
import (
"net/http"
"inventree/internal/models"
)
type SearchPanelData struct {
Query string
Results []models.Asset
}
func (a *App) handleSearch(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query().Get("q")
results, err := a.Assets.Search(r.Context(), q, 50)
if err != nil {
writeErr(w, http.StatusInternalServerError, err.Error())
return
}
a.RenderFragment(w, http.StatusOK, "panel_search", SearchPanelData{Query: q, Results: results})
}
+156
View File
@@ -0,0 +1,156 @@
package web
import (
"net/http"
"strconv"
"inventree/internal/models"
)
type settingsPageData struct {
User *models.User
Statuses []models.Status
SiteName string
AssetPrefix string
AssetPadding string
Error string
}
func (a *App) settingsData(r *http.Request) (settingsPageData, error) {
ctx := r.Context()
statusList, err := a.Statuses.List(ctx)
if err != nil {
return settingsPageData{}, err
}
siteName, err := a.Settings.Get(ctx, "site_name", "inventree")
if err != nil {
return settingsPageData{}, err
}
prefix, err := a.Settings.Get(ctx, "asset_id_prefix", "IT-")
if err != nil {
return settingsPageData{}, err
}
padding, err := a.Settings.Get(ctx, "asset_id_padding", "6")
if err != nil {
return settingsPageData{}, err
}
return settingsPageData{
User: currentUser(r), Statuses: statusList, SiteName: siteName, AssetPrefix: prefix, AssetPadding: padding,
}, nil
}
func (a *App) handleSettingsPage(w http.ResponseWriter, r *http.Request) {
data, err := a.settingsData(r)
if err != nil {
writeErr(w, http.StatusInternalServerError, err.Error())
return
}
a.Render(w, http.StatusOK, "settings", data)
}
func (a *App) handleSettingsGeneral(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
writeErr(w, http.StatusBadRequest, err.Error())
return
}
ctx := r.Context()
_ = a.Settings.Set(ctx, "site_name", r.FormValue("site_name"))
_ = a.Settings.Set(ctx, "asset_id_prefix", r.FormValue("asset_id_prefix"))
if _, err := strconv.Atoi(r.FormValue("asset_id_padding")); err == nil {
_ = a.Settings.Set(ctx, "asset_id_padding", r.FormValue("asset_id_padding"))
}
http.Redirect(w, r, "/settings", http.StatusSeeOther)
}
func (a *App) handleStatusCreate(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
writeErr(w, http.StatusBadRequest, err.Error())
return
}
st := &models.Status{Name: r.FormValue("name"), Color: r.FormValue("color")}
if err := a.Statuses.Create(r.Context(), st); err != nil {
a.Log.Warn("create status failed", "error", err)
}
http.Redirect(w, r, "/settings", http.StatusSeeOther)
}
func (a *App) handleStatusUpdate(w http.ResponseWriter, r *http.Request) {
id, err := pathID(r, "id")
if err != nil {
writeErr(w, http.StatusBadRequest, err.Error())
return
}
if err := r.ParseForm(); err != nil {
writeErr(w, http.StatusBadRequest, err.Error())
return
}
st, err := a.Statuses.Get(r.Context(), id)
if err != nil {
writeErr(w, http.StatusNotFound, err.Error())
return
}
st.Name = r.FormValue("name")
st.Color = r.FormValue("color")
if err := a.Statuses.Update(r.Context(), st); err != nil {
a.Log.Warn("update status failed", "error", err)
}
http.Redirect(w, r, "/settings", http.StatusSeeOther)
}
func (a *App) handleStatusDelete(w http.ResponseWriter, r *http.Request) {
id, err := pathID(r, "id")
if err != nil {
writeErr(w, http.StatusBadRequest, err.Error())
return
}
if err := a.Statuses.Delete(r.Context(), id); err != nil {
a.Log.Warn("delete status failed", "error", err)
}
http.Redirect(w, r, "/settings", http.StatusSeeOther)
}
func (a *App) handleStatusReorder(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
id, err := pathID(r, "id")
if err != nil {
writeErr(w, http.StatusBadRequest, err.Error())
return
}
if err := r.ParseForm(); err != nil {
writeErr(w, http.StatusBadRequest, err.Error())
return
}
dir := r.FormValue("dir")
list, err := a.Statuses.List(ctx)
if err != nil {
writeErr(w, http.StatusInternalServerError, err.Error())
return
}
idx := -1
for i, st := range list {
if st.ID == id {
idx = i
break
}
}
if idx >= 0 {
swapWith := -1
if dir == "up" && idx > 0 {
swapWith = idx - 1
} else if dir == "down" && idx < len(list)-1 {
swapWith = idx + 1
}
if swapWith >= 0 {
ids := make([]int64, len(list))
for i, st := range list {
ids[i] = st.ID
}
ids[idx], ids[swapWith] = ids[swapWith], ids[idx]
if err := a.Statuses.Reorder(ctx, ids); err != nil {
a.Log.Warn("reorder statuses failed", "error", err)
}
}
}
http.Redirect(w, r, "/settings", http.StatusSeeOther)
}
+127
View File
@@ -0,0 +1,127 @@
package web
import (
"net/http"
"strconv"
"inventree/internal/models"
)
type usersListData struct {
User *models.User
Users []models.User
}
func (a *App) handleUsersList(w http.ResponseWriter, r *http.Request) {
list, err := a.Users.List(r.Context())
if err != nil {
writeErr(w, http.StatusInternalServerError, err.Error())
return
}
a.Render(w, http.StatusOK, "users_list", usersListData{User: currentUser(r), Users: list})
}
type userFormData struct {
User *models.User
EditUser *models.User
Error string
}
func (a *App) handleUserNewForm(w http.ResponseWriter, r *http.Request) {
a.Render(w, http.StatusOK, "users_form", userFormData{User: currentUser(r)})
}
func (a *App) handleUserCreate(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
writeErr(w, http.StatusBadRequest, err.Error())
return
}
role := models.RoleUser
if r.FormValue("role") == "admin" {
role = models.RoleAdmin
}
u := &models.User{Username: r.FormValue("username"), Role: role, Enabled: r.FormValue("enabled") == "on"}
password := r.FormValue("password")
if password == "" {
a.Render(w, http.StatusBadRequest, "users_form", userFormData{User: currentUser(r), EditUser: u, Error: "Password is required."})
return
}
if err := a.Users.Create(r.Context(), u, password); err != nil {
a.Render(w, http.StatusBadRequest, "users_form", userFormData{User: currentUser(r), EditUser: u, Error: err.Error()})
return
}
http.Redirect(w, r, "/users", http.StatusSeeOther)
}
func (a *App) handleUserEditForm(w http.ResponseWriter, r *http.Request) {
id, err := pathID(r, "id")
if err != nil {
writeErr(w, http.StatusBadRequest, err.Error())
return
}
u, err := a.Users.Get(r.Context(), id)
if err != nil {
writeErr(w, http.StatusNotFound, err.Error())
return
}
a.Render(w, http.StatusOK, "users_form", userFormData{User: currentUser(r), EditUser: u})
}
func (a *App) handleUserUpdate(w http.ResponseWriter, r *http.Request) {
id, err := pathID(r, "id")
if err != nil {
writeErr(w, http.StatusBadRequest, err.Error())
return
}
if err := r.ParseForm(); err != nil {
writeErr(w, http.StatusBadRequest, err.Error())
return
}
u, err := a.Users.Get(r.Context(), id)
if err != nil {
writeErr(w, http.StatusNotFound, err.Error())
return
}
u.Username = r.FormValue("username")
u.Role = models.RoleUser
if r.FormValue("role") == "admin" {
u.Role = models.RoleAdmin
}
u.Enabled = r.FormValue("enabled") == "on"
if err := a.Users.Update(r.Context(), u); err != nil {
a.Render(w, http.StatusBadRequest, "users_form", userFormData{User: currentUser(r), EditUser: u, Error: err.Error()})
return
}
http.Redirect(w, r, "/users", http.StatusSeeOther)
}
func (a *App) handleUserPassword(w http.ResponseWriter, r *http.Request) {
id, err := pathID(r, "id")
if err != nil {
writeErr(w, http.StatusBadRequest, err.Error())
return
}
if err := r.ParseForm(); err != nil {
writeErr(w, http.StatusBadRequest, err.Error())
return
}
password := r.FormValue("password")
if password != "" {
if err := a.Users.SetPassword(r.Context(), id, password); err != nil {
a.Log.Warn("set password failed", "error", err)
}
}
http.Redirect(w, r, "/users/"+strconv.FormatInt(id, 10)+"/edit", http.StatusSeeOther)
}
func (a *App) handleUserDelete(w http.ResponseWriter, r *http.Request) {
id, err := pathID(r, "id")
if err != nil {
writeErr(w, http.StatusBadRequest, err.Error())
return
}
if err := a.Users.Delete(r.Context(), id); err != nil {
a.Log.Warn("delete user failed", "error", err)
}
http.Redirect(w, r, "/users", http.StatusSeeOther)
}
+74
View File
@@ -0,0 +1,74 @@
package web
import (
"encoding/json"
"net/http"
"strconv"
)
func pathID(r *http.Request, name string) (int64, error) {
return strconv.ParseInt(r.PathValue(name), 10, 64)
}
// queryOptionalID parses a query parameter as *int64; an empty or missing
// value returns nil (meaning "top level" / "unfiled" in folder/asset
// contexts).
func queryOptionalID(r *http.Request, name string) (*int64, error) {
v := r.URL.Query().Get(name)
if v == "" {
return nil, nil
}
id, err := strconv.ParseInt(v, 10, 64)
if err != nil {
return nil, err
}
return &id, nil
}
func queryInt64(r *http.Request, name string) int64 {
v := r.URL.Query().Get(name)
if v == "" {
return 0
}
id, _ := strconv.ParseInt(v, 10, 64)
return id
}
func queryInt(r *http.Request, name string, def int) int {
v := r.URL.Query().Get(name)
if v == "" {
return def
}
n, err := strconv.Atoi(v)
if err != nil {
return def
}
return n
}
type jsonEnvelope struct {
OK bool `json:"ok"`
Error string `json:"error,omitempty"`
Message string `json:"message,omitempty"`
Data any `json:"data,omitempty"`
}
func writeJSON(w http.ResponseWriter, status int, v jsonEnvelope) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(v)
}
func jsonOK(w http.ResponseWriter, data any) {
writeJSON(w, http.StatusOK, jsonEnvelope{OK: true, Data: data})
}
func jsonErr(w http.ResponseWriter, status int, err error) {
writeJSON(w, status, jsonEnvelope{OK: false, Error: err.Error()})
}
func readJSON(r *http.Request, v any) error {
dec := json.NewDecoder(r.Body)
dec.DisallowUnknownFields()
return dec.Decode(v)
}
+65
View File
@@ -0,0 +1,65 @@
package web
import (
"net/http"
"inventree/internal/auth"
"inventree/internal/models"
)
// loadSession attaches the current user (if any) to the request context
// based on the session cookie. It never blocks the request.
func (a *App) loadSession(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
cookie, err := r.Cookie(auth.CookieName)
if err != nil || cookie.Value == "" {
next.ServeHTTP(w, r)
return
}
u, err := a.Auth.UserForToken(r.Context(), cookie.Value)
if err != nil {
next.ServeHTTP(w, r)
return
}
r = r.WithContext(auth.WithUser(r.Context(), u))
next.ServeHTTP(w, r)
})
}
// requireLogin redirects unauthenticated page requests to /login, and
// returns 401 for AJAX/API requests instead.
func (a *App) requireLogin(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if _, ok := auth.UserFromContext(r.Context()); !ok {
if isAjax(r) {
writeErr(w, http.StatusUnauthorized, "not authenticated")
return
}
http.Redirect(w, r, "/login?next="+r.URL.Path, http.StatusSeeOther)
return
}
next(w, r)
}
}
// requireAdmin additionally requires the current user to hold the admin
// role.
func (a *App) requireAdmin(next http.HandlerFunc) http.HandlerFunc {
return a.requireLogin(func(w http.ResponseWriter, r *http.Request) {
u, _ := auth.UserFromContext(r.Context())
if !u.IsAdmin() {
writeErr(w, http.StatusForbidden, "admin access required")
return
}
next(w, r)
})
}
func isAjax(r *http.Request) bool {
return r.Header.Get("X-Requested-With") == "XMLHttpRequest" || r.Header.Get("HX-Request") != ""
}
func currentUser(r *http.Request) *models.User {
u, _ := auth.UserFromContext(r.Context())
return u
}
+180
View File
@@ -0,0 +1,180 @@
package web
import (
"fmt"
"html/template"
"io/fs"
"net/http"
"path"
"strings"
"time"
content "inventree/web"
)
// templateFuncs are available to every template (pages and fragments).
var templateFuncs = template.FuncMap{
"dict": func(pairs ...any) (map[string]any, error) {
if len(pairs)%2 != 0 {
return nil, fmt.Errorf("dict: odd number of arguments")
}
m := make(map[string]any, len(pairs)/2)
for i := 0; i < len(pairs); i += 2 {
key, ok := pairs[i].(string)
if !ok {
return nil, fmt.Errorf("dict: key %v is not a string", pairs[i])
}
m[key] = pairs[i+1]
}
return m, nil
},
"add": func(a, b int) int { return a + b },
"sub": func(a, b int) int { return a - b },
"mul": func(a, b int) int { return a * b },
"ceilDiv": func(a, b int) int {
if b <= 0 {
return 0
}
return (a + b - 1) / b
},
"seq": func(n int) []int {
out := make([]int, n)
for i := range out {
out[i] = i
}
return out
},
"seqFrom1": func(n int) []int {
out := make([]int, n)
for i := range out {
out[i] = i + 1
}
return out
},
"fmtDate": func(t time.Time) string {
if t.IsZero() {
return ""
}
return t.Format("2006-01-02 15:04")
},
"fmtDateShort": func(t time.Time) string {
if t.IsZero() {
return ""
}
return t.Format("Jan 2, 2006")
},
"iconOr": func(icon, fallback string) string {
if icon == "" {
return fallback
}
return icon
},
"hasPrefix": strings.HasPrefix,
"lower": strings.ToLower,
"list": func(items ...string) []string { return items },
"deref": func(p *int64) int64 {
if p == nil {
return 0
}
return *p
},
}
// templateSet bundles the shared fragment/partial template collection with
// one isolated template set per full page (see loadTemplates for why pages
// must be isolated from one another).
type templateSet struct {
shared *template.Template // layout.html + partials/*.html; used to render AJAX fragments directly
pages map[string]*template.Template // page name -> shared clone + that page's templates/pages/<name>.html
login *template.Template // standalone minimal layout for the login screen
}
// loadTemplates parses layout.html and every partial once into a shared
// template set, then clones that set once per page in templates/pages/ so
// each page can define a block named "content" without colliding with the
// others (html/template templates sharing one *template.Template must have
// unique names, but isolated clones may reuse the same name freely).
func loadTemplates() (*templateSet, error) {
shared := template.New("root").Funcs(templateFuncs)
shared, err := shared.ParseFS(content.TemplatesFS, "templates/layout.html")
if err != nil {
return nil, fmt.Errorf("parse layout: %w", err)
}
shared, err = parseGlob(shared, "templates/partials")
if err != nil {
return nil, fmt.Errorf("parse partials: %w", err)
}
pageFiles, err := fs.Glob(content.TemplatesFS, "templates/pages/*.html")
if err != nil {
return nil, err
}
pages := map[string]*template.Template{}
for _, pf := range pageFiles {
name := strings.TrimSuffix(path.Base(pf), ".html")
clone, err := shared.Clone()
if err != nil {
return nil, fmt.Errorf("clone template set for %s: %w", name, err)
}
if _, err := clone.ParseFS(content.TemplatesFS, pf); err != nil {
return nil, fmt.Errorf("parse page %s: %w", pf, err)
}
pages[name] = clone
}
login := template.New("login-root").Funcs(templateFuncs)
login, err = login.ParseFS(content.TemplatesFS, "templates/login.html")
if err != nil {
return nil, fmt.Errorf("parse login: %w", err)
}
return &templateSet{shared: shared, pages: pages, login: login}, nil
}
func parseGlob(t *template.Template, dir string) (*template.Template, error) {
files, err := fs.Glob(content.TemplatesFS, dir+"/*.html")
if err != nil {
return nil, err
}
if len(files) == 0 {
return t, nil
}
return t.ParseFS(content.TemplatesFS, files...)
}
// Render executes the named page's "layout" template (which pulls in that
// page's "content" block) into the response. Page "login" uses its own
// minimal "login" template instead.
func (a *App) Render(w http.ResponseWriter, status int, page string, data any) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if page == "login" {
w.WriteHeader(status)
if err := a.templates.login.ExecuteTemplate(w, "login", data); err != nil {
a.Log.Error("template render failed", "page", page, "error", err)
}
return
}
ts, ok := a.templates.pages[page]
if !ok {
writeErr(w, http.StatusInternalServerError, "template not found: "+page)
return
}
w.WriteHeader(status)
if err := ts.ExecuteTemplate(w, "layout", data); err != nil {
a.Log.Error("template render failed", "page", page, "error", err)
}
}
// RenderFragment executes a single named partial (defined under
// templates/partials/) with no layout wrapper, for AJAX responses.
func (a *App) RenderFragment(w http.ResponseWriter, status int, name string, data any) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(status)
if err := a.templates.shared.ExecuteTemplate(w, name, data); err != nil {
a.Log.Error("fragment render failed", "name", name, "error", err)
}
}
+104
View File
@@ -0,0 +1,104 @@
package web
import (
"net/http"
content "inventree/web"
)
// Router builds the complete HTTP handler for inventree: static assets,
// authentication, the AJAX-driven inventory tree, and the traditional
// full-page admin sections (item types, users, settings).
func (a *App) Router() http.Handler {
mux := http.NewServeMux()
// Static assets (Bootstrap, icons, vanilla JS) and health check are
// always public.
mux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServerFS(mustSub(content.StaticFS, "static"))))
mux.HandleFunc("GET /healthz", a.handleHealthz)
// Auth
mux.HandleFunc("GET /login", a.handleLoginForm)
mux.HandleFunc("POST /login", a.handleLoginSubmit)
mux.HandleFunc("POST /logout", a.handleLogout)
mux.HandleFunc("GET /{$}", a.requireLogin(a.handleRoot))
// Inventory tree (AJAX fragments)
mux.HandleFunc("GET /inventory", a.requireLogin(a.handleInventoryPage))
mux.HandleFunc("GET /app/tree/children", a.requireLogin(a.handleTreeChildren))
mux.HandleFunc("POST /app/tree/collapse", a.requireLogin(a.handleTreeCollapse))
mux.HandleFunc("POST /app/tree/collapse-all", a.requireLogin(a.handleTreeCollapseAll))
mux.HandleFunc("GET /app/panel/root", a.requireLogin(a.handlePanelRoot))
mux.HandleFunc("GET /app/panel/folders/{id}", a.requireLogin(a.handlePanelFolder))
mux.HandleFunc("GET /app/panel/assets/{id}", a.requireLogin(a.handlePanelAsset))
mux.HandleFunc("GET /app/panel/assets/new", a.requireAdmin(a.handlePanelAssetNew))
mux.HandleFunc("GET /app/panel/assets/{id}/edit", a.requireAdmin(a.handlePanelAssetEdit))
mux.HandleFunc("GET /app/forms/asset-fields", a.requireLogin(a.handleAssetFieldsFragment))
mux.HandleFunc("GET /app/search", a.requireLogin(a.handleSearch))
// Folder mutations (JSON in/out)
mux.HandleFunc("GET /app/folders/{id}", a.requireAdmin(a.handleFolderGet))
mux.HandleFunc("POST /app/folders", a.requireAdmin(a.handleFolderCreate))
mux.HandleFunc("PATCH /app/folders/{id}", a.requireAdmin(a.handleFolderUpdate))
mux.HandleFunc("POST /app/folders/{id}/move", a.requireAdmin(a.handleFolderMove))
mux.HandleFunc("GET /app/folders/{id}/delete-info", a.requireAdmin(a.handleFolderDeleteInfo))
mux.HandleFunc("DELETE /app/folders/{id}", a.requireAdmin(a.handleFolderDelete))
// Asset mutations (JSON in/out)
mux.HandleFunc("POST /app/assets", a.requireAdmin(a.handleAssetCreate))
mux.HandleFunc("PATCH /app/assets/{id}", a.requireAdmin(a.handleAssetUpdate))
mux.HandleFunc("DELETE /app/assets/{id}", a.requireAdmin(a.handleAssetDelete))
mux.HandleFunc("POST /app/assets/{id}/duplicate", a.requireAdmin(a.handleAssetDuplicate))
mux.HandleFunc("POST /app/assets/{id}/move", a.requireAdmin(a.handleAssetMove))
// CSV import/export
mux.HandleFunc("GET /export/assets.csv", a.requireLogin(a.handleExportCSV))
mux.HandleFunc("POST /import/assets.csv", a.requireAdmin(a.handleImportCSV))
// Item types (admin, traditional pages)
mux.HandleFunc("GET /item-types", a.requireAdmin(a.handleItemTypesList))
mux.HandleFunc("GET /item-types/new", a.requireAdmin(a.handleItemTypeNewForm))
mux.HandleFunc("POST /item-types", a.requireAdmin(a.handleItemTypeCreate))
mux.HandleFunc("GET /item-types/{id}/edit", a.requireAdmin(a.handleItemTypeEditForm))
mux.HandleFunc("POST /item-types/{id}", a.requireAdmin(a.handleItemTypeUpdate))
mux.HandleFunc("POST /item-types/{id}/delete", a.requireAdmin(a.handleItemTypeDelete))
mux.HandleFunc("POST /item-types/{id}/fields", a.requireAdmin(a.handleFieldCreate))
mux.HandleFunc("POST /item-types/{id}/fields/{fieldID}", a.requireAdmin(a.handleFieldUpdate))
mux.HandleFunc("POST /item-types/{id}/fields/{fieldID}/delete", a.requireAdmin(a.handleFieldDelete))
// Users (admin, traditional pages)
mux.HandleFunc("GET /users", a.requireAdmin(a.handleUsersList))
mux.HandleFunc("GET /users/new", a.requireAdmin(a.handleUserNewForm))
mux.HandleFunc("POST /users", a.requireAdmin(a.handleUserCreate))
mux.HandleFunc("GET /users/{id}/edit", a.requireAdmin(a.handleUserEditForm))
mux.HandleFunc("POST /users/{id}", a.requireAdmin(a.handleUserUpdate))
mux.HandleFunc("POST /users/{id}/password", a.requireAdmin(a.handleUserPassword))
mux.HandleFunc("POST /users/{id}/delete", a.requireAdmin(a.handleUserDelete))
// Settings (admin, traditional page)
mux.HandleFunc("GET /settings", a.requireAdmin(a.handleSettingsPage))
mux.HandleFunc("POST /settings/general", a.requireAdmin(a.handleSettingsGeneral))
mux.HandleFunc("POST /settings/statuses", a.requireAdmin(a.handleStatusCreate))
mux.HandleFunc("POST /settings/statuses/{id}", a.requireAdmin(a.handleStatusUpdate))
mux.HandleFunc("POST /settings/statuses/{id}/delete", a.requireAdmin(a.handleStatusDelete))
mux.HandleFunc("POST /settings/statuses/{id}/reorder", a.requireAdmin(a.handleStatusReorder))
return a.loadSession(mux)
}
func (a *App) handleHealthz(w http.ResponseWriter, r *http.Request) {
if err := a.DB.PingContext(r.Context()); err != nil {
writeErr(w, http.StatusServiceUnavailable, "database unavailable")
return
}
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("ok"))
}
func (a *App) handleRoot(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/inventory", http.StatusSeeOther)
}
+14
View File
@@ -0,0 +1,14 @@
package web
import "io/fs"
// mustSub returns an FS rooted at dir within root, panicking on error (dir
// is always a compile-time-known embedded path, so this can only fail if the
// embed itself is misconfigured).
func mustSub(root fs.FS, dir string) fs.FS {
sub, err := fs.Sub(root, dir)
if err != nil {
panic(err)
}
return sub
}
+116
View File
@@ -0,0 +1,116 @@
package web
import (
"context"
"fmt"
"inventree/internal/assets"
"inventree/internal/models"
)
// treeAssetLimit bounds how many assets are rendered directly inside a
// single tree node before showing a "N more" placeholder, so a folder with
// thousands of assets doesn't blow up the tree fragment.
const treeAssetLimit = 300
// expandedFolderIDs returns the set of folder IDs the given user has
// persisted as expanded.
func (a *App) expandedFolderIDs(ctx context.Context, userID int64) (map[int64]bool, error) {
rows, err := a.DB.QueryContext(ctx, `SELECT folder_id FROM user_tree_state WHERE user_id = ?`, userID)
if err != nil {
return nil, err
}
defer rows.Close()
out := map[int64]bool{}
for rows.Next() {
var id int64
if err := rows.Scan(&id); err != nil {
return nil, err
}
out[id] = true
}
return out, rows.Err()
}
func (a *App) persistExpand(ctx context.Context, userID, folderID int64) error {
_, err := a.DB.ExecContext(ctx,
`INSERT INTO user_tree_state (user_id, folder_id) VALUES (?, ?) ON CONFLICT(user_id, folder_id) DO NOTHING`,
userID, folderID)
return err
}
func (a *App) persistCollapse(ctx context.Context, userID, folderID int64) error {
_, err := a.DB.ExecContext(ctx, `DELETE FROM user_tree_state WHERE user_id = ? AND folder_id = ?`, userID, folderID)
return err
}
func (a *App) persistCollapseAll(ctx context.Context, userID int64) error {
_, err := a.DB.ExecContext(ctx, `DELETE FROM user_tree_state WHERE user_id = ?`, userID)
return err
}
// buildTree returns the ordered list of tree nodes (subfolders then assets)
// directly under parentID. Folders present in the expanded set are
// recursively expanded inline; others are returned collapsed for the client
// to lazy-load on demand.
func (a *App) buildTree(ctx context.Context, parentID *int64, expanded map[int64]bool) ([]models.TreeNode, error) {
childFolders, err := a.Folders.Children(ctx, parentID)
if err != nil {
return nil, err
}
var out []models.TreeNode
for _, f := range childFolders {
node := models.TreeNode{
Type: "folder",
ID: f.ID,
Name: f.Name,
Icon: f.Icon,
ParentID: f.ParentID,
HasChildren: f.FolderCount > 0 || f.AssetCount > 0,
}
if expanded[f.ID] {
node.Expanded = true
children, err := a.buildTree(ctx, &f.ID, expanded)
if err != nil {
return nil, err
}
node.Children = children
}
out = append(out, node)
}
assetList, total, err := a.Assets.List(ctx, assets.ListFilter{
FolderID: parentID, FolderSet: true, Page: 1, PageSize: treeAssetLimit,
})
if err != nil {
return nil, err
}
for _, as := range assetList {
out = append(out, models.TreeNode{
Type: "asset",
ID: as.ID,
Name: as.Name,
Icon: as.ItemTypeIcon,
AssetTag: as.AssetTag,
StatusName: as.StatusName,
StatusColor: as.StatusColor,
})
}
if total > len(assetList) {
out = append(out, models.TreeNode{
Type: "more",
Name: fmt.Sprintf("%d more asset(s) — open this folder to view all", total-len(assetList)),
})
}
return out, nil
}
func fieldValuesForCreate(fields []models.CustomFieldDef) []models.AssetFieldValue {
out := make([]models.AssetFieldValue, len(fields))
for i, f := range fields {
out[i] = models.AssetFieldValue{Def: f, Value: f.DefaultValue}
}
return out
}
+216
View File
@@ -0,0 +1,216 @@
package web_test
import (
"bytes"
"encoding/json"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"path/filepath"
"strings"
"testing"
"inventree/internal/database"
"inventree/internal/models"
"inventree/internal/web"
)
func testApp(t *testing.T) *web.App {
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() })
log := slog.New(slog.NewTextHandler(io.Discard, nil))
app, err := web.NewApp(db, log, false)
if err != nil {
t.Fatalf("new app: %v", err)
}
return app
}
// loginAs signs in and returns the session cookie for subsequent requests.
func loginAs(t *testing.T, srv *httptest.Server, username, password string) *http.Cookie {
t.Helper()
form := "username=" + username + "&password=" + password
req, _ := http.NewRequest(http.MethodPost, srv.URL+"/login", strings.NewReader(form))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
client := &http.Client{CheckRedirect: func(req *http.Request, via []*http.Request) error { return http.ErrUseLastResponse }}
res, err := client.Do(req)
if err != nil {
t.Fatalf("login request: %v", err)
}
defer res.Body.Close()
if res.StatusCode != http.StatusSeeOther {
t.Fatalf("expected redirect on login, got %d", res.StatusCode)
}
for _, c := range res.Cookies() {
if c.Name == "inventree_session" {
return c
}
}
t.Fatal("no session cookie set")
return nil
}
func TestHealthz(t *testing.T) {
app := testApp(t)
srv := httptest.NewServer(app.Router())
defer srv.Close()
res, err := http.Get(srv.URL + "/healthz")
if err != nil {
t.Fatalf("get: %v", err)
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
t.Fatalf("expected 200, got %d", res.StatusCode)
}
}
func TestLoginAndInventoryPage(t *testing.T) {
app := testApp(t)
srv := httptest.NewServer(app.Router())
defer srv.Close()
cookie := loginAs(t, srv, "admin", "admin")
req, _ := http.NewRequest(http.MethodGet, srv.URL+"/inventory", nil)
req.AddCookie(cookie)
res, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("get inventory: %v", err)
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
t.Fatalf("expected 200, got %d", res.StatusCode)
}
body, _ := io.ReadAll(res.Body)
if !bytes.Contains(body, []byte("Inventory")) {
t.Fatalf("expected inventory page content")
}
}
func TestUnauthenticatedRedirectsToLogin(t *testing.T) {
app := testApp(t)
srv := httptest.NewServer(app.Router())
defer srv.Close()
client := &http.Client{CheckRedirect: func(req *http.Request, via []*http.Request) error { return http.ErrUseLastResponse }}
res, err := client.Get(srv.URL + "/inventory")
if err != nil {
t.Fatalf("get: %v", err)
}
defer res.Body.Close()
if res.StatusCode != http.StatusSeeOther {
t.Fatalf("expected redirect to login, got %d", res.StatusCode)
}
}
func TestFolderCreateRequiresAdmin(t *testing.T) {
app := testApp(t)
srv := httptest.NewServer(app.Router())
defer srv.Close()
// Create a normal (non-admin) user directly via the store.
normalUser := &models.User{Username: "normie", Role: models.RoleUser, Enabled: true}
if err := app.Users.Create(t.Context(), normalUser, "normiepass"); err != nil {
t.Fatalf("create user: %v", err)
}
cookie := loginAs(t, srv, "normie", "normiepass")
payload := `{"name":"Should Fail"}`
req, _ := http.NewRequest(http.MethodPost, srv.URL+"/app/folders", strings.NewReader(payload))
req.Header.Set("Content-Type", "application/json")
req.AddCookie(cookie)
res, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("post folder: %v", err)
}
defer res.Body.Close()
if res.StatusCode != http.StatusForbidden {
t.Fatalf("expected 403 for non-admin folder create, got %d", res.StatusCode)
}
}
func TestFolderAndAssetLifecycleAsAdmin(t *testing.T) {
app := testApp(t)
srv := httptest.NewServer(app.Router())
defer srv.Close()
cookie := loginAs(t, srv, "admin", "admin")
folderID := postJSON(t, srv, cookie, "/app/folders", `{"name":"Main Office"}`)["ID"].(float64)
assetPayload := `{"name":"Core Switch","item_type_id":4,"folder_id":` + itoa(int64(folderID)) + `,"status_id":1,"fields":{}}`
req, _ := http.NewRequest(http.MethodPost, srv.URL+"/app/assets", strings.NewReader(assetPayload))
req.Header.Set("Content-Type", "application/json")
req.AddCookie(cookie)
res, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("post asset: %v", err)
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
body, _ := io.ReadAll(res.Body)
t.Fatalf("expected 200 creating asset, got %d: %s", res.StatusCode, body)
}
var envelope struct {
Data struct {
AssetID string `json:"asset_id"`
} `json:"data"`
}
if err := json.NewDecoder(res.Body).Decode(&envelope); err != nil {
t.Fatalf("decode: %v", err)
}
if envelope.Data.AssetID != "IT-000001" {
t.Fatalf("expected auto-generated IT-000001, got %s", envelope.Data.AssetID)
}
}
func postJSON(t *testing.T, srv *httptest.Server, cookie *http.Cookie, path, payload string) map[string]any {
t.Helper()
req, _ := http.NewRequest(http.MethodPost, srv.URL+path, strings.NewReader(payload))
req.Header.Set("Content-Type", "application/json")
req.AddCookie(cookie)
res, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("post %s: %v", path, err)
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
body, _ := io.ReadAll(res.Body)
t.Fatalf("expected 200 for %s, got %d: %s", path, res.StatusCode, body)
}
var envelope struct {
Data map[string]any `json:"data"`
}
if err := json.NewDecoder(res.Body).Decode(&envelope); err != nil {
t.Fatalf("decode: %v", err)
}
return envelope.Data
}
func itoa(n int64) string {
if n == 0 {
return "0"
}
neg := n < 0
if neg {
n = -n
}
var b []byte
for n > 0 {
b = append([]byte{byte('0' + n%10)}, b...)
n /= 10
}
if neg {
b = append([]byte{'-'}, b...)
}
return string(b)
}