Statuses import, an icon picker, folder-scoped CSV import, and site name
- Statuses can now be bulk-imported/updated via CSV (Name, Color columns; matches and updates existing statuses by name). New internal/statuses/csv.go, tested. - Icon fields (folder create/rename, item type form) are now a searchable dropdown of the 50 most common Bootstrap Icons instead of a free-text box, via a small reusable .icon-picker widget in app.js/app.css. - CSV asset import is now folder-scoped when triggered from the tree: right-click a folder (or its background) > Import CSV... imports every row directly into that folder, so the CSV needs no Folder column at all, and the option no longer appears at the top level. The original global, Folder-path-aware import (with auto-create-missing-folders) moved to a new admin-only Settings > Import / Export page, alongside the new Statuses import and format examples for both. assets.Import gained ImportOptions.TargetFolderID to drive the folder-scoped mode. - Site Name (Settings > General) now actually renders in the navbar brand and <title>, via a per-App "siteName" template func bound in loadTemplates. The brand is plain text now, not a link to /inventory.
This commit is contained in:
+10
-2
@@ -25,6 +25,13 @@ const (
|
||||
// ImportOptions controls CSV import behavior.
|
||||
type ImportOptions struct {
|
||||
CreateMissingFolders bool
|
||||
|
||||
// TargetFolderID, when set, imports every row directly into this
|
||||
// folder and ignores any Folder column in the CSV entirely (used for
|
||||
// the folder-scoped "import here" context menu action). When nil,
|
||||
// each row's folder is resolved from its Folder column instead
|
||||
// (global import), optionally creating missing folders.
|
||||
TargetFolderID *int64
|
||||
}
|
||||
|
||||
type ImportResult struct {
|
||||
@@ -157,8 +164,9 @@ func (s *Store) Import(ctx context.Context, r io.Reader, opts ImportOptions,
|
||||
}
|
||||
|
||||
var folderID *int64
|
||||
folderPath := get(row, colFolder)
|
||||
if folderPath != "" {
|
||||
if opts.TargetFolderID != nil {
|
||||
folderID = opts.TargetFolderID
|
||||
} else if folderPath := get(row, colFolder); 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))
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
package statuses
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/csv"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"inventree/internal/models"
|
||||
)
|
||||
|
||||
type ImportResult struct {
|
||||
Created int
|
||||
Updated int
|
||||
Errors []string
|
||||
}
|
||||
|
||||
// Import creates or updates statuses from CSV data with "Name" and "Color"
|
||||
// columns (case-insensitive header names, Color optional). Existing
|
||||
// statuses are matched and updated by name (case-insensitive); everything
|
||||
// else is created.
|
||||
func (s *Store) Import(ctx context.Context, r io.Reader) (*ImportResult, error) {
|
||||
existing, err := s.List(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
byName := map[string]int64{}
|
||||
for _, st := range existing {
|
||||
byName[strings.ToLower(st.Name)] = st.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{}
|
||||
for i, h := range header {
|
||||
colIdx[strings.ToLower(strings.TrimSpace(h))] = 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, "name")
|
||||
if name == "" {
|
||||
result.Errors = append(result.Errors, fmt.Sprintf("row %d: name is required, skipped", rowNum))
|
||||
continue
|
||||
}
|
||||
color := get(row, "color")
|
||||
|
||||
if id, ok := byName[strings.ToLower(name)]; ok {
|
||||
st, err := s.Get(ctx, id)
|
||||
if err != nil {
|
||||
result.Errors = append(result.Errors, fmt.Sprintf("row %d: %v", rowNum, err))
|
||||
continue
|
||||
}
|
||||
if color != "" {
|
||||
st.Color = color
|
||||
}
|
||||
if err := s.Update(ctx, st); err != nil {
|
||||
result.Errors = append(result.Errors, fmt.Sprintf("row %d: update failed: %v", rowNum, err))
|
||||
continue
|
||||
}
|
||||
result.Updated++
|
||||
continue
|
||||
}
|
||||
|
||||
st := &models.Status{Name: name, Color: color}
|
||||
if err := s.Create(ctx, st); err != nil {
|
||||
result.Errors = append(result.Errors, fmt.Sprintf("row %d: create failed: %v", rowNum, err))
|
||||
continue
|
||||
}
|
||||
byName[strings.ToLower(name)] = st.ID
|
||||
result.Created++
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package statuses_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"inventree/internal/database"
|
||||
"inventree/internal/statuses"
|
||||
)
|
||||
|
||||
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 TestImportCreatesAndUpdatesByName(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s := statuses.NewStore(testDB(t))
|
||||
|
||||
// "In Use" already exists from seed data; this should update its
|
||||
// color rather than create a duplicate, and create "Awaiting Parts".
|
||||
csvData := "Name,Color\nIn Use,primary\nAwaiting Parts,warning\n"
|
||||
result, err := s.Import(ctx, strings.NewReader(csvData))
|
||||
if err != nil {
|
||||
t.Fatalf("import: %v", err)
|
||||
}
|
||||
if result.Created != 1 || result.Updated != 1 {
|
||||
t.Fatalf("expected 1 created, 1 updated, got %+v", result)
|
||||
}
|
||||
|
||||
list, err := s.List(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("list: %v", err)
|
||||
}
|
||||
var foundInUse, foundAwaiting bool
|
||||
for _, st := range list {
|
||||
if st.Name == "In Use" {
|
||||
foundInUse = true
|
||||
if st.Color != "primary" {
|
||||
t.Errorf("expected In Use color updated to primary, got %s", st.Color)
|
||||
}
|
||||
}
|
||||
if st.Name == "Awaiting Parts" {
|
||||
foundAwaiting = true
|
||||
if st.Color != "warning" {
|
||||
t.Errorf("expected Awaiting Parts color warning, got %s", st.Color)
|
||||
}
|
||||
}
|
||||
}
|
||||
if !foundInUse || !foundAwaiting {
|
||||
t.Fatalf("expected both statuses present, got %+v", list)
|
||||
}
|
||||
}
|
||||
|
||||
func TestImportSkipsRowsMissingName(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s := statuses.NewStore(testDB(t))
|
||||
|
||||
csvData := "Name,Color\n,primary\n"
|
||||
result, err := s.Import(ctx, strings.NewReader(csvData))
|
||||
if err != nil {
|
||||
t.Fatalf("import: %v", err)
|
||||
}
|
||||
if result.Created != 0 || len(result.Errors) != 1 {
|
||||
t.Fatalf("expected 0 created and 1 error, got %+v", result)
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -58,7 +58,7 @@ func NewApp(db *sql.DB, log *slog.Logger, secureCookies bool) (*App, error) {
|
||||
SecureCookies: secureCookies,
|
||||
}
|
||||
|
||||
tmpl, err := loadTemplates()
|
||||
tmpl, err := loadTemplates(app)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -30,6 +30,11 @@ func (a *App) handleExportCSV(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
// handleImportCSV backs the folder-scoped "Import CSV..." context-menu
|
||||
// action: every row imports directly into the given folder_id, so the CSV
|
||||
// needs no Folder column at all. It's only ever invoked from within a
|
||||
// specific folder (never at the top level) and responds with JSON since
|
||||
// it's called via fetch from the tree/panel context menu.
|
||||
func (a *App) handleImportCSV(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
if err := r.ParseMultipartForm(32 << 20); err != nil {
|
||||
@@ -43,7 +48,10 @@ func (a *App) handleImportCSV(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
createMissing := r.FormValue("create_missing_folders") == "1"
|
||||
opts := assets.ImportOptions{}
|
||||
if folderID, err := formOptionalID(r, "folder_id"); err == nil && folderID != nil {
|
||||
opts.TargetFolderID = folderID
|
||||
}
|
||||
|
||||
itemTypes, err := a.ItemTypes.List(ctx, true)
|
||||
if err != nil {
|
||||
@@ -61,7 +69,7 @@ func (a *App) handleImportCSV(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
result, err := a.Assets.Import(ctx, file, assets.ImportOptions{CreateMissingFolders: createMissing}, itemTypes, statusList, userList)
|
||||
result, err := a.Assets.Import(ctx, file, opts, itemTypes, statusList, userList)
|
||||
if err != nil {
|
||||
jsonErr(w, http.StatusBadRequest, err)
|
||||
return
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
|
||||
"inventree/internal/assets"
|
||||
"inventree/internal/models"
|
||||
)
|
||||
|
||||
type settingsImportPageData struct {
|
||||
User *models.User
|
||||
Kind string
|
||||
Imported int
|
||||
Updated int
|
||||
Errs int
|
||||
Error string
|
||||
}
|
||||
|
||||
// handleSettingsImportPage backs the admin "Import / Export" settings page:
|
||||
// global CSV import for assets (with a Folder column, optionally
|
||||
// auto-creating missing folders) and for statuses, plus format examples.
|
||||
// This is distinct from the folder-scoped "Import CSV..." context-menu
|
||||
// action in the tree, which never needs a Folder column.
|
||||
func (a *App) handleSettingsImportPage(w http.ResponseWriter, r *http.Request) {
|
||||
q := r.URL.Query()
|
||||
a.Render(w, http.StatusOK, "settings_import", settingsImportPageData{
|
||||
User: currentUser(r),
|
||||
Kind: q.Get("kind"),
|
||||
Imported: queryInt(r, "imported", 0),
|
||||
Updated: queryInt(r, "updated", 0),
|
||||
Errs: queryInt(r, "errs", 0),
|
||||
Error: q.Get("error"),
|
||||
})
|
||||
}
|
||||
|
||||
func (a *App) handleSettingsImportAssets(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
if err := r.ParseMultipartForm(32 << 20); err != nil {
|
||||
http.Redirect(w, r, "/settings/import?kind=assets&error="+url.QueryEscape(err.Error()), http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
file, _, err := r.FormFile("file")
|
||||
if err != nil {
|
||||
http.Redirect(w, r, "/settings/import?kind=assets&error="+url.QueryEscape(err.Error()), http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
createMissing := r.FormValue("create_missing_folders") == "1"
|
||||
|
||||
itemTypes, err := a.ItemTypes.List(ctx, true)
|
||||
if err != nil {
|
||||
http.Redirect(w, r, "/settings/import?kind=assets&error="+url.QueryEscape(err.Error()), http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
statusList, err := a.Statuses.List(ctx)
|
||||
if err != nil {
|
||||
http.Redirect(w, r, "/settings/import?kind=assets&error="+url.QueryEscape(err.Error()), http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
userList, err := a.Users.List(ctx)
|
||||
if err != nil {
|
||||
http.Redirect(w, r, "/settings/import?kind=assets&error="+url.QueryEscape(err.Error()), http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
result, err := a.Assets.Import(ctx, file, assets.ImportOptions{CreateMissingFolders: createMissing}, itemTypes, statusList, userList)
|
||||
if err != nil {
|
||||
http.Redirect(w, r, "/settings/import?kind=assets&error="+url.QueryEscape(err.Error()), http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, fmt.Sprintf("/settings/import?kind=assets&imported=%d&updated=%d&errs=%d", result.Created, result.Updated, len(result.Errors)), http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (a *App) handleSettingsImportStatuses(w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.ParseMultipartForm(10 << 20); err != nil {
|
||||
http.Redirect(w, r, "/settings/import?kind=statuses&error="+url.QueryEscape(err.Error()), http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
file, _, err := r.FormFile("file")
|
||||
if err != nil {
|
||||
http.Redirect(w, r, "/settings/import?kind=statuses&error="+url.QueryEscape(err.Error()), http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
result, err := a.Statuses.Import(r.Context(), file)
|
||||
if err != nil {
|
||||
http.Redirect(w, r, "/settings/import?kind=statuses&error="+url.QueryEscape(err.Error()), http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, fmt.Sprintf("/settings/import?kind=statuses&imported=%d&updated=%d&errs=%d", result.Created, result.Updated, len(result.Errors)), http.StatusSeeOther)
|
||||
}
|
||||
@@ -25,6 +25,20 @@ func queryOptionalID(r *http.Request, name string) (*int64, error) {
|
||||
return &id, nil
|
||||
}
|
||||
|
||||
// formOptionalID parses a POST form/multipart-form field (already parsed via
|
||||
// ParseForm/ParseMultipartForm) as *int64; empty or missing returns nil.
|
||||
func formOptionalID(r *http.Request, name string) (*int64, error) {
|
||||
v := r.FormValue(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 == "" {
|
||||
|
||||
+17
-3
@@ -1,6 +1,7 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"io/fs"
|
||||
@@ -94,8 +95,21 @@ type templateSet struct {
|
||||
// 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)
|
||||
func loadTemplates(a *App) (*templateSet, error) {
|
||||
// siteName is bound per-App (rather than living in the package-level
|
||||
// templateFuncs) since it needs a live DB read through a.Settings; it's
|
||||
// used by layout.html for the <title> and navbar brand text.
|
||||
appFuncs := template.FuncMap{
|
||||
"siteName": func() string {
|
||||
name, err := a.Settings.Get(context.Background(), "site_name", "inventree")
|
||||
if err != nil || name == "" {
|
||||
return "inventree"
|
||||
}
|
||||
return name
|
||||
},
|
||||
}
|
||||
|
||||
shared := template.New("root").Funcs(templateFuncs).Funcs(appFuncs)
|
||||
|
||||
shared, err := shared.ParseFS(content.TemplatesFS, "templates/layout.html")
|
||||
if err != nil {
|
||||
@@ -124,7 +138,7 @@ func loadTemplates() (*templateSet, error) {
|
||||
pages[name] = clone
|
||||
}
|
||||
|
||||
login := template.New("login-root").Funcs(templateFuncs)
|
||||
login := template.New("login-root").Funcs(templateFuncs).Funcs(appFuncs)
|
||||
login, err = login.ParseFS(content.TemplatesFS, "templates/login.html")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse login: %w", err)
|
||||
|
||||
@@ -80,6 +80,10 @@ func (a *App) Router() http.Handler {
|
||||
mux.HandleFunc("POST /settings/item-types/{id}/fields/{fieldID}", a.requireAdmin(a.handleFieldUpdate))
|
||||
mux.HandleFunc("POST /settings/item-types/{id}/fields/{fieldID}/delete", a.requireAdmin(a.handleFieldDelete))
|
||||
|
||||
mux.HandleFunc("GET /settings/import", a.requireAdmin(a.handleSettingsImportPage))
|
||||
mux.HandleFunc("POST /settings/import/assets", a.requireAdmin(a.handleSettingsImportAssets))
|
||||
mux.HandleFunc("POST /settings/import/statuses", a.requireAdmin(a.handleSettingsImportStatuses))
|
||||
|
||||
mux.HandleFunc("GET /settings/users", a.requireAdmin(a.handleUsersList))
|
||||
mux.HandleFunc("GET /settings/users/new", a.requireAdmin(a.handleUserNewForm))
|
||||
mux.HandleFunc("POST /settings/users", a.requireAdmin(a.handleUserCreate))
|
||||
|
||||
Reference in New Issue
Block a user