Initial implementation of inventree
A small IT asset inventory system built around a nested folder tree, matching the project brief: Go + html/template + Bootstrap 5 + vanilla JS, SQLite storage (WAL, FKs, migrations), embedded templates/static assets, single compiled binary. - AJAX-driven inventory tree: lazy-loaded folder expansion with per-user persisted expand state, drag-and-drop move for folders/assets, safe delete-with-relocate-or-cascade confirmation, search with breadcrumbs. - Assets: CRUD, duplicate, move, auto-generated IT-000001-style IDs, CSV import (with optional folder auto-create) and export. - Admin-defined item types with custom fields (text/long_text/number/date/ boolean/dropdown/url/ip_address). - Configurable statuses, session-based auth (bcrypt, secure cookies), admin/user roles, user management. - CLI: serve, version, database migrate, database backup; /healthz; graceful shutdown; Dockerfile/docker-compose/systemd unit; Go test suite.
This commit is contained in:
@@ -0,0 +1,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
|
||||
}
|
||||
Reference in New Issue
Block a user