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,6 @@
|
||||
data/
|
||||
*.db
|
||||
*.db-shm
|
||||
*.db-wal
|
||||
.git/
|
||||
inventree
|
||||
@@ -0,0 +1,8 @@
|
||||
/inventree
|
||||
/data/
|
||||
*.db
|
||||
*.db-shm
|
||||
*.db-wal
|
||||
*.db-journal
|
||||
backup-*.db
|
||||
.DS_Store
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
syntax=docker/dockerfile:1
|
||||
|
||||
FROM golang:1.25-alpine AS build
|
||||
WORKDIR /src
|
||||
ENV CGO_ENABLED=0
|
||||
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
|
||||
COPY . .
|
||||
ARG VERSION=dev
|
||||
RUN go build -trimpath -ldflags "-s -w -X main.version=${VERSION}" -o /out/inventree ./cmd/inventree
|
||||
|
||||
FROM alpine:3.20
|
||||
RUN apk add --no-cache ca-certificates tzdata && \
|
||||
addgroup -S inventree && adduser -S inventree -G inventree
|
||||
WORKDIR /app
|
||||
COPY --from=build /out/inventree /usr/local/bin/inventree
|
||||
RUN mkdir -p /app/data && chown -R inventree:inventree /app/data
|
||||
|
||||
USER inventree
|
||||
VOLUME ["/app/data"]
|
||||
EXPOSE 8080
|
||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s CMD wget -qO- http://127.0.0.1:8080/healthz || exit 1
|
||||
|
||||
ENTRYPOINT ["inventree"]
|
||||
CMD ["serve", "-addr", ":8080", "-db", "/app/data/inventree.db"]
|
||||
@@ -0,0 +1,172 @@
|
||||
# inventree
|
||||
|
||||
A small, self-contained IT asset inventory system. The heart of inventree is a
|
||||
nested folder tree — like a file manager — for organizing physical and
|
||||
virtual IT assets (servers, switches, laptops, printers, etc.).
|
||||
|
||||
- Single compiled Go binary, embedded HTML templates and static assets
|
||||
- SQLite storage (WAL mode, foreign keys, migrations)
|
||||
- Bootstrap 5 UI, minimal vanilla JavaScript (no Node/React/build step)
|
||||
- Session-based auth with two roles: Admin and User
|
||||
- Custom, admin-defined asset types with custom fields
|
||||
- CSV import/export, full-text-ish search with breadcrumbs
|
||||
|
||||
## Quick start
|
||||
|
||||
```sh
|
||||
go build -o inventree ./cmd/inventree
|
||||
./inventree serve
|
||||
```
|
||||
|
||||
Then open http://localhost:8080 and log in with:
|
||||
|
||||
```
|
||||
Username: admin
|
||||
Password: admin
|
||||
```
|
||||
|
||||
(This default account is created intentionally on first run. Change or
|
||||
disable it via the Users admin page once you're up and running.)
|
||||
|
||||
The database file is created automatically at `data/inventree.db` on first
|
||||
run, with migrations applied and seed data (default statuses, item types,
|
||||
and the admin account) inserted.
|
||||
|
||||
## CLI
|
||||
|
||||
```sh
|
||||
inventree # same as `inventree serve`
|
||||
inventree serve # start the web server
|
||||
inventree version # print the version
|
||||
inventree database migrate # apply pending migrations and exit
|
||||
inventree database backup [path] # write a consistent VACUUM INTO snapshot
|
||||
```
|
||||
|
||||
`serve` flags:
|
||||
|
||||
| Flag | Default | Description |
|
||||
|---------------------|------------------------|--------------------------------------------|
|
||||
| `-addr` | `:8080` | Listen address |
|
||||
| `-db` | `data/inventree.db` | Path to the SQLite database file |
|
||||
| `-secure-cookies` | `false` | Mark the session cookie `Secure` (enable when serving over HTTPS) |
|
||||
|
||||
`/healthz` is available for load balancer / container health checks.
|
||||
|
||||
## The inventory tree
|
||||
|
||||
The Inventory page is the default page after login and the center of the
|
||||
application: a folder/asset tree on the left, and a detail panel on the
|
||||
right that shows whatever is selected (a folder's contents, or a single
|
||||
asset).
|
||||
|
||||
- **Folders** can nest arbitrarily deep, and contain both subfolders and
|
||||
assets.
|
||||
- **Expand/collapse** state is remembered per user (persisted server-side),
|
||||
and folder contents are fetched lazily via small AJAX requests — the page
|
||||
never fully reloads when you browse the tree.
|
||||
- **Drag and drop** folders and assets onto another folder (or onto
|
||||
"Inventory" itself) to move them. Admin only.
|
||||
- **Right-click-style actions**: each folder has a "…" action menu (New
|
||||
Subfolder, Add Asset, Rename, Delete).
|
||||
- **Deleting a folder** that still contains subfolders or assets requires
|
||||
confirmation, and offers a safe "move contents elsewhere" option instead
|
||||
of an accidental cascade. A "permanently delete everything inside" option
|
||||
is also available for when that's genuinely what you want.
|
||||
- **Search** (top of the tree pane) looks across asset ID, name, folder
|
||||
name, item type, assigned user, notes, and custom field values, and shows
|
||||
each result's full folder breadcrumb.
|
||||
|
||||
## Item types and custom fields
|
||||
|
||||
Admins can define arbitrary asset types (Laptop, Switch, Firewall, …) under
|
||||
**Item Types**, each with its own set of custom fields (text, long text,
|
||||
number, date, boolean, dropdown, URL, IP address), marked required or not,
|
||||
with a default value and display order. A representative starter set of
|
||||
types and fields is seeded on first run — feel free to edit or delete it.
|
||||
|
||||
## CSV import / export
|
||||
|
||||
- **Export**: from the Inventory toolbar, or `/export/assets.csv` (optionally
|
||||
scoped to a folder, with `?recursive=1` to include subfolders). Includes
|
||||
one `cf_<field_key>` column per distinct custom field in use.
|
||||
- **Import**: CSV with columns `Asset ID` (optional — auto-generated if
|
||||
blank), `Name`, `Item Type`, `Folder` (a `/`-separated path, e.g.
|
||||
`Main Office/Network`), `Status`, `Assigned User`, `Notes`, plus
|
||||
`cf_<field_key>` columns for custom fields. Existing assets are matched
|
||||
and updated by Asset ID; everything else is created. Missing folders in
|
||||
the path can optionally be created automatically.
|
||||
|
||||
A full backup/restore of everything (folders, assets, types, custom fields,
|
||||
statuses) is just `inventree database backup` — it's a consistent snapshot
|
||||
of the whole SQLite database.
|
||||
|
||||
## Project layout
|
||||
|
||||
```
|
||||
cmd/inventree/ CLI entrypoint (serve, version, database migrate/backup)
|
||||
internal/
|
||||
auth/ session management, secure cookies
|
||||
database/ SQLite connection, pragmas, migrations
|
||||
migrations/ embedded .sql migration files
|
||||
models/ shared domain types
|
||||
folders/ nested folder tree
|
||||
assets/ asset CRUD, search, CSV import/export
|
||||
itemtypes/ admin-defined types + custom field definitions
|
||||
users/ accounts
|
||||
statuses/ configurable asset statuses
|
||||
settings/ key/value app settings
|
||||
web/ HTTP router, handlers, template rendering
|
||||
web/
|
||||
templates/ Go html/template pages and partials (embedded)
|
||||
static/ Bootstrap 5, Bootstrap Icons, app.css, app.js (embedded)
|
||||
deploy/ systemd unit example
|
||||
```
|
||||
|
||||
## Development
|
||||
|
||||
```sh
|
||||
go build ./...
|
||||
go vet ./...
|
||||
go test ./...
|
||||
```
|
||||
|
||||
The SQLite driver is [modernc.org/sqlite](https://pkg.go.dev/modernc.org/sqlite),
|
||||
a pure-Go implementation, so `CGO_ENABLED=0` builds work out of the box and
|
||||
the compiled binary has no C library dependencies.
|
||||
|
||||
## Docker
|
||||
|
||||
```sh
|
||||
docker compose up --build
|
||||
```
|
||||
|
||||
This builds the image, starts inventree on port 8080, and persists the
|
||||
database in a named volume (`inventree-data`, mounted at `/app/data`).
|
||||
|
||||
Or directly:
|
||||
|
||||
```sh
|
||||
docker build -t inventree .
|
||||
docker run -p 8080:8080 -v inventree-data:/app/data inventree
|
||||
```
|
||||
|
||||
## systemd
|
||||
|
||||
See `deploy/inventree.service` for an example unit. Adjust the paths, then:
|
||||
|
||||
```sh
|
||||
sudo useradd --system --home /opt/inventree --shell /usr/sbin/nologin inventree
|
||||
sudo mkdir -p /opt/inventree/data
|
||||
sudo cp inventree /opt/inventree/inventree
|
||||
sudo chown -R inventree:inventree /opt/inventree
|
||||
sudo cp deploy/inventree.service /etc/systemd/system/
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable --now inventree
|
||||
```
|
||||
|
||||
## Scope
|
||||
|
||||
inventree is intentionally a focused MVP: folders, assets, custom types,
|
||||
statuses, users, search, and CSV import/export. It does not include
|
||||
ticketing, purchasing, monitoring, RMM, MDM, or accounting features — if you
|
||||
need those, look elsewhere and integrate via CSV.
|
||||
@@ -0,0 +1,205 @@
|
||||
// Command inventree runs the inventree IT asset inventory server, or one of
|
||||
// its supporting subcommands (version, database migrate, database backup).
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"inventree/internal/database"
|
||||
"inventree/internal/web"
|
||||
)
|
||||
|
||||
// version is set at build time via -ldflags "-X main.version=...".
|
||||
var version = "dev"
|
||||
|
||||
func main() {
|
||||
if err := run(os.Args[1:]); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "error:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func run(args []string) error {
|
||||
if len(args) == 0 {
|
||||
return runServe(args)
|
||||
}
|
||||
switch args[0] {
|
||||
case "serve":
|
||||
return runServe(args[1:])
|
||||
case "version":
|
||||
fmt.Println("inventree", version)
|
||||
return nil
|
||||
case "database":
|
||||
if len(args) < 2 {
|
||||
return fmt.Errorf("usage: inventree database <migrate|backup>")
|
||||
}
|
||||
switch args[1] {
|
||||
case "migrate":
|
||||
return runDatabaseMigrate(args[2:])
|
||||
case "backup":
|
||||
return runDatabaseBackup(args[2:])
|
||||
default:
|
||||
return fmt.Errorf("unknown database subcommand %q", args[1])
|
||||
}
|
||||
case "help", "-h", "--help":
|
||||
printUsage()
|
||||
return nil
|
||||
default:
|
||||
printUsage()
|
||||
return fmt.Errorf("unknown command %q", args[0])
|
||||
}
|
||||
}
|
||||
|
||||
func printUsage() {
|
||||
fmt.Println(`inventree - a small IT asset inventory system
|
||||
|
||||
Usage:
|
||||
inventree [serve] Start the web server (default command)
|
||||
inventree version Print the version
|
||||
inventree database migrate Apply pending database migrations
|
||||
inventree database backup [path] Write a consistent backup snapshot
|
||||
|
||||
Flags for "serve":
|
||||
-addr string listen address (default ":8080")
|
||||
-db string path to the SQLite database file (default "data/inventree.db")
|
||||
-secure-cookies mark the session cookie Secure (enable when serving over HTTPS)`)
|
||||
}
|
||||
|
||||
func dbPathFlag(args []string) (dbPath string, rest []string) {
|
||||
dbPath = "data/inventree.db"
|
||||
for i := 0; i < len(args); i++ {
|
||||
if args[i] == "-db" && i+1 < len(args) {
|
||||
dbPath = args[i+1]
|
||||
i++
|
||||
continue
|
||||
}
|
||||
rest = append(rest, args[i])
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func runDatabaseMigrate(args []string) error {
|
||||
dbPath, _ := dbPathFlag(args)
|
||||
if err := ensureDir(dbPath); err != nil {
|
||||
return err
|
||||
}
|
||||
db, err := database.Open(dbPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer db.Close()
|
||||
fmt.Println("migrations applied to", dbPath)
|
||||
return nil
|
||||
}
|
||||
|
||||
func runDatabaseBackup(args []string) error {
|
||||
dbPath, rest := dbPathFlag(args)
|
||||
dest := fmt.Sprintf("backup-%s.db", time.Now().Format("20060102-150405"))
|
||||
if len(rest) > 0 {
|
||||
dest = rest[0]
|
||||
}
|
||||
if err := ensureDir(dbPath); err != nil {
|
||||
return err
|
||||
}
|
||||
db, err := database.Open(dbPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
if err := ensureDir(dest); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := db.Exec(`VACUUM INTO ?`, dest); err != nil {
|
||||
return fmt.Errorf("backup failed: %w", err)
|
||||
}
|
||||
fmt.Println("backup written to", dest)
|
||||
return nil
|
||||
}
|
||||
|
||||
func ensureDir(path string) error {
|
||||
dir := filepath.Dir(path)
|
||||
if dir == "" || dir == "." {
|
||||
return nil
|
||||
}
|
||||
return os.MkdirAll(dir, 0o755)
|
||||
}
|
||||
|
||||
func runServe(args []string) error {
|
||||
var (
|
||||
addr = ":8080"
|
||||
dbPath = "data/inventree.db"
|
||||
secureCookies = false
|
||||
)
|
||||
for i := 0; i < len(args); i++ {
|
||||
switch args[i] {
|
||||
case "-addr":
|
||||
if i+1 < len(args) {
|
||||
addr = args[i+1]
|
||||
i++
|
||||
}
|
||||
case "-db":
|
||||
if i+1 < len(args) {
|
||||
dbPath = args[i+1]
|
||||
i++
|
||||
}
|
||||
case "-secure-cookies":
|
||||
secureCookies = true
|
||||
}
|
||||
}
|
||||
|
||||
log := slog.New(slog.NewTextHandler(os.Stdout, nil))
|
||||
|
||||
if err := ensureDir(dbPath); err != nil {
|
||||
return err
|
||||
}
|
||||
db, err := database.Open(dbPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("open database: %w", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
app, err := web.NewApp(db, log, secureCookies)
|
||||
if err != nil {
|
||||
return fmt.Errorf("initialize app: %w", err)
|
||||
}
|
||||
|
||||
stop := make(chan struct{})
|
||||
app.StartSessionJanitor(stop)
|
||||
defer close(stop)
|
||||
|
||||
srv := &http.Server{
|
||||
Addr: addr,
|
||||
Handler: app.Router(),
|
||||
ReadHeaderTimeout: 10 * time.Second,
|
||||
}
|
||||
|
||||
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
defer cancel()
|
||||
|
||||
go func() {
|
||||
<-ctx.Done()
|
||||
log.Info("shutting down")
|
||||
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer shutdownCancel()
|
||||
if err := srv.Shutdown(shutdownCtx); err != nil {
|
||||
log.Error("graceful shutdown failed", "error", err)
|
||||
}
|
||||
}()
|
||||
|
||||
log.Info("inventree listening", "addr", addr, "db", dbPath)
|
||||
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
return err
|
||||
}
|
||||
log.Info("server stopped")
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
[Unit]
|
||||
Description=inventree - IT asset inventory
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=inventree
|
||||
Group=inventree
|
||||
WorkingDirectory=/opt/inventree
|
||||
ExecStart=/opt/inventree/inventree serve -addr :8080 -db /opt/inventree/data/inventree.db
|
||||
Restart=on-failure
|
||||
RestartSec=2
|
||||
|
||||
# Hardening (safe defaults for a small single-binary service)
|
||||
NoNewPrivileges=true
|
||||
ProtectSystem=strict
|
||||
ProtectHome=true
|
||||
ReadWritePaths=/opt/inventree/data
|
||||
PrivateTmp=true
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,19 @@
|
||||
services:
|
||||
inventree:
|
||||
build: .
|
||||
image: inventree:latest
|
||||
container_name: inventree
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8080:8080"
|
||||
volumes:
|
||||
- inventree-data:/app/data
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:8080/healthz"]
|
||||
interval: 30s
|
||||
timeout: 3s
|
||||
retries: 3
|
||||
start_period: 5s
|
||||
|
||||
volumes:
|
||||
inventree-data:
|
||||
@@ -0,0 +1,20 @@
|
||||
module inventree
|
||||
|
||||
go 1.25.0
|
||||
|
||||
require (
|
||||
golang.org/x/crypto v0.55.0
|
||||
modernc.org/sqlite v1.56.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.24 // indirect
|
||||
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
golang.org/x/sys v0.47.0 // indirect
|
||||
modernc.org/libc v1.74.4 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
modernc.org/memory v1.11.0 // indirect
|
||||
)
|
||||
@@ -0,0 +1,52 @@
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo=
|
||||
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||
github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
|
||||
github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
|
||||
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M=
|
||||
golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis=
|
||||
golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
|
||||
golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
|
||||
golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
|
||||
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q=
|
||||
golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA=
|
||||
modernc.org/cc/v4 v4.29.1 h1:MKgdCV3WykTSPqpVrnxdEDS0HEd2FHpKZDzxzU5LyeI=
|
||||
modernc.org/cc/v4 v4.29.1/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
|
||||
modernc.org/ccgo/v4 v4.34.6 h1:sBgfIwyN0TQ9C5hwIeuqyeAKyMWnbvj2fvpF4L11uzU=
|
||||
modernc.org/ccgo/v4 v4.34.6/go.mod h1:SZ8YcN9NG7XVsQYdm6jYBvi8PQP1qi+kqB6OhjqI3Fk=
|
||||
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
|
||||
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
|
||||
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
|
||||
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
||||
modernc.org/gc/v3 v3.1.4 h1:2g65LGVSmFQrXeITAw97x7hCRvZFcyE1uDP+7Vng7JI=
|
||||
modernc.org/gc/v3 v3.1.4/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
|
||||
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
|
||||
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
|
||||
modernc.org/libc v1.74.4 h1:fX1Omw4o2/1C2iRkkIsrQTasJQldLhRmuPreXLoWs9k=
|
||||
modernc.org/libc v1.74.4/go.mod h1:eeQAS9W3sZeKYMFubydxJpII9ybHWshk+7or7bLG9co=
|
||||
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
||||
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||
modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
|
||||
modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
||||
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
||||
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
||||
modernc.org/sqlite v1.56.0 h1:/D8e2RfFqoy/Zc6PuC76U28zFwmI/sYx1Kjm4yEn9e0=
|
||||
modernc.org/sqlite v1.56.0/go.mod h1:yCJ2cmAaIkHQ25oXWrF8H4O1lIfPYPR26yCEDj2P3pQ=
|
||||
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
||||
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
||||
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||
@@ -0,0 +1,549 @@
|
||||
// Package assets implements CRUD, search, and CSV import/export for IT
|
||||
// assets.
|
||||
package assets
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"inventree/internal/folders"
|
||||
"inventree/internal/models"
|
||||
"inventree/internal/settings"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrNotFound = errors.New("asset not found")
|
||||
ErrNameRequired = errors.New("asset name is required")
|
||||
ErrTagRequired = errors.New("asset ID is required")
|
||||
ErrTagTaken = errors.New("asset ID is already in use")
|
||||
ErrFieldRequired = errors.New("a required custom field is missing a value")
|
||||
)
|
||||
|
||||
type Store struct {
|
||||
db *sql.DB
|
||||
settings *settings.Store
|
||||
folders *folders.Store
|
||||
}
|
||||
|
||||
func NewStore(db *sql.DB, st *settings.Store, fs *folders.Store) *Store {
|
||||
return &Store{db: db, settings: st, folders: fs}
|
||||
}
|
||||
|
||||
const timeLayout = "2006-01-02T15:04:05.000Z"
|
||||
|
||||
func parseTime(s string) time.Time {
|
||||
t, _ := time.Parse(timeLayout, s)
|
||||
return t
|
||||
}
|
||||
|
||||
// GenerateAssetTag produces the next available asset ID such as IT-000042,
|
||||
// based on the configured prefix/padding, retrying past any manually
|
||||
// assigned collisions.
|
||||
func (s *Store) GenerateAssetTag(ctx context.Context) (string, error) {
|
||||
prefix, err := s.settings.Get(ctx, "asset_id_prefix", "IT-")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
paddingStr, err := s.settings.Get(ctx, "asset_id_padding", "6")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
padding, err := strconv.Atoi(paddingStr)
|
||||
if err != nil || padding <= 0 {
|
||||
padding = 6
|
||||
}
|
||||
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
var seq int64
|
||||
var seqStr string
|
||||
if err := tx.QueryRowContext(ctx, `SELECT value FROM settings WHERE key = 'asset_id_seq'`).Scan(&seqStr); err != nil && !errors.Is(err, sql.ErrNoRows) {
|
||||
return "", err
|
||||
}
|
||||
if seqStr != "" {
|
||||
if n, err := strconv.ParseInt(seqStr, 10, 64); err == nil {
|
||||
seq = n
|
||||
}
|
||||
}
|
||||
|
||||
var candidate string
|
||||
for {
|
||||
seq++
|
||||
candidate = fmt.Sprintf("%s%0*d", prefix, padding, seq)
|
||||
var exists int
|
||||
if err := tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM assets WHERE asset_id = ?`, candidate).Scan(&exists); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if exists == 0 {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`INSERT INTO settings (key, value, updated_at) VALUES ('asset_id_seq', ?, strftime('%Y-%m-%dT%H:%M:%fZ','now'))
|
||||
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at`,
|
||||
strconv.FormatInt(seq, 10)); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return candidate, nil
|
||||
}
|
||||
|
||||
func (s *Store) tagExists(ctx context.Context, tag string, excludeID int64) (bool, error) {
|
||||
var count int
|
||||
err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM assets WHERE asset_id = ? AND id != ?`, tag, excludeID).Scan(&count)
|
||||
return count > 0, err
|
||||
}
|
||||
|
||||
// Create inserts a new asset. If a.AssetTag is empty, one is generated.
|
||||
// fieldValues maps custom_field_defs.id to the submitted value.
|
||||
func (s *Store) Create(ctx context.Context, a *models.Asset, fieldValues map[int64]string) error {
|
||||
if a.Name == "" {
|
||||
return ErrNameRequired
|
||||
}
|
||||
if a.AssetTag == "" {
|
||||
tag, err := s.GenerateAssetTag(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
a.AssetTag = tag
|
||||
} else if taken, err := s.tagExists(ctx, a.AssetTag, 0); err != nil {
|
||||
return err
|
||||
} else if taken {
|
||||
return ErrTagTaken
|
||||
}
|
||||
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
res, err := tx.ExecContext(ctx,
|
||||
`INSERT INTO assets (asset_id, name, item_type_id, folder_id, status_id, assigned_user_id, notes)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||
a.AssetTag, a.Name, a.ItemTypeID, a.FolderID, a.StatusID, a.AssignedUserID, a.Notes)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
id, err := res.LastInsertId()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
a.ID = id
|
||||
|
||||
if err := saveFieldValues(ctx, tx, id, fieldValues); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func saveFieldValues(ctx context.Context, tx *sql.Tx, assetID int64, values map[int64]string) error {
|
||||
for defID, val := range values {
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`INSERT INTO custom_field_values (asset_id, field_def_id, value) VALUES (?, ?, ?)
|
||||
ON CONFLICT(asset_id, field_def_id) DO UPDATE SET value = excluded.value`,
|
||||
assetID, defID, val); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) Update(ctx context.Context, a *models.Asset, fieldValues map[int64]string) error {
|
||||
if a.Name == "" {
|
||||
return ErrNameRequired
|
||||
}
|
||||
if a.AssetTag == "" {
|
||||
return ErrTagRequired
|
||||
}
|
||||
if taken, err := s.tagExists(ctx, a.AssetTag, a.ID); err != nil {
|
||||
return err
|
||||
} else if taken {
|
||||
return ErrTagTaken
|
||||
}
|
||||
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
_, err = tx.ExecContext(ctx,
|
||||
`UPDATE assets SET asset_id = ?, name = ?, item_type_id = ?, folder_id = ?, status_id = ?, assigned_user_id = ?, notes = ?,
|
||||
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?`,
|
||||
a.AssetTag, a.Name, a.ItemTypeID, a.FolderID, a.StatusID, a.AssignedUserID, a.Notes, a.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err := tx.ExecContext(ctx, `DELETE FROM custom_field_values WHERE asset_id = ?`, a.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := saveFieldValues(ctx, tx, a.ID, fieldValues); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func (s *Store) Delete(ctx context.Context, id int64) error {
|
||||
_, err := s.db.ExecContext(ctx, `DELETE FROM assets WHERE id = ?`, id)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) Move(ctx context.Context, id int64, folderID *int64) error {
|
||||
_, err := s.db.ExecContext(ctx,
|
||||
`UPDATE assets SET folder_id = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?`,
|
||||
folderID, id)
|
||||
return err
|
||||
}
|
||||
|
||||
// Duplicate copies an asset's fields and custom field values into a new
|
||||
// asset with a freshly generated asset tag.
|
||||
func (s *Store) Duplicate(ctx context.Context, id int64) (*models.Asset, error) {
|
||||
orig, err := s.Get(ctx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tag, err := s.GenerateAssetTag(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
copyAsset := &models.Asset{
|
||||
AssetTag: tag,
|
||||
Name: orig.Name + " (Copy)",
|
||||
ItemTypeID: orig.ItemTypeID,
|
||||
FolderID: orig.FolderID,
|
||||
StatusID: orig.StatusID,
|
||||
AssignedUserID: orig.AssignedUserID,
|
||||
Notes: orig.Notes,
|
||||
}
|
||||
values := map[int64]string{}
|
||||
for _, fv := range orig.CustomFields {
|
||||
if fv.Value != "" {
|
||||
values[fv.Def.ID] = fv.Value
|
||||
}
|
||||
}
|
||||
if err := s.Create(ctx, copyAsset, values); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return copyAsset, nil
|
||||
}
|
||||
|
||||
const assetSelect = `
|
||||
SELECT a.id, a.asset_id, a.name, a.item_type_id, a.folder_id, a.status_id, a.assigned_user_id, a.notes, a.created_at, a.updated_at,
|
||||
it.name, it.icon, st.name, st.color, COALESCE(u.username, '')
|
||||
FROM assets a
|
||||
JOIN item_types it ON it.id = a.item_type_id
|
||||
JOIN statuses st ON st.id = a.status_id
|
||||
LEFT JOIN users u ON u.id = a.assigned_user_id`
|
||||
|
||||
func scanAsset(row interface{ Scan(...any) error }) (*models.Asset, error) {
|
||||
var a models.Asset
|
||||
var folderID, assignedUserID sql.NullInt64
|
||||
var created, updated string
|
||||
if err := row.Scan(&a.ID, &a.AssetTag, &a.Name, &a.ItemTypeID, &folderID, &a.StatusID, &assignedUserID, &a.Notes, &created, &updated,
|
||||
&a.ItemTypeName, &a.ItemTypeIcon, &a.StatusName, &a.StatusColor, &a.AssignedUsername); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if folderID.Valid {
|
||||
a.FolderID = &folderID.Int64
|
||||
}
|
||||
if assignedUserID.Valid {
|
||||
a.AssignedUserID = &assignedUserID.Int64
|
||||
}
|
||||
a.CreatedAt = parseTime(created)
|
||||
a.UpdatedAt = parseTime(updated)
|
||||
return &a, nil
|
||||
}
|
||||
|
||||
// Get loads an asset with its custom field values (including empty values
|
||||
// for fields with no data yet) and folder breadcrumb path.
|
||||
func (s *Store) Get(ctx context.Context, id int64) (*models.Asset, error) {
|
||||
row := s.db.QueryRowContext(ctx, assetSelect+` WHERE a.id = ?`, id)
|
||||
a, err := scanAsset(row)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.loadExtras(ctx, a); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return a, nil
|
||||
}
|
||||
|
||||
func (s *Store) GetByTag(ctx context.Context, tag string) (*models.Asset, error) {
|
||||
row := s.db.QueryRowContext(ctx, assetSelect+` WHERE a.asset_id = ?`, tag)
|
||||
a, err := scanAsset(row)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.loadExtras(ctx, a); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return a, nil
|
||||
}
|
||||
|
||||
func (s *Store) loadExtras(ctx context.Context, a *models.Asset) error {
|
||||
fields, err := s.fieldValues(ctx, a.ID, a.ItemTypeID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
a.CustomFields = fields
|
||||
|
||||
if a.FolderID != nil {
|
||||
path, err := s.folders.Path(ctx, *a.FolderID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
a.FolderPath = path
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) fieldValues(ctx context.Context, assetID, itemTypeID int64) ([]models.AssetFieldValue, error) {
|
||||
rows, err := s.db.QueryContext(ctx, `
|
||||
SELECT d.id, d.item_type_id, d.name, d.field_key, d.field_type, d.required, d.default_value, d.options, d.sort_order,
|
||||
COALESCE(v.value, '')
|
||||
FROM custom_field_defs d
|
||||
LEFT JOIN custom_field_values v ON v.field_def_id = d.id AND v.asset_id = ?
|
||||
WHERE d.item_type_id = ?
|
||||
ORDER BY d.sort_order ASC, d.name COLLATE NOCASE ASC`, assetID, itemTypeID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []models.AssetFieldValue
|
||||
for rows.Next() {
|
||||
var fv models.AssetFieldValue
|
||||
var options string
|
||||
if err := rows.Scan(&fv.Def.ID, &fv.Def.ItemTypeID, &fv.Def.Name, &fv.Def.FieldKey, &fv.Def.FieldType,
|
||||
&fv.Def.Required, &fv.Def.DefaultValue, &options, &fv.Def.SortOrder, &fv.Value); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if options != "" {
|
||||
fv.Def.Options = strings.Split(options, "\n")
|
||||
}
|
||||
out = append(out, fv)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// ListFilter narrows a List query. A nil pointer means "don't filter on
|
||||
// this field"; FolderID pointing at a zero value with FolderSet=true means
|
||||
// "top-level / unfiled only".
|
||||
type ListFilter struct {
|
||||
FolderID *int64
|
||||
FolderSet bool
|
||||
IncludeSub bool // if true and FolderID set, include assets in descendant folders too
|
||||
ItemTypeID int64
|
||||
StatusID int64
|
||||
AssignedUserID int64
|
||||
Query string
|
||||
Page int
|
||||
PageSize int
|
||||
}
|
||||
|
||||
// List returns assets matching the filter plus the total matching row count
|
||||
// (for pagination), ordered by name.
|
||||
func (s *Store) List(ctx context.Context, f ListFilter) ([]models.Asset, int, error) {
|
||||
var where []string
|
||||
var args []any
|
||||
|
||||
if f.FolderSet {
|
||||
if f.IncludeSub && f.FolderID != nil {
|
||||
ids, err := s.descendantFolderIDs(ctx, *f.FolderID)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
ids = append(ids, *f.FolderID)
|
||||
placeholders := make([]string, len(ids))
|
||||
for i, id := range ids {
|
||||
placeholders[i] = "?"
|
||||
args = append(args, id)
|
||||
}
|
||||
where = append(where, "a.folder_id IN ("+strings.Join(placeholders, ",")+")")
|
||||
} else if f.FolderID == nil {
|
||||
where = append(where, "a.folder_id IS NULL")
|
||||
} else {
|
||||
where = append(where, "a.folder_id = ?")
|
||||
args = append(args, *f.FolderID)
|
||||
}
|
||||
}
|
||||
if f.ItemTypeID != 0 {
|
||||
where = append(where, "a.item_type_id = ?")
|
||||
args = append(args, f.ItemTypeID)
|
||||
}
|
||||
if f.StatusID != 0 {
|
||||
where = append(where, "a.status_id = ?")
|
||||
args = append(args, f.StatusID)
|
||||
}
|
||||
if f.AssignedUserID != 0 {
|
||||
where = append(where, "a.assigned_user_id = ?")
|
||||
args = append(args, f.AssignedUserID)
|
||||
}
|
||||
if f.Query != "" {
|
||||
where = append(where, "(a.name LIKE '%'||?||'%' COLLATE NOCASE OR a.asset_id LIKE '%'||?||'%' COLLATE NOCASE)")
|
||||
args = append(args, f.Query, f.Query)
|
||||
}
|
||||
|
||||
whereClause := ""
|
||||
if len(where) > 0 {
|
||||
whereClause = "WHERE " + strings.Join(where, " AND ")
|
||||
}
|
||||
|
||||
var total int
|
||||
countQuery := `SELECT COUNT(*) FROM assets a ` + whereClause
|
||||
if err := s.db.QueryRowContext(ctx, countQuery, args...).Scan(&total); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
page, pageSize := f.Page, f.PageSize
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if pageSize < 1 || pageSize > 500 {
|
||||
pageSize = 50
|
||||
}
|
||||
offset := (page - 1) * pageSize
|
||||
|
||||
query := assetSelect + " " + whereClause + " ORDER BY a.name COLLATE NOCASE ASC LIMIT ? OFFSET ?"
|
||||
args = append(args, pageSize, offset)
|
||||
|
||||
rows, err := s.db.QueryContext(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []models.Asset
|
||||
for rows.Next() {
|
||||
a, err := scanAsset(rows)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
out = append(out, *a)
|
||||
}
|
||||
return out, total, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Store) descendantFolderIDs(ctx context.Context, rootID int64) ([]int64, error) {
|
||||
all, err := s.folders.All(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
childrenOf := map[int64][]int64{}
|
||||
for _, f := range all {
|
||||
if f.ParentID != nil {
|
||||
childrenOf[*f.ParentID] = append(childrenOf[*f.ParentID], f.ID)
|
||||
}
|
||||
}
|
||||
var out []int64
|
||||
var walk func(int64)
|
||||
walk = func(id int64) {
|
||||
for _, c := range childrenOf[id] {
|
||||
out = append(out, c)
|
||||
walk(c)
|
||||
}
|
||||
}
|
||||
walk(rootID)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// DashboardStats summarizes inventory counts for the small dashboard.
|
||||
type DashboardStats struct {
|
||||
Total int
|
||||
ByStatus []StatusCount
|
||||
ByType []TypeCount
|
||||
RecentlyAdded []models.Asset
|
||||
}
|
||||
|
||||
type StatusCount struct {
|
||||
Name string
|
||||
Color string
|
||||
Count int
|
||||
}
|
||||
|
||||
type TypeCount struct {
|
||||
Name string
|
||||
Icon string
|
||||
Count int
|
||||
}
|
||||
|
||||
func (s *Store) Stats(ctx context.Context) (*DashboardStats, error) {
|
||||
stats := &DashboardStats{}
|
||||
if err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM assets`).Scan(&stats.Total); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
rows, err := s.db.QueryContext(ctx, `
|
||||
SELECT st.name, st.color, COUNT(a.id)
|
||||
FROM statuses st LEFT JOIN assets a ON a.status_id = st.id
|
||||
GROUP BY st.id ORDER BY st.sort_order ASC`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for rows.Next() {
|
||||
var sc StatusCount
|
||||
if err := rows.Scan(&sc.Name, &sc.Color, &sc.Count); err != nil {
|
||||
rows.Close()
|
||||
return nil, err
|
||||
}
|
||||
stats.ByStatus = append(stats.ByStatus, sc)
|
||||
}
|
||||
rows.Close()
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
rows, err = s.db.QueryContext(ctx, `
|
||||
SELECT it.name, it.icon, COUNT(a.id)
|
||||
FROM item_types it LEFT JOIN assets a ON a.item_type_id = it.id
|
||||
GROUP BY it.id HAVING COUNT(a.id) > 0 ORDER BY COUNT(a.id) DESC`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for rows.Next() {
|
||||
var tc TypeCount
|
||||
if err := rows.Scan(&tc.Name, &tc.Icon, &tc.Count); err != nil {
|
||||
rows.Close()
|
||||
return nil, err
|
||||
}
|
||||
stats.ByType = append(stats.ByType, tc)
|
||||
}
|
||||
rows.Close()
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
recentRows, err := s.db.QueryContext(ctx, assetSelect+` ORDER BY a.created_at DESC LIMIT 8`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer recentRows.Close()
|
||||
for recentRows.Next() {
|
||||
a, err := scanAsset(recentRows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
stats.RecentlyAdded = append(stats.RecentlyAdded, *a)
|
||||
}
|
||||
return stats, recentRows.Err()
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
package assets_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"database/sql"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"inventree/internal/assets"
|
||||
"inventree/internal/database"
|
||||
"inventree/internal/folders"
|
||||
"inventree/internal/models"
|
||||
"inventree/internal/settings"
|
||||
)
|
||||
|
||||
// Seed data from migrations/0002_seed.sql gives us stable IDs to rely on:
|
||||
// item types 1=Laptop 2=Desktop 3=Server 4=Switch ... statuses 1=In Use 2=Spare ...
|
||||
const (
|
||||
itemTypeLaptop = 1
|
||||
itemTypeSwitch = 4
|
||||
statusInUse = 1
|
||||
statusSpare = 2
|
||||
)
|
||||
|
||||
func testStores(t *testing.T) (*assets.Store, *folders.Store, *sql.DB) {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
db, err := database.Open(filepath.Join(dir, "test.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open db: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { db.Close() })
|
||||
fs := folders.NewStore(db)
|
||||
st := settings.NewStore(db)
|
||||
return assets.NewStore(db, st, fs), fs, db
|
||||
}
|
||||
|
||||
func TestAssetCreateAutoGeneratesID(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
as, _, _ := testStores(t)
|
||||
|
||||
a1 := &models.Asset{Name: "Core Switch", ItemTypeID: itemTypeSwitch, StatusID: statusInUse}
|
||||
if err := as.Create(ctx, a1, nil); err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
if a1.AssetTag != "IT-000001" {
|
||||
t.Fatalf("expected IT-000001, got %s", a1.AssetTag)
|
||||
}
|
||||
|
||||
a2 := &models.Asset{Name: "Firewall", ItemTypeID: itemTypeSwitch, StatusID: statusInUse}
|
||||
if err := as.Create(ctx, a2, nil); err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
if a2.AssetTag != "IT-000002" {
|
||||
t.Fatalf("expected IT-000002, got %s", a2.AssetTag)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAssetCreateWithManualIDAndCollision(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
as, _, _ := testStores(t)
|
||||
|
||||
a1 := &models.Asset{AssetTag: "CUSTOM-1", Name: "Core Switch", ItemTypeID: itemTypeSwitch, StatusID: statusInUse}
|
||||
if err := as.Create(ctx, a1, nil); err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
|
||||
dup := &models.Asset{AssetTag: "CUSTOM-1", Name: "Other", ItemTypeID: itemTypeSwitch, StatusID: statusInUse}
|
||||
if err := as.Create(ctx, dup, nil); err != assets.ErrTagTaken {
|
||||
t.Fatalf("expected ErrTagTaken, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAssetDuplicateGeneratesNewID(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
as, _, _ := testStores(t)
|
||||
|
||||
orig := &models.Asset{Name: "Laptop 1", ItemTypeID: itemTypeLaptop, StatusID: statusInUse, Notes: "hello"}
|
||||
if err := as.Create(ctx, orig, nil); err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
|
||||
dup, err := as.Duplicate(ctx, orig.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("duplicate: %v", err)
|
||||
}
|
||||
if dup.AssetTag == orig.AssetTag {
|
||||
t.Fatalf("expected different asset tag, got same: %s", dup.AssetTag)
|
||||
}
|
||||
if dup.Notes != "hello" {
|
||||
t.Fatalf("expected notes copied, got %q", dup.Notes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAssetListFilterByFolder(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
as, fs, _ := testStores(t)
|
||||
|
||||
f1 := &models.Folder{Name: "Office"}
|
||||
must(t, fs.Create(ctx, f1))
|
||||
f2 := &models.Folder{Name: "Warehouse"}
|
||||
must(t, fs.Create(ctx, f2))
|
||||
|
||||
a1 := &models.Asset{Name: "A1", ItemTypeID: itemTypeLaptop, StatusID: statusInUse, FolderID: &f1.ID}
|
||||
must(t, as.Create(ctx, a1, nil))
|
||||
a2 := &models.Asset{Name: "A2", ItemTypeID: itemTypeLaptop, StatusID: statusInUse, FolderID: &f2.ID}
|
||||
must(t, as.Create(ctx, a2, nil))
|
||||
|
||||
list, total, err := as.List(ctx, assets.ListFilter{FolderID: &f1.ID, FolderSet: true, Page: 1, PageSize: 10})
|
||||
if err != nil {
|
||||
t.Fatalf("list: %v", err)
|
||||
}
|
||||
if total != 1 || len(list) != 1 || list[0].Name != "A1" {
|
||||
t.Fatalf("unexpected list result: total=%d list=%+v", total, list)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAssetCSVExportImportRoundTrip(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
as, fs, db := testStores(t)
|
||||
|
||||
office := &models.Folder{Name: "Main Office"}
|
||||
must(t, fs.Create(ctx, office))
|
||||
orig := &models.Asset{Name: "Core Switch", ItemTypeID: itemTypeSwitch, StatusID: statusInUse, FolderID: &office.ID}
|
||||
must(t, as.Create(ctx, orig, nil))
|
||||
|
||||
var buf bytes.Buffer
|
||||
if err := as.Export(ctx, &buf, assets.ListFilter{}); err != nil {
|
||||
t.Fatalf("export: %v", err)
|
||||
}
|
||||
if !strings.Contains(buf.String(), "Core Switch") {
|
||||
t.Fatalf("expected exported CSV to contain asset name, got: %s", buf.String())
|
||||
}
|
||||
|
||||
// Import a new asset referencing a not-yet-existing nested folder path,
|
||||
// with CreateMissingFolders enabled.
|
||||
csvData := "Name,Item Type,Folder,Status\nBackup Router,Switch,Main Office/Network,In Use\n"
|
||||
|
||||
itemTypes := seedItemTypes(t, db)
|
||||
statuses := seedStatuses(t, db)
|
||||
|
||||
result, err := as.Import(ctx, strings.NewReader(csvData), assets.ImportOptions{CreateMissingFolders: true}, itemTypes, statuses, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("import: %v", err)
|
||||
}
|
||||
if result.Created != 1 {
|
||||
t.Fatalf("expected 1 created, got %+v", result)
|
||||
}
|
||||
|
||||
all, total, err := as.List(ctx, assets.ListFilter{Page: 1, PageSize: 10})
|
||||
if err != nil {
|
||||
t.Fatalf("list: %v", err)
|
||||
}
|
||||
if total != 2 {
|
||||
t.Fatalf("expected 2 assets total after import, got %d (%+v)", total, all)
|
||||
}
|
||||
}
|
||||
|
||||
func seedItemTypes(t *testing.T, db *sql.DB) []models.ItemType {
|
||||
t.Helper()
|
||||
rows, err := db.Query(`SELECT id, name, icon, enabled, sort_order FROM item_types`)
|
||||
if err != nil {
|
||||
t.Fatalf("query item types: %v", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []models.ItemType
|
||||
for rows.Next() {
|
||||
var it models.ItemType
|
||||
if err := rows.Scan(&it.ID, &it.Name, &it.Icon, &it.Enabled, &it.SortOrder); err != nil {
|
||||
t.Fatalf("scan: %v", err)
|
||||
}
|
||||
out = append(out, it)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func seedStatuses(t *testing.T, db *sql.DB) []models.Status {
|
||||
t.Helper()
|
||||
rows, err := db.Query(`SELECT id, name, color, sort_order FROM statuses`)
|
||||
if err != nil {
|
||||
t.Fatalf("query statuses: %v", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []models.Status
|
||||
for rows.Next() {
|
||||
var st models.Status
|
||||
if err := rows.Scan(&st.ID, &st.Name, &st.Color, &st.SortOrder); err != nil {
|
||||
t.Fatalf("scan: %v", err)
|
||||
}
|
||||
out = append(out, st)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func must(t *testing.T, err error) {
|
||||
t.Helper()
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,349 @@
|
||||
package assets
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/csv"
|
||||
"fmt"
|
||||
"io"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"inventree/internal/models"
|
||||
)
|
||||
|
||||
const (
|
||||
colAssetID = "asset id"
|
||||
colName = "name"
|
||||
colItemType = "item type"
|
||||
colFolder = "folder"
|
||||
colStatus = "status"
|
||||
colAssignedUser = "assigned user"
|
||||
colNotes = "notes"
|
||||
cfColPrefix = "cf_"
|
||||
)
|
||||
|
||||
// ImportOptions controls CSV import behavior.
|
||||
type ImportOptions struct {
|
||||
CreateMissingFolders bool
|
||||
}
|
||||
|
||||
type ImportResult struct {
|
||||
Created int
|
||||
Updated int
|
||||
Errors []string
|
||||
}
|
||||
|
||||
// lookups bundles reference data needed to resolve CSV rows to foreign keys.
|
||||
type lookups struct {
|
||||
itemTypesByName map[string]*models.ItemType
|
||||
statusesByName map[string]int64
|
||||
usersByName map[string]int64
|
||||
folderPathToID map[string]int64
|
||||
}
|
||||
|
||||
func normalize(s string) string { return strings.ToLower(strings.TrimSpace(s)) }
|
||||
|
||||
// Import reads assets from CSV data, creating or updating rows. Matching is
|
||||
// done by Asset ID: if a row's Asset ID matches an existing asset, it is
|
||||
// updated in place; otherwise a new asset is created (auto-generating an ID
|
||||
// if the column is blank).
|
||||
func (s *Store) Import(ctx context.Context, r io.Reader, opts ImportOptions,
|
||||
itemTypesList []models.ItemType, statusesList []models.Status, usersList []models.User) (*ImportResult, error) {
|
||||
|
||||
lk := &lookups{
|
||||
itemTypesByName: map[string]*models.ItemType{},
|
||||
statusesByName: map[string]int64{},
|
||||
usersByName: map[string]int64{},
|
||||
folderPathToID: map[string]int64{},
|
||||
}
|
||||
for i := range itemTypesList {
|
||||
lk.itemTypesByName[normalize(itemTypesList[i].Name)] = &itemTypesList[i]
|
||||
}
|
||||
for _, st := range statusesList {
|
||||
lk.statusesByName[normalize(st.Name)] = st.ID
|
||||
}
|
||||
for _, u := range usersList {
|
||||
lk.usersByName[normalize(u.Username)] = u.ID
|
||||
}
|
||||
allFolders, err := s.folders.All(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
byID := map[int64]models.Folder{}
|
||||
for _, f := range allFolders {
|
||||
byID[f.ID] = f
|
||||
}
|
||||
var pathOf func(id int64) string
|
||||
pathOf = func(id int64) string {
|
||||
f, ok := byID[id]
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
if f.ParentID == nil {
|
||||
return f.Name
|
||||
}
|
||||
return pathOf(*f.ParentID) + "/" + f.Name
|
||||
}
|
||||
for _, f := range allFolders {
|
||||
lk.folderPathToID[normalize(pathOf(f.ID))] = f.ID
|
||||
}
|
||||
|
||||
cr := csv.NewReader(r)
|
||||
cr.FieldsPerRecord = -1
|
||||
header, err := cr.Read()
|
||||
if err != nil {
|
||||
if err == io.EOF {
|
||||
return &ImportResult{}, nil
|
||||
}
|
||||
return nil, fmt.Errorf("read CSV header: %w", err)
|
||||
}
|
||||
|
||||
colIdx := map[string]int{}
|
||||
var cfCols []struct {
|
||||
key string
|
||||
idx int
|
||||
}
|
||||
for i, h := range header {
|
||||
n := normalize(h)
|
||||
if strings.HasPrefix(n, cfColPrefix) {
|
||||
cfCols = append(cfCols, struct {
|
||||
key string
|
||||
idx int
|
||||
}{key: strings.TrimPrefix(n, cfColPrefix), idx: i})
|
||||
continue
|
||||
}
|
||||
colIdx[n] = i
|
||||
}
|
||||
|
||||
get := func(row []string, key string) string {
|
||||
idx, ok := colIdx[key]
|
||||
if !ok || idx >= len(row) {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(row[idx])
|
||||
}
|
||||
|
||||
result := &ImportResult{}
|
||||
rowNum := 1
|
||||
for {
|
||||
row, err := cr.Read()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read CSV row %d: %w", rowNum, err)
|
||||
}
|
||||
rowNum++
|
||||
|
||||
name := get(row, colName)
|
||||
if name == "" {
|
||||
result.Errors = append(result.Errors, fmt.Sprintf("row %d: name is required, skipped", rowNum))
|
||||
continue
|
||||
}
|
||||
itemTypeName := get(row, colItemType)
|
||||
it, ok := lk.itemTypesByName[normalize(itemTypeName)]
|
||||
if !ok {
|
||||
result.Errors = append(result.Errors, fmt.Sprintf("row %d: unknown item type %q, skipped", rowNum, itemTypeName))
|
||||
continue
|
||||
}
|
||||
statusName := get(row, colStatus)
|
||||
statusID, ok := lk.statusesByName[normalize(statusName)]
|
||||
if !ok {
|
||||
if len(statusesList) == 0 {
|
||||
result.Errors = append(result.Errors, fmt.Sprintf("row %d: no statuses configured, skipped", rowNum))
|
||||
continue
|
||||
}
|
||||
statusID = statusesList[0].ID
|
||||
}
|
||||
|
||||
var folderID *int64
|
||||
folderPath := get(row, colFolder)
|
||||
if folderPath != "" {
|
||||
id, err := s.resolveFolderPath(ctx, lk, folderPath, opts.CreateMissingFolders)
|
||||
if err != nil {
|
||||
result.Errors = append(result.Errors, fmt.Sprintf("row %d: folder %q: %v", rowNum, folderPath, err))
|
||||
continue
|
||||
}
|
||||
folderID = &id
|
||||
}
|
||||
|
||||
var assignedUserID *int64
|
||||
if uname := get(row, colAssignedUser); uname != "" {
|
||||
if id, ok := lk.usersByName[normalize(uname)]; ok {
|
||||
assignedUserID = &id
|
||||
} else {
|
||||
result.Errors = append(result.Errors, fmt.Sprintf("row %d: unknown assigned user %q, leaving unassigned", rowNum, uname))
|
||||
}
|
||||
}
|
||||
|
||||
fieldValues := map[int64]string{}
|
||||
for _, cf := range cfCols {
|
||||
if cf.idx >= len(row) {
|
||||
continue
|
||||
}
|
||||
val := strings.TrimSpace(row[cf.idx])
|
||||
if val == "" {
|
||||
continue
|
||||
}
|
||||
for _, def := range it.Fields {
|
||||
if normalize(def.FieldKey) == cf.key {
|
||||
fieldValues[def.ID] = val
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
asset := &models.Asset{
|
||||
AssetTag: get(row, colAssetID),
|
||||
Name: name,
|
||||
ItemTypeID: it.ID,
|
||||
FolderID: folderID,
|
||||
StatusID: statusID,
|
||||
AssignedUserID: assignedUserID,
|
||||
Notes: get(row, colNotes),
|
||||
}
|
||||
|
||||
if asset.AssetTag != "" {
|
||||
existing, err := s.GetByTag(ctx, asset.AssetTag)
|
||||
if err == nil {
|
||||
asset.ID = existing.ID
|
||||
if err := s.Update(ctx, asset, fieldValues); err != nil {
|
||||
result.Errors = append(result.Errors, fmt.Sprintf("row %d: update failed: %v", rowNum, err))
|
||||
continue
|
||||
}
|
||||
result.Updated++
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
if err := s.Create(ctx, asset, fieldValues); err != nil {
|
||||
result.Errors = append(result.Errors, fmt.Sprintf("row %d: create failed: %v", rowNum, err))
|
||||
continue
|
||||
}
|
||||
result.Created++
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *Store) resolveFolderPath(ctx context.Context, lk *lookups, path string, createMissing bool) (int64, error) {
|
||||
norm := normalize(path)
|
||||
if id, ok := lk.folderPathToID[norm]; ok {
|
||||
return id, nil
|
||||
}
|
||||
if !createMissing {
|
||||
return 0, fmt.Errorf("folder not found")
|
||||
}
|
||||
|
||||
segments := strings.Split(path, "/")
|
||||
var parentID *int64
|
||||
built := ""
|
||||
for _, seg := range segments {
|
||||
seg = strings.TrimSpace(seg)
|
||||
if seg == "" {
|
||||
continue
|
||||
}
|
||||
if built == "" {
|
||||
built = seg
|
||||
} else {
|
||||
built = built + "/" + seg
|
||||
}
|
||||
if id, ok := lk.folderPathToID[normalize(built)]; ok {
|
||||
parentID = &id
|
||||
continue
|
||||
}
|
||||
f := &models.Folder{Name: seg, ParentID: parentID}
|
||||
if err := s.folders.Create(ctx, f); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
lk.folderPathToID[normalize(built)] = f.ID
|
||||
parentID = &f.ID
|
||||
}
|
||||
if parentID == nil {
|
||||
return 0, fmt.Errorf("empty folder path")
|
||||
}
|
||||
return *parentID, nil
|
||||
}
|
||||
|
||||
// Export writes all assets (optionally filtered) to CSV, with one column per
|
||||
// distinct custom field key used across all item types.
|
||||
func (s *Store) Export(ctx context.Context, w io.Writer, f ListFilter) error {
|
||||
fieldDefs, err := s.allFieldKeys(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cw := csv.NewWriter(w)
|
||||
header := []string{"Asset ID", "Name", "Item Type", "Folder", "Status", "Assigned User", "Notes", "Created At", "Updated At"}
|
||||
for _, k := range fieldDefs {
|
||||
header = append(header, cfColPrefix+k)
|
||||
}
|
||||
if err := cw.Write(header); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
f.Page = 1
|
||||
f.PageSize = 1000
|
||||
for {
|
||||
batch, total, err := s.List(ctx, f)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for i := range batch {
|
||||
a, err := s.Get(ctx, batch[i].ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
row := []string{
|
||||
a.AssetTag, a.Name, a.ItemTypeName, folderPathString(a.FolderPath), a.StatusName, a.AssignedUsername, a.Notes,
|
||||
a.CreatedAt.Format("2006-01-02 15:04:05"), a.UpdatedAt.Format("2006-01-02 15:04:05"),
|
||||
}
|
||||
values := map[string]string{}
|
||||
for _, fv := range a.CustomFields {
|
||||
values[fv.Def.FieldKey] = fv.Value
|
||||
}
|
||||
for _, k := range fieldDefs {
|
||||
row = append(row, values[k])
|
||||
}
|
||||
if err := cw.Write(row); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if f.Page*f.PageSize >= total || len(batch) == 0 {
|
||||
break
|
||||
}
|
||||
f.Page++
|
||||
}
|
||||
cw.Flush()
|
||||
return cw.Error()
|
||||
}
|
||||
|
||||
func folderPathString(path []models.BreadcrumbItem) string {
|
||||
names := make([]string, len(path))
|
||||
for i, p := range path {
|
||||
names[i] = p.Name
|
||||
}
|
||||
return strings.Join(names, "/")
|
||||
}
|
||||
|
||||
func (s *Store) allFieldKeys(ctx context.Context) ([]string, error) {
|
||||
rows, err := s.db.QueryContext(ctx, `SELECT DISTINCT field_key FROM custom_field_defs`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
seen := map[string]bool{}
|
||||
var out []string
|
||||
for rows.Next() {
|
||||
var k string
|
||||
if err := rows.Scan(&k); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !seen[k] {
|
||||
seen[k] = true
|
||||
out = append(out, k)
|
||||
}
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out, rows.Err()
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package assets
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"inventree/internal/models"
|
||||
)
|
||||
|
||||
// Search performs a global, case-insensitive substring search across asset
|
||||
// ID, name, notes, folder name, item type name, assigned username, and
|
||||
// custom field values, returning fully-loaded assets (with folder path and
|
||||
// custom fields) ready for display with breadcrumbs.
|
||||
func (s *Store) Search(ctx context.Context, query string, limit int) ([]models.Asset, error) {
|
||||
if query == "" {
|
||||
return nil, nil
|
||||
}
|
||||
if limit <= 0 || limit > 200 {
|
||||
limit = 50
|
||||
}
|
||||
like := "%" + query + "%"
|
||||
|
||||
rows, err := s.db.QueryContext(ctx, `
|
||||
SELECT DISTINCT a.id
|
||||
FROM assets a
|
||||
LEFT JOIN folders fo ON fo.id = a.folder_id
|
||||
LEFT JOIN users au ON au.id = a.assigned_user_id
|
||||
JOIN item_types it ON it.id = a.item_type_id
|
||||
WHERE a.asset_id LIKE ? COLLATE NOCASE
|
||||
OR a.name LIKE ? COLLATE NOCASE
|
||||
OR a.notes LIKE ? COLLATE NOCASE
|
||||
OR fo.name LIKE ? COLLATE NOCASE
|
||||
OR it.name LIKE ? COLLATE NOCASE
|
||||
OR au.username LIKE ? COLLATE NOCASE
|
||||
OR EXISTS (SELECT 1 FROM custom_field_values v WHERE v.asset_id = a.id AND v.value LIKE ? COLLATE NOCASE)
|
||||
ORDER BY a.name COLLATE NOCASE ASC
|
||||
LIMIT ?`,
|
||||
like, like, like, like, like, like, like, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var ids []int64
|
||||
for rows.Next() {
|
||||
var id int64
|
||||
if err := rows.Scan(&id); err != nil {
|
||||
rows.Close()
|
||||
return nil, err
|
||||
}
|
||||
ids = append(ids, id)
|
||||
}
|
||||
rows.Close()
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var out []models.Asset
|
||||
for _, id := range ids {
|
||||
a, err := s.Get(ctx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, *a)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
);
|
||||
@@ -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';
|
||||
@@ -0,0 +1,342 @@
|
||||
// Package folders implements the nested folder tree that organizes assets.
|
||||
package folders
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"inventree/internal/models"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrNotFound = errors.New("folder not found")
|
||||
ErrInvalidParent = errors.New("cannot move a folder into itself or one of its own descendants")
|
||||
ErrNameRequired = errors.New("folder name is required")
|
||||
ErrNotEmpty = errors.New("folder still contains child folders or assets")
|
||||
)
|
||||
|
||||
type Store struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
func NewStore(db *sql.DB) *Store { return &Store{db: db} }
|
||||
|
||||
const timeLayout = "2006-01-02T15:04:05.000Z"
|
||||
|
||||
func parseTime(s string) time.Time {
|
||||
t, _ := time.Parse(timeLayout, s)
|
||||
return t
|
||||
}
|
||||
|
||||
func scanFolder(row interface{ Scan(...any) error }) (*models.Folder, error) {
|
||||
var f models.Folder
|
||||
var parentID sql.NullInt64
|
||||
var created, updated string
|
||||
if err := row.Scan(&f.ID, &f.Name, &parentID, &f.Description, &f.Icon, &f.SortOrder, &created, &updated); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if parentID.Valid {
|
||||
f.ParentID = &parentID.Int64
|
||||
}
|
||||
f.CreatedAt = parseTime(created)
|
||||
f.UpdatedAt = parseTime(updated)
|
||||
return &f, nil
|
||||
}
|
||||
|
||||
func (s *Store) Get(ctx context.Context, id int64) (*models.Folder, error) {
|
||||
row := s.db.QueryRowContext(ctx, `SELECT id, name, parent_id, description, icon, sort_order, created_at, updated_at FROM folders WHERE id = ?`, id)
|
||||
f, err := scanFolder(row)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return f, nil
|
||||
}
|
||||
|
||||
// Children returns the direct child folders of parentID (nil for top level),
|
||||
// ordered by sort_order then name, annotated with folder/asset counts.
|
||||
func (s *Store) Children(ctx context.Context, parentID *int64) ([]models.Folder, error) {
|
||||
query := `
|
||||
SELECT f.id, f.name, f.parent_id, f.description, f.icon, f.sort_order, f.created_at, f.updated_at,
|
||||
(SELECT COUNT(*) FROM folders c WHERE c.parent_id = f.id) AS folder_count,
|
||||
(SELECT COUNT(*) FROM assets a WHERE a.folder_id = f.id) AS asset_count
|
||||
FROM folders f
|
||||
WHERE f.parent_id IS ?
|
||||
ORDER BY f.sort_order ASC, f.name COLLATE NOCASE ASC`
|
||||
rows, err := s.db.QueryContext(ctx, query, parentID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []models.Folder
|
||||
for rows.Next() {
|
||||
var f models.Folder
|
||||
var pid sql.NullInt64
|
||||
var created, updated string
|
||||
if err := rows.Scan(&f.ID, &f.Name, &pid, &f.Description, &f.Icon, &f.SortOrder, &created, &updated, &f.FolderCount, &f.AssetCount); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if pid.Valid {
|
||||
f.ParentID = &pid.Int64
|
||||
}
|
||||
f.CreatedAt = parseTime(created)
|
||||
f.UpdatedAt = parseTime(updated)
|
||||
out = append(out, f)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// All returns every folder in the system (id, name, parent_id only) for
|
||||
// building the full in-memory tree or search indexes.
|
||||
func (s *Store) All(ctx context.Context) ([]models.Folder, error) {
|
||||
rows, err := s.db.QueryContext(ctx, `SELECT id, name, parent_id, description, icon, sort_order, created_at, updated_at FROM folders ORDER BY sort_order ASC, name COLLATE NOCASE ASC`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []models.Folder
|
||||
for rows.Next() {
|
||||
f, err := scanFolder(rowsScanner{rows})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, *f)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
type rowsScanner struct{ r *sql.Rows }
|
||||
|
||||
func (rs rowsScanner) Scan(dest ...any) error { return rs.r.Scan(dest...) }
|
||||
|
||||
func (s *Store) Create(ctx context.Context, f *models.Folder) error {
|
||||
if f.Name == "" {
|
||||
return ErrNameRequired
|
||||
}
|
||||
if f.ParentID != nil {
|
||||
if _, err := s.Get(ctx, *f.ParentID); err != nil {
|
||||
return fmt.Errorf("parent folder: %w", err)
|
||||
}
|
||||
}
|
||||
res, err := s.db.ExecContext(ctx,
|
||||
`INSERT INTO folders (name, parent_id, description, icon, sort_order) VALUES (?, ?, ?, ?, ?)`,
|
||||
f.Name, f.ParentID, f.Description, f.Icon, f.SortOrder)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
id, err := res.LastInsertId()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
f.ID = id
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) Update(ctx context.Context, f *models.Folder) error {
|
||||
if f.Name == "" {
|
||||
return ErrNameRequired
|
||||
}
|
||||
_, err := s.db.ExecContext(ctx,
|
||||
`UPDATE folders SET name = ?, description = ?, icon = ?, sort_order = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?`,
|
||||
f.Name, f.Description, f.Icon, f.SortOrder, f.ID)
|
||||
return err
|
||||
}
|
||||
|
||||
// IsDescendant reports whether candidateID is descendantOf ancestorID (or
|
||||
// equal to it), by walking parent pointers.
|
||||
func (s *Store) isSelfOrDescendant(ctx context.Context, folderID int64, targetParentID *int64) (bool, error) {
|
||||
if targetParentID == nil {
|
||||
return false, nil
|
||||
}
|
||||
cur := *targetParentID
|
||||
for {
|
||||
if cur == folderID {
|
||||
return true, nil
|
||||
}
|
||||
var parentID sql.NullInt64
|
||||
err := s.db.QueryRowContext(ctx, `SELECT parent_id FROM folders WHERE id = ?`, cur).Scan(&parentID)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if !parentID.Valid {
|
||||
return false, nil
|
||||
}
|
||||
cur = parentID.Int64
|
||||
}
|
||||
}
|
||||
|
||||
// Move relocates a folder to a new parent (nil = top level), rejecting moves
|
||||
// into itself or one of its own descendants.
|
||||
func (s *Store) Move(ctx context.Context, id int64, newParentID *int64) error {
|
||||
if newParentID != nil && *newParentID == id {
|
||||
return ErrInvalidParent
|
||||
}
|
||||
bad, err := s.isSelfOrDescendant(ctx, id, newParentID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if bad {
|
||||
return ErrInvalidParent
|
||||
}
|
||||
if newParentID != nil {
|
||||
if _, err := s.Get(ctx, *newParentID); err != nil {
|
||||
return fmt.Errorf("parent folder: %w", err)
|
||||
}
|
||||
}
|
||||
_, err = s.db.ExecContext(ctx,
|
||||
`UPDATE folders SET parent_id = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?`,
|
||||
newParentID, id)
|
||||
return err
|
||||
}
|
||||
|
||||
// Counts returns the number of direct child folders and direct assets in a
|
||||
// folder, used to decide whether deletion needs confirmation.
|
||||
func (s *Store) Counts(ctx context.Context, id int64) (folderCount, assetCount int, err error) {
|
||||
err = s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM folders WHERE parent_id = ?`, id).Scan(&folderCount)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
err = s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM assets WHERE folder_id = ?`, id).Scan(&assetCount)
|
||||
return
|
||||
}
|
||||
|
||||
// Delete removes an empty folder. It fails if the folder still has children
|
||||
// or assets (enforced both here and by the FK constraint).
|
||||
func (s *Store) Delete(ctx context.Context, id int64) error {
|
||||
fc, ac, err := s.Counts(ctx, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if fc > 0 || ac > 0 {
|
||||
return ErrNotEmpty
|
||||
}
|
||||
_, err = s.db.ExecContext(ctx, `DELETE FROM folders WHERE id = ?`, id)
|
||||
return err
|
||||
}
|
||||
|
||||
// DeleteAndRelocate moves all direct child folders and assets of id to
|
||||
// destParentID/destFolderID (nil = top level / unfiled) and then deletes the
|
||||
// now-empty folder id. Runs in a single transaction.
|
||||
func (s *Store) DeleteAndRelocate(ctx context.Context, id int64, dest *int64) error {
|
||||
if dest != nil {
|
||||
bad, err := s.isSelfOrDescendant(ctx, id, dest)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if bad || *dest == id {
|
||||
return ErrInvalidParent
|
||||
}
|
||||
}
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
if _, err := tx.ExecContext(ctx, `UPDATE folders SET parent_id = ? WHERE parent_id = ?`, dest, id); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `UPDATE assets SET folder_id = ? WHERE folder_id = ?`, dest, id); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `DELETE FROM folders WHERE id = ?`, id); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// CascadeDelete permanently deletes a folder along with every descendant
|
||||
// folder and asset inside it. Descendant folders are removed deepest-first
|
||||
// so the ON DELETE RESTRICT foreign key is always satisfied.
|
||||
func (s *Store) CascadeDelete(ctx context.Context, id int64) error {
|
||||
all, err := s.All(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
childrenOf := map[int64][]int64{}
|
||||
for _, f := range all {
|
||||
if f.ParentID != nil {
|
||||
childrenOf[*f.ParentID] = append(childrenOf[*f.ParentID], f.ID)
|
||||
}
|
||||
}
|
||||
// Depth-first order, id last, so children are deleted before parents.
|
||||
var order []int64
|
||||
var walk func(int64)
|
||||
walk = func(fid int64) {
|
||||
for _, c := range childrenOf[fid] {
|
||||
walk(c)
|
||||
}
|
||||
order = append(order, fid)
|
||||
}
|
||||
walk(id)
|
||||
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
for _, fid := range order {
|
||||
if _, err := tx.ExecContext(ctx, `DELETE FROM assets WHERE folder_id = ?`, fid); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `DELETE FROM folders WHERE id = ?`, fid); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// Path returns breadcrumb items from the root down to (and including) id.
|
||||
func (s *Store) Path(ctx context.Context, id int64) ([]models.BreadcrumbItem, error) {
|
||||
var path []models.BreadcrumbItem
|
||||
cur := &id
|
||||
for cur != nil {
|
||||
var name string
|
||||
var parentID sql.NullInt64
|
||||
err := s.db.QueryRowContext(ctx, `SELECT name, parent_id FROM folders WHERE id = ?`, *cur).Scan(&name, &parentID)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
path = append([]models.BreadcrumbItem{{ID: *cur, Name: name}}, path...)
|
||||
if parentID.Valid {
|
||||
cur = &parentID.Int64
|
||||
} else {
|
||||
cur = nil
|
||||
}
|
||||
}
|
||||
return path, nil
|
||||
}
|
||||
|
||||
// Search returns folders whose name matches the query (case-insensitive
|
||||
// substring match).
|
||||
func (s *Store) Search(ctx context.Context, query string) ([]models.Folder, error) {
|
||||
rows, err := s.db.QueryContext(ctx,
|
||||
`SELECT id, name, parent_id, description, icon, sort_order, created_at, updated_at
|
||||
FROM folders WHERE name LIKE '%' || ? || '%' COLLATE NOCASE ORDER BY name COLLATE NOCASE ASC LIMIT 50`, query)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []models.Folder
|
||||
for rows.Next() {
|
||||
f, err := scanFolder(rowsScanner{rows})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, *f)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
package folders_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"inventree/internal/database"
|
||||
"inventree/internal/folders"
|
||||
"inventree/internal/models"
|
||||
)
|
||||
|
||||
func testDB(t *testing.T) *sql.DB {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
db, err := database.Open(filepath.Join(dir, "test.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open db: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { db.Close() })
|
||||
return db
|
||||
}
|
||||
|
||||
func TestFolderCreateAndChildren(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s := folders.NewStore(testDB(t))
|
||||
|
||||
root := &models.Folder{Name: "Main Office"}
|
||||
if err := s.Create(ctx, root); err != nil {
|
||||
t.Fatalf("create root: %v", err)
|
||||
}
|
||||
if root.ID == 0 {
|
||||
t.Fatal("expected assigned ID")
|
||||
}
|
||||
|
||||
child := &models.Folder{Name: "Network", ParentID: &root.ID}
|
||||
if err := s.Create(ctx, child); err != nil {
|
||||
t.Fatalf("create child: %v", err)
|
||||
}
|
||||
|
||||
children, err := s.Children(ctx, &root.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("children: %v", err)
|
||||
}
|
||||
if len(children) != 1 || children[0].Name != "Network" {
|
||||
t.Fatalf("unexpected children: %+v", children)
|
||||
}
|
||||
if children[0].FolderCount != 0 {
|
||||
t.Fatalf("expected 0 sub-folders, got %d", children[0].FolderCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFolderCreateRequiresName(t *testing.T) {
|
||||
s := folders.NewStore(testDB(t))
|
||||
err := s.Create(context.Background(), &models.Folder{})
|
||||
if err != folders.ErrNameRequired {
|
||||
t.Fatalf("expected ErrNameRequired, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFolderMovePreventsCycles(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s := folders.NewStore(testDB(t))
|
||||
|
||||
a := &models.Folder{Name: "A"}
|
||||
must(t, s.Create(ctx, a))
|
||||
b := &models.Folder{Name: "B", ParentID: &a.ID}
|
||||
must(t, s.Create(ctx, b))
|
||||
c := &models.Folder{Name: "C", ParentID: &b.ID}
|
||||
must(t, s.Create(ctx, c))
|
||||
|
||||
// Moving A under its own descendant C must fail.
|
||||
if err := s.Move(ctx, a.ID, &c.ID); err != folders.ErrInvalidParent {
|
||||
t.Fatalf("expected ErrInvalidParent, got %v", err)
|
||||
}
|
||||
// Moving A under itself must fail.
|
||||
if err := s.Move(ctx, a.ID, &a.ID); err != folders.ErrInvalidParent {
|
||||
t.Fatalf("expected ErrInvalidParent for self-move, got %v", err)
|
||||
}
|
||||
// A legitimate move should succeed.
|
||||
if err := s.Move(ctx, c.ID, &a.ID); err != nil {
|
||||
t.Fatalf("expected valid move to succeed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFolderDeleteRequiresEmpty(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s := folders.NewStore(testDB(t))
|
||||
|
||||
a := &models.Folder{Name: "A"}
|
||||
must(t, s.Create(ctx, a))
|
||||
b := &models.Folder{Name: "B", ParentID: &a.ID}
|
||||
must(t, s.Create(ctx, b))
|
||||
|
||||
if err := s.Delete(ctx, a.ID); err != folders.ErrNotEmpty {
|
||||
t.Fatalf("expected ErrNotEmpty, got %v", err)
|
||||
}
|
||||
if err := s.Delete(ctx, b.ID); err != nil {
|
||||
t.Fatalf("delete empty folder: %v", err)
|
||||
}
|
||||
if err := s.Delete(ctx, a.ID); err != nil {
|
||||
t.Fatalf("delete now-empty folder: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFolderDeleteAndRelocate(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s := folders.NewStore(testDB(t))
|
||||
|
||||
a := &models.Folder{Name: "A"}
|
||||
must(t, s.Create(ctx, a))
|
||||
b := &models.Folder{Name: "B", ParentID: &a.ID}
|
||||
must(t, s.Create(ctx, b))
|
||||
dest := &models.Folder{Name: "Dest"}
|
||||
must(t, s.Create(ctx, dest))
|
||||
|
||||
if err := s.DeleteAndRelocate(ctx, a.ID, &dest.ID); err != nil {
|
||||
t.Fatalf("delete and relocate: %v", err)
|
||||
}
|
||||
children, err := s.Children(ctx, &dest.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("children: %v", err)
|
||||
}
|
||||
if len(children) != 1 || children[0].ID != b.ID {
|
||||
t.Fatalf("expected B relocated under Dest, got %+v", children)
|
||||
}
|
||||
if _, err := s.Get(ctx, a.ID); err != folders.ErrNotFound {
|
||||
t.Fatalf("expected A to be deleted, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFolderCascadeDelete(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s := folders.NewStore(testDB(t))
|
||||
|
||||
a := &models.Folder{Name: "A"}
|
||||
must(t, s.Create(ctx, a))
|
||||
b := &models.Folder{Name: "B", ParentID: &a.ID}
|
||||
must(t, s.Create(ctx, b))
|
||||
c := &models.Folder{Name: "C", ParentID: &b.ID}
|
||||
must(t, s.Create(ctx, c))
|
||||
|
||||
if err := s.CascadeDelete(ctx, a.ID); err != nil {
|
||||
t.Fatalf("cascade delete: %v", err)
|
||||
}
|
||||
for _, id := range []int64{a.ID, b.ID, c.ID} {
|
||||
if _, err := s.Get(ctx, id); err != folders.ErrNotFound {
|
||||
t.Fatalf("expected folder %d deleted, got %v", id, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func must(t *testing.T, err error) {
|
||||
t.Helper()
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
// Package content embeds inventree's HTML templates and static assets
|
||||
// (Bootstrap CSS/JS, icons, vanilla JS) into the compiled binary.
|
||||
package content
|
||||
|
||||
import "embed"
|
||||
|
||||
//go:embed templates
|
||||
var TemplatesFS embed.FS
|
||||
|
||||
//go:embed static
|
||||
var StaticFS embed.FS
|
||||
@@ -0,0 +1,184 @@
|
||||
:root {
|
||||
--tree-indent: 1.15rem;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: #f7f8fa;
|
||||
}
|
||||
|
||||
.app-navbar {
|
||||
background-color: #fff;
|
||||
}
|
||||
|
||||
.login-body {
|
||||
min-height: 100vh;
|
||||
background: linear-gradient(180deg, #eef1f6 0%, #f7f8fa 100%);
|
||||
}
|
||||
|
||||
/* Admin-only UI: shown only when <body> carries the role-admin class. */
|
||||
.admin-only { display: none !important; }
|
||||
body.role-admin .admin-only { display: inherit !important; }
|
||||
body.role-admin button.admin-only,
|
||||
body.role-admin a.admin-only { display: inline-block !important; }
|
||||
|
||||
.inventory-shell {
|
||||
height: calc(100vh - 56px);
|
||||
min-height: 480px;
|
||||
}
|
||||
|
||||
.tree-pane {
|
||||
width: 340px;
|
||||
min-width: 260px;
|
||||
max-width: 45vw;
|
||||
background-color: #fff;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tree-scroll {
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.main-panel {
|
||||
background-color: #fff;
|
||||
}
|
||||
|
||||
ul.tree, ul.tree-children {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
ul.tree-children {
|
||||
padding-left: var(--tree-indent);
|
||||
}
|
||||
|
||||
.tree-node-root > .tree-row {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.tree-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
padding: 0.2rem 0.4rem;
|
||||
border-radius: 0.25rem;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.tree-row:hover {
|
||||
background-color: #f1f3f5;
|
||||
}
|
||||
|
||||
.tree-row.selected {
|
||||
background-color: #e7f1ff;
|
||||
color: #0d6efd;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.tree-row.drop-target {
|
||||
outline: 2px dashed #0d6efd;
|
||||
outline-offset: -2px;
|
||||
background-color: #e7f1ff;
|
||||
}
|
||||
|
||||
.tree-row-more {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.tree-toggle {
|
||||
border: none;
|
||||
background: none;
|
||||
padding: 0;
|
||||
width: 1.1rem;
|
||||
height: 1.1rem;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
color: #6c757d;
|
||||
}
|
||||
|
||||
.tree-toggle i {
|
||||
transition: transform 0.12s ease;
|
||||
}
|
||||
|
||||
.tree-toggle.expanded i {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.tree-toggle-spacer {
|
||||
width: 1.1rem;
|
||||
flex-shrink: 0;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.tree-icon {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tree-label {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.tree-status-badge {
|
||||
font-size: 0.65rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tree-actions {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.btn-tree-action {
|
||||
border: none;
|
||||
background: none;
|
||||
color: #6c757d;
|
||||
padding: 0 0.25rem;
|
||||
}
|
||||
|
||||
.btn-tree-action:hover {
|
||||
color: #212529;
|
||||
}
|
||||
|
||||
.tree-node.dragging {
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
.stat-card .card-body {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 1.4rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 0.75rem;
|
||||
color: #6c757d;
|
||||
}
|
||||
|
||||
.panel-content {
|
||||
max-width: 1100px;
|
||||
}
|
||||
|
||||
.asset-row, .folder-link, .asset-link {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@media (max-width: 767.98px) {
|
||||
.inventory-shell {
|
||||
flex-direction: column;
|
||||
height: auto;
|
||||
}
|
||||
.tree-pane {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
max-height: 40vh;
|
||||
}
|
||||
}
|
||||
Vendored
+6
File diff suppressed because one or more lines are too long
Vendored
+5
File diff suppressed because one or more lines are too long
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,722 @@
|
||||
// inventree - vanilla JS driving the inventory tree and AJAX panels.
|
||||
// No build step, no frameworks: plain DOM APIs + fetch.
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var treePane = document.getElementById('tree-pane');
|
||||
var mainPanel = document.getElementById('main-panel');
|
||||
var isAdmin = document.body.classList.contains('role-admin');
|
||||
var draggedNode = null;
|
||||
var formReturnContext = { type: 'root', id: '' };
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Generic helpers
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
function escapeHtml(s) {
|
||||
return String(s).replace(/[&<>"']/g, function (c) {
|
||||
return { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c];
|
||||
});
|
||||
}
|
||||
|
||||
async function fetchText(url) {
|
||||
var res = await fetch(url);
|
||||
if (!res.ok) throw new Error('Request failed (' + res.status + ')');
|
||||
return res.text();
|
||||
}
|
||||
|
||||
async function apiFetch(url, opts) {
|
||||
opts = opts || {};
|
||||
var fetchOpts = { method: opts.method || 'GET', headers: {} };
|
||||
if (opts.json !== undefined) {
|
||||
fetchOpts.headers['Content-Type'] = 'application/json';
|
||||
fetchOpts.body = JSON.stringify(opts.json);
|
||||
}
|
||||
var res = await fetch(url, fetchOpts);
|
||||
var body = null;
|
||||
var ct = res.headers.get('content-type') || '';
|
||||
if (ct.indexOf('application/json') !== -1) {
|
||||
body = await res.json();
|
||||
}
|
||||
if (!res.ok) {
|
||||
var msg = (body && body.error) ? body.error : ('Request failed (' + res.status + ')');
|
||||
throw new Error(msg);
|
||||
}
|
||||
return body ? body.data : null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Main panel navigation
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
async function loadPanel(type, id) {
|
||||
var url;
|
||||
if (type === 'root') url = '/app/panel/root';
|
||||
else if (type === 'folder') url = '/app/panel/folders/' + id;
|
||||
else if (type === 'asset') url = '/app/panel/assets/' + id;
|
||||
else return;
|
||||
var html = await fetchText(url);
|
||||
mainPanel.innerHTML = html;
|
||||
mainPanel.dataset.contextType = type;
|
||||
mainPanel.dataset.contextId = id || '';
|
||||
}
|
||||
|
||||
async function reloadCurrentPanel() {
|
||||
var type = mainPanel.dataset.contextType || 'root';
|
||||
var id = mainPanel.dataset.contextId || '';
|
||||
if (type !== 'root' && type !== 'folder' && type !== 'asset') return;
|
||||
await loadPanel(type, id);
|
||||
}
|
||||
|
||||
function selectTreeRowVisually(type, id) {
|
||||
document.querySelectorAll('.tree-row.selected').forEach(function (r) { r.classList.remove('selected'); });
|
||||
var row;
|
||||
if (type === 'root') {
|
||||
row = document.querySelector('.tree-row[data-type="root"]');
|
||||
} else {
|
||||
var node = document.querySelector('.tree-node[data-type="' + type + '"][data-id="' + id + '"]');
|
||||
row = node ? node.querySelector(':scope > .tree-row') : null;
|
||||
}
|
||||
if (row) row.classList.add('selected');
|
||||
}
|
||||
|
||||
async function navigateAndSelect(type, id) {
|
||||
await loadPanel(type, id);
|
||||
selectTreeRowVisually(type, id);
|
||||
}
|
||||
|
||||
async function selectNode(row) {
|
||||
document.querySelectorAll('.tree-row.selected').forEach(function (r) { r.classList.remove('selected'); });
|
||||
row.classList.add('selected');
|
||||
var type = row.dataset.type;
|
||||
var id = row.dataset.id || '';
|
||||
await loadPanel(type, id);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Tree: refresh / expand / collapse
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
async function refreshWholeTree() {
|
||||
var ul = document.getElementById('top-level-children');
|
||||
if (!ul) return;
|
||||
var html = await fetchText('/app/tree/children');
|
||||
ul.innerHTML = html;
|
||||
}
|
||||
|
||||
async function toggleFolder(toggleBtn) {
|
||||
var li = toggleBtn.closest('.tree-node');
|
||||
var childUl = li.querySelector(':scope > ul.tree-children');
|
||||
var id = toggleBtn.dataset.id;
|
||||
var expanded = toggleBtn.classList.contains('expanded');
|
||||
if (expanded) {
|
||||
toggleBtn.classList.remove('expanded');
|
||||
if (childUl) childUl.classList.add('d-none');
|
||||
fetch('/app/tree/collapse', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: 'folder_id=' + encodeURIComponent(id),
|
||||
});
|
||||
} else {
|
||||
toggleBtn.classList.add('expanded');
|
||||
if (childUl) {
|
||||
childUl.classList.remove('d-none');
|
||||
var html = await fetchText('/app/tree/children?parent_id=' + encodeURIComponent(id) + '&persist=1');
|
||||
childUl.innerHTML = html;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function expandAllUnder(ul) {
|
||||
var folderLis = Array.prototype.filter.call(ul.children, function (li) {
|
||||
return li.dataset && li.dataset.type === 'folder';
|
||||
});
|
||||
for (var i = 0; i < folderLis.length; i++) {
|
||||
var li = folderLis[i];
|
||||
var toggle = li.querySelector(':scope > .tree-row > .tree-toggle');
|
||||
if (toggle && !toggle.classList.contains('expanded')) {
|
||||
await toggleFolder(toggle);
|
||||
}
|
||||
var childUl = li.querySelector(':scope > ul.tree-children');
|
||||
if (childUl) await expandAllUnder(childUl);
|
||||
}
|
||||
}
|
||||
|
||||
async function expandAllHandler() {
|
||||
var top = document.getElementById('top-level-children');
|
||||
if (top) await expandAllUnder(top);
|
||||
}
|
||||
|
||||
async function collapseAllHandler() {
|
||||
document.querySelectorAll('.tree-toggle.expanded').forEach(function (t) {
|
||||
t.classList.remove('expanded');
|
||||
var li = t.closest('.tree-node');
|
||||
var childUl = li ? li.querySelector(':scope > ul.tree-children') : null;
|
||||
if (childUl) childUl.classList.add('d-none');
|
||||
});
|
||||
try { await fetch('/app/tree/collapse-all', { method: 'POST' }); } catch (e) { /* ignore */ }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Tree context menu ("...")
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
var ctxMenu = null;
|
||||
|
||||
function ensureCtxMenu() {
|
||||
if (ctxMenu) return ctxMenu;
|
||||
ctxMenu = document.createElement('div');
|
||||
ctxMenu.className = 'dropdown-menu tree-ctx-menu';
|
||||
ctxMenu.style.position = 'fixed';
|
||||
ctxMenu.style.display = 'none';
|
||||
ctxMenu.innerHTML =
|
||||
'<button type="button" class="dropdown-item" data-menu-action="new-subfolder"><i class="bi bi-folder-plus"></i> New Subfolder</button>' +
|
||||
'<button type="button" class="dropdown-item" data-menu-action="new-asset"><i class="bi bi-plus-lg"></i> Add Asset</button>' +
|
||||
'<button type="button" class="dropdown-item" data-menu-action="rename"><i class="bi bi-pencil"></i> Rename</button>' +
|
||||
'<button type="button" class="dropdown-item text-danger" data-menu-action="delete"><i class="bi bi-trash"></i> Delete</button>';
|
||||
document.body.appendChild(ctxMenu);
|
||||
return ctxMenu;
|
||||
}
|
||||
|
||||
function closeCtxMenu() {
|
||||
if (ctxMenu) ctxMenu.style.display = 'none';
|
||||
}
|
||||
|
||||
function openTreeContextMenu(btn) {
|
||||
var menu = ensureCtxMenu();
|
||||
var rect = btn.getBoundingClientRect();
|
||||
menu.style.left = Math.min(rect.left, window.innerWidth - 200) + 'px';
|
||||
menu.style.top = rect.bottom + 'px';
|
||||
menu.style.display = 'block';
|
||||
menu.dataset.folderId = btn.dataset.id;
|
||||
}
|
||||
|
||||
document.addEventListener('click', function (e) {
|
||||
if (ctxMenu && ctxMenu.style.display === 'block' && !ctxMenu.contains(e.target) && !e.target.closest('.btn-tree-action')) {
|
||||
closeCtxMenu();
|
||||
}
|
||||
var menuAction = e.target.closest('[data-menu-action]');
|
||||
if (menuAction && ctxMenu) {
|
||||
var id = ctxMenu.dataset.folderId;
|
||||
var action = menuAction.dataset.menuAction;
|
||||
closeCtxMenu();
|
||||
if (action === 'new-subfolder') openFolderModal({ parentId: id });
|
||||
else if (action === 'new-asset') loadAssetForm({ folderId: id });
|
||||
else if (action === 'rename') openRenameFolder(id);
|
||||
else if (action === 'delete') startDeleteFolder(id);
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Folder create / rename modal
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
var folderModalEl = document.getElementById('folderModal');
|
||||
var folderModal = (folderModalEl && window.bootstrap) ? new bootstrap.Modal(folderModalEl) : null;
|
||||
|
||||
function openFolderModal(opts) {
|
||||
if (!folderModal) return;
|
||||
document.getElementById('folderFormId').value = '';
|
||||
document.getElementById('folderFormParentId').value = (opts && opts.parentId) || '';
|
||||
document.getElementById('folderFormName').value = '';
|
||||
document.getElementById('folderFormDescription').value = '';
|
||||
document.getElementById('folderFormIcon').value = '';
|
||||
document.getElementById('folderFormSortOrder').value = '0';
|
||||
document.getElementById('folderModalTitle').textContent = 'New Folder';
|
||||
document.getElementById('folderFormError').classList.add('d-none');
|
||||
folderModal.show();
|
||||
}
|
||||
|
||||
async function openRenameFolder(id) {
|
||||
if (!folderModal) return;
|
||||
try {
|
||||
var f = await apiFetch('/app/folders/' + id);
|
||||
document.getElementById('folderFormId').value = f.ID;
|
||||
document.getElementById('folderFormParentId').value = '';
|
||||
document.getElementById('folderFormName').value = f.Name;
|
||||
document.getElementById('folderFormDescription').value = f.Description;
|
||||
document.getElementById('folderFormIcon').value = f.Icon;
|
||||
document.getElementById('folderFormSortOrder').value = f.SortOrder;
|
||||
document.getElementById('folderModalTitle').textContent = 'Rename Folder';
|
||||
document.getElementById('folderFormError').classList.add('d-none');
|
||||
folderModal.show();
|
||||
} catch (err) {
|
||||
alert('Could not load folder: ' + err.message);
|
||||
}
|
||||
}
|
||||
|
||||
var folderFormEl = document.getElementById('folderForm');
|
||||
if (folderFormEl) {
|
||||
folderFormEl.addEventListener('submit', async function (e) {
|
||||
e.preventDefault();
|
||||
var id = document.getElementById('folderFormId').value;
|
||||
var payload = {
|
||||
name: document.getElementById('folderFormName').value,
|
||||
description: document.getElementById('folderFormDescription').value,
|
||||
icon: document.getElementById('folderFormIcon').value,
|
||||
sort_order: Number(document.getElementById('folderFormSortOrder').value || 0),
|
||||
};
|
||||
var errorBox = document.getElementById('folderFormError');
|
||||
errorBox.classList.add('d-none');
|
||||
try {
|
||||
if (id) {
|
||||
await apiFetch('/app/folders/' + id, { method: 'PATCH', json: payload });
|
||||
} else {
|
||||
var parentId = document.getElementById('folderFormParentId').value;
|
||||
payload.parent_id = parentId ? Number(parentId) : null;
|
||||
await apiFetch('/app/folders', { method: 'POST', json: payload });
|
||||
}
|
||||
folderModal.hide();
|
||||
await refreshWholeTree();
|
||||
await reloadCurrentPanel();
|
||||
} catch (err) {
|
||||
errorBox.textContent = err.message;
|
||||
errorBox.classList.remove('d-none');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Folder delete modal
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
var deleteFolderModalEl = document.getElementById('deleteFolderModal');
|
||||
var deleteFolderModal = (deleteFolderModalEl && window.bootstrap) ? new bootstrap.Modal(deleteFolderModalEl) : null;
|
||||
|
||||
async function startDeleteFolder(id) {
|
||||
if (!deleteFolderModal) return;
|
||||
try {
|
||||
var info = await apiFetch('/app/folders/' + id + '/delete-info');
|
||||
deleteFolderModalEl.dataset.folderId = id;
|
||||
document.getElementById('deleteFolderName').textContent = info.folder_name;
|
||||
var empty = info.folder_count === 0 && info.asset_count === 0;
|
||||
document.getElementById('deleteFolderEmptyNotice').classList.toggle('d-none', !empty);
|
||||
document.getElementById('deleteFolderNotEmptyNotice').classList.toggle('d-none', empty);
|
||||
if (!empty) {
|
||||
document.getElementById('deleteFolderCounts').textContent =
|
||||
info.folder_count + ' folder(s) and ' + info.asset_count + ' asset(s)';
|
||||
var sel = document.getElementById('deleteFolderDestination');
|
||||
sel.innerHTML = info.destinations.map(function (d) {
|
||||
return '<option value="' + d.id + '">' + escapeHtml(d.path) + '</option>';
|
||||
}).join('');
|
||||
document.getElementById('modeRelocate').checked = true;
|
||||
}
|
||||
deleteFolderModal.show();
|
||||
} catch (err) {
|
||||
alert('Could not load folder info: ' + err.message);
|
||||
}
|
||||
}
|
||||
|
||||
var confirmDeleteFolderBtn = document.getElementById('confirmDeleteFolderBtn');
|
||||
if (confirmDeleteFolderBtn) {
|
||||
confirmDeleteFolderBtn.addEventListener('click', async function () {
|
||||
var id = deleteFolderModalEl.dataset.folderId;
|
||||
var notEmpty = !document.getElementById('deleteFolderNotEmptyNotice').classList.contains('d-none');
|
||||
var url = '/app/folders/' + id;
|
||||
if (notEmpty) {
|
||||
var mode = document.querySelector('input[name="delete_mode"]:checked').value;
|
||||
if (mode === 'cascade') {
|
||||
url += '?mode=cascade';
|
||||
} else {
|
||||
var dest = document.getElementById('deleteFolderDestination').value;
|
||||
url += '?mode=relocate&dest=' + encodeURIComponent(dest);
|
||||
}
|
||||
}
|
||||
try {
|
||||
await apiFetch(url, { method: 'DELETE' });
|
||||
deleteFolderModal.hide();
|
||||
await refreshWholeTree();
|
||||
await navigateAndSelect('root', '');
|
||||
} catch (err) {
|
||||
alert('Delete failed: ' + err.message);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Asset form (create / edit)
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
async function loadAssetForm(opts) {
|
||||
formReturnContext = { type: mainPanel.dataset.contextType || 'root', id: mainPanel.dataset.contextId || '' };
|
||||
var params = new URLSearchParams();
|
||||
if (opts && opts.folderId) params.set('folder_id', opts.folderId);
|
||||
var qs = params.toString();
|
||||
var html = await fetchText('/app/panel/assets/new' + (qs ? '?' + qs : ''));
|
||||
mainPanel.innerHTML = html;
|
||||
mainPanel.dataset.contextType = 'asset-form';
|
||||
mainPanel.dataset.contextId = '';
|
||||
}
|
||||
|
||||
async function loadAssetEditForm(id) {
|
||||
var html = await fetchText('/app/panel/assets/' + id + '/edit');
|
||||
mainPanel.innerHTML = html;
|
||||
mainPanel.dataset.contextType = 'asset-form';
|
||||
mainPanel.dataset.contextId = id;
|
||||
}
|
||||
|
||||
function cancelAssetForm(btn) {
|
||||
if (btn.dataset.id) {
|
||||
navigateAndSelect('asset', btn.dataset.id);
|
||||
} else {
|
||||
navigateAndSelect(formReturnContext.type, formReturnContext.id);
|
||||
}
|
||||
}
|
||||
|
||||
async function submitAssetForm(form) {
|
||||
var errorBox = document.getElementById('assetFormError');
|
||||
errorBox.classList.add('d-none');
|
||||
var fd = new FormData(form);
|
||||
var payload = { fields: {} };
|
||||
for (var pair of fd.entries()) {
|
||||
var key = pair[0], value = pair[1];
|
||||
if (key.indexOf('field_') === 0) {
|
||||
payload.fields[key.slice(6)] = value;
|
||||
} else if (key === 'item_type_id') {
|
||||
payload.item_type_id = Number(value);
|
||||
} else if (key === 'folder_id') {
|
||||
payload.folder_id = value ? Number(value) : null;
|
||||
} else if (key === 'status_id') {
|
||||
payload.status_id = Number(value);
|
||||
} else if (key === 'assigned_user_id') {
|
||||
payload.assigned_user_id = value ? Number(value) : null;
|
||||
} else {
|
||||
payload[key] = value;
|
||||
}
|
||||
}
|
||||
var mode = form.dataset.mode;
|
||||
var url = mode === 'edit' ? '/app/assets/' + form.dataset.assetId : '/app/assets';
|
||||
var method = mode === 'edit' ? 'PATCH' : 'POST';
|
||||
try {
|
||||
var res = await apiFetch(url, { method: method, json: payload });
|
||||
await refreshWholeTree();
|
||||
await navigateAndSelect('asset', res.id);
|
||||
} catch (err) {
|
||||
errorBox.textContent = err.message;
|
||||
errorBox.classList.remove('d-none');
|
||||
}
|
||||
}
|
||||
|
||||
async function duplicateAsset(id) {
|
||||
try {
|
||||
var res = await apiFetch('/app/assets/' + id + '/duplicate', { method: 'POST' });
|
||||
await refreshWholeTree();
|
||||
await navigateAndSelect('asset', res.id);
|
||||
} catch (err) {
|
||||
alert('Duplicate failed: ' + err.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteAsset(id, name) {
|
||||
if (!confirm('Delete asset "' + name + '"? This cannot be undone.')) return;
|
||||
try {
|
||||
var res = await apiFetch('/app/assets/' + id, { method: 'DELETE' });
|
||||
await refreshWholeTree();
|
||||
if (res.folder_id) await navigateAndSelect('folder', res.folder_id);
|
||||
else await navigateAndSelect('root', '');
|
||||
} catch (err) {
|
||||
alert('Delete failed: ' + err.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmMoveAsset(id, selectId) {
|
||||
var select = document.getElementById(selectId);
|
||||
var val = select.value;
|
||||
try {
|
||||
await apiFetch('/app/assets/' + id + '/move', { method: 'POST', json: { folder_id: val ? Number(val) : null } });
|
||||
var modalEl = select.closest('.modal');
|
||||
if (modalEl && window.bootstrap) {
|
||||
var inst = bootstrap.Modal.getInstance(modalEl);
|
||||
if (inst) inst.hide();
|
||||
}
|
||||
await refreshWholeTree();
|
||||
await navigateAndSelect('asset', id);
|
||||
} catch (err) {
|
||||
alert('Move failed: ' + err.message);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Folder listing filters / pagination
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
async function loadFolderPanelWithFilters(folderId, overrides) {
|
||||
var form = mainPanel.querySelector('.panel-filter-form');
|
||||
var params = new URLSearchParams();
|
||||
if (form) {
|
||||
var fd = new FormData(form);
|
||||
for (var pair of fd.entries()) {
|
||||
if (pair[1]) params.set(pair[0], pair[1]);
|
||||
}
|
||||
}
|
||||
if (overrides) {
|
||||
for (var k in overrides) params.set(k, overrides[k]);
|
||||
}
|
||||
var qs = params.toString();
|
||||
var html = await fetchText('/app/panel/folders/' + folderId + (qs ? '?' + qs : ''));
|
||||
mainPanel.innerHTML = html;
|
||||
mainPanel.dataset.contextType = 'folder';
|
||||
mainPanel.dataset.contextId = folderId;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Search
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
async function runSearch(q) {
|
||||
if (!q || !q.trim()) return;
|
||||
var html = await fetchText('/app/search?q=' + encodeURIComponent(q));
|
||||
mainPanel.innerHTML = html;
|
||||
mainPanel.dataset.contextType = 'search';
|
||||
mainPanel.dataset.contextId = '';
|
||||
document.querySelectorAll('.tree-row.selected').forEach(function (r) { r.classList.remove('selected'); });
|
||||
}
|
||||
|
||||
var treeSearchEl = document.getElementById('tree-search');
|
||||
if (treeSearchEl) {
|
||||
var searchDebounce;
|
||||
treeSearchEl.addEventListener('keydown', function (e) {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
runSearch(treeSearchEl.value);
|
||||
}
|
||||
});
|
||||
treeSearchEl.addEventListener('input', function () {
|
||||
clearTimeout(searchDebounce);
|
||||
var q = treeSearchEl.value;
|
||||
if (!q) return;
|
||||
searchDebounce = setTimeout(function () { runSearch(q); }, 400);
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// CSV import
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
var importFormEl = document.getElementById('importForm');
|
||||
if (importFormEl) {
|
||||
importFormEl.addEventListener('submit', async function (e) {
|
||||
e.preventDefault();
|
||||
var resultBox = document.getElementById('importResult');
|
||||
resultBox.innerHTML = '<div class="text-body-secondary">Importing…</div>';
|
||||
var fd = new FormData(importFormEl);
|
||||
try {
|
||||
var res = await fetch('/import/assets.csv', { method: 'POST', body: fd });
|
||||
var body = await res.json();
|
||||
if (!res.ok || !body.ok) throw new Error(body.error || 'Import failed');
|
||||
var data = body.data;
|
||||
var html = '<div class="alert alert-success py-2">Created ' + data.Created + ', updated ' + data.Updated + '.</div>';
|
||||
if (data.Errors && data.Errors.length) {
|
||||
html += '<div class="alert alert-warning py-2"><strong>' + data.Errors.length + ' note(s):</strong><ul class="mb-0">' +
|
||||
data.Errors.map(function (m) { return '<li>' + escapeHtml(m) + '</li>'; }).join('') + '</ul></div>';
|
||||
}
|
||||
resultBox.innerHTML = html;
|
||||
await refreshWholeTree();
|
||||
await reloadCurrentPanel();
|
||||
} catch (err) {
|
||||
resultBox.innerHTML = '<div class="alert alert-danger py-2">' + escapeHtml(err.message) + '</div>';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Tree pane: click delegation
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
if (treePane) {
|
||||
treePane.addEventListener('click', function (e) {
|
||||
if (e.target.closest('#btn-expand-all')) { expandAllHandler(); return; }
|
||||
if (e.target.closest('#btn-collapse-all')) { collapseAllHandler(); return; }
|
||||
if (e.target.closest('#btn-new-root-folder')) { openFolderModal({ parentId: null }); return; }
|
||||
|
||||
var toggle = e.target.closest('.tree-toggle');
|
||||
if (toggle) { e.stopPropagation(); toggleFolder(toggle); return; }
|
||||
|
||||
var actionBtn = e.target.closest('.btn-tree-action');
|
||||
if (actionBtn) { e.stopPropagation(); openTreeContextMenu(actionBtn); return; }
|
||||
|
||||
var row = e.target.closest('.tree-row');
|
||||
if (row && row.dataset.type && !row.classList.contains('tree-row-more')) {
|
||||
selectNode(row);
|
||||
}
|
||||
});
|
||||
|
||||
// Drag and drop (admin only)
|
||||
treePane.addEventListener('dragstart', function (e) {
|
||||
if (!isAdmin) { e.preventDefault(); return; }
|
||||
var li = e.target.closest('.tree-node[draggable="true"]');
|
||||
if (!li) return;
|
||||
draggedNode = { type: li.dataset.type, id: li.dataset.id };
|
||||
li.classList.add('dragging');
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
e.dataTransfer.setData('text/plain', draggedNode.type + ':' + draggedNode.id);
|
||||
});
|
||||
|
||||
treePane.addEventListener('dragend', function () {
|
||||
document.querySelectorAll('.tree-node.dragging').forEach(function (n) { n.classList.remove('dragging'); });
|
||||
document.querySelectorAll('.tree-row.drop-target').forEach(function (n) { n.classList.remove('drop-target'); });
|
||||
draggedNode = null;
|
||||
});
|
||||
|
||||
treePane.addEventListener('dragover', function (e) {
|
||||
if (!draggedNode) return;
|
||||
var row = e.target.closest('.tree-row');
|
||||
if (!row) return;
|
||||
var type = row.dataset.type;
|
||||
if (type !== 'folder' && type !== 'root') return;
|
||||
if (type === 'folder' && draggedNode.type === 'folder' && draggedNode.id === row.dataset.id) return;
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = 'move';
|
||||
row.classList.add('drop-target');
|
||||
});
|
||||
|
||||
treePane.addEventListener('dragleave', function (e) {
|
||||
var row = e.target.closest('.tree-row');
|
||||
if (row) row.classList.remove('drop-target');
|
||||
});
|
||||
|
||||
treePane.addEventListener('drop', async function (e) {
|
||||
if (!draggedNode) return;
|
||||
var row = e.target.closest('.tree-row');
|
||||
if (!row) return;
|
||||
var type = row.dataset.type;
|
||||
if (type !== 'folder' && type !== 'root') return;
|
||||
e.preventDefault();
|
||||
row.classList.remove('drop-target');
|
||||
var destId = type === 'root' ? null : Number(row.dataset.id);
|
||||
var dragged = draggedNode;
|
||||
draggedNode = null;
|
||||
try {
|
||||
if (dragged.type === 'folder') {
|
||||
await apiFetch('/app/folders/' + dragged.id + '/move', { method: 'POST', json: { parent_id: destId } });
|
||||
} else if (dragged.type === 'asset') {
|
||||
await apiFetch('/app/assets/' + dragged.id + '/move', { method: 'POST', json: { folder_id: destId } });
|
||||
}
|
||||
await refreshWholeTree();
|
||||
await reloadCurrentPanel();
|
||||
} catch (err) {
|
||||
alert('Move failed: ' + err.message);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Main panel: click / change / submit delegation
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
if (mainPanel) {
|
||||
mainPanel.addEventListener('click', function (e) {
|
||||
var folderLink = e.target.closest('.folder-link');
|
||||
if (folderLink) { e.preventDefault(); navigateAndSelect('folder', folderLink.dataset.id); return; }
|
||||
|
||||
var assetLink = e.target.closest('.asset-link, .asset-row');
|
||||
if (assetLink) { e.preventDefault(); navigateAndSelect('asset', assetLink.dataset.id); return; }
|
||||
|
||||
var crumb = e.target.closest('.breadcrumb-link');
|
||||
if (crumb) { e.preventDefault(); navigateAndSelect(crumb.dataset.type, crumb.dataset.id || ''); return; }
|
||||
|
||||
var pageLink = e.target.closest('.panel-page-link');
|
||||
if (pageLink) { e.preventDefault(); loadFolderPanelWithFilters(pageLink.dataset.folderId, { page: pageLink.dataset.page }); return; }
|
||||
|
||||
var newSub = e.target.closest('[data-action="new-subfolder"]');
|
||||
if (newSub) { openFolderModal({ parentId: newSub.dataset.id }); return; }
|
||||
|
||||
var renameBtn = e.target.closest('[data-action="rename-folder"]');
|
||||
if (renameBtn) { openRenameFolder(renameBtn.dataset.id); return; }
|
||||
|
||||
var deleteBtn = e.target.closest('[data-action="delete-folder"]');
|
||||
if (deleteBtn) { startDeleteFolder(deleteBtn.dataset.id); return; }
|
||||
|
||||
var newAssetBtn = e.target.closest('[data-action="new-asset"]');
|
||||
if (newAssetBtn) { loadAssetForm({ folderId: newAssetBtn.dataset.folderId }); return; }
|
||||
|
||||
var editAssetBtn = e.target.closest('[data-action="edit-asset"]');
|
||||
if (editAssetBtn) { loadAssetEditForm(editAssetBtn.dataset.id); return; }
|
||||
|
||||
var dupBtn = e.target.closest('[data-action="duplicate-asset"]');
|
||||
if (dupBtn) { duplicateAsset(dupBtn.dataset.id); return; }
|
||||
|
||||
var delAssetBtn = e.target.closest('[data-action="delete-asset"]');
|
||||
if (delAssetBtn) { deleteAsset(delAssetBtn.dataset.id, delAssetBtn.dataset.name); return; }
|
||||
|
||||
var confirmMoveBtn = e.target.closest('[data-action="confirm-move-asset"]');
|
||||
if (confirmMoveBtn) { confirmMoveAsset(confirmMoveBtn.dataset.id, confirmMoveBtn.dataset.select); return; }
|
||||
|
||||
var cancelFormBtn = e.target.closest('[data-action="cancel-asset-form"]');
|
||||
if (cancelFormBtn) { cancelAssetForm(cancelFormBtn); return; }
|
||||
});
|
||||
|
||||
mainPanel.addEventListener('change', async function (e) {
|
||||
if (e.target.id === 'assetFormItemType') {
|
||||
var html = await fetchText('/app/forms/asset-fields?item_type_id=' + e.target.value);
|
||||
var container = document.getElementById('asset-fields-container');
|
||||
if (container) container.innerHTML = html;
|
||||
return;
|
||||
}
|
||||
var filterForm = e.target.closest('.panel-filter-form');
|
||||
if (filterForm) {
|
||||
loadFolderPanelWithFilters(filterForm.dataset.folderId, { page: 1 });
|
||||
}
|
||||
});
|
||||
|
||||
var filterDebounce;
|
||||
mainPanel.addEventListener('input', function (e) {
|
||||
if (e.target.matches && e.target.matches('.panel-filter-form input[name="q"]')) {
|
||||
clearTimeout(filterDebounce);
|
||||
var form = e.target.closest('.panel-filter-form');
|
||||
filterDebounce = setTimeout(function () { loadFolderPanelWithFilters(form.dataset.folderId, { page: 1 }); }, 350);
|
||||
}
|
||||
});
|
||||
|
||||
mainPanel.addEventListener('submit', function (e) {
|
||||
if (e.target.matches && e.target.matches('.panel-filter-form')) {
|
||||
e.preventDefault();
|
||||
loadFolderPanelWithFilters(e.target.dataset.folderId, { page: 1 });
|
||||
return;
|
||||
}
|
||||
if (e.target.id === 'assetForm') {
|
||||
e.preventDefault();
|
||||
submitAssetForm(e.target);
|
||||
}
|
||||
});
|
||||
|
||||
// Assets/folders are also draggable as drop-into-root targets from the
|
||||
// panel view when browsing (drag onto the tree's root row still works
|
||||
// via the tree pane handler above); dragstart on panel rows is not
|
||||
// implemented since the tree is the primary drag surface.
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Item type custom field admin form (item-types/{id}/edit page)
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
var fieldForm = document.getElementById('fieldForm');
|
||||
if (fieldForm) {
|
||||
document.addEventListener('click', function (e) {
|
||||
var editBtn = e.target.closest('.field-edit-btn');
|
||||
if (editBtn) {
|
||||
document.getElementById('fieldFormTitle').textContent = 'Edit Field';
|
||||
document.getElementById('fieldFormName').value = editBtn.dataset.name;
|
||||
document.getElementById('fieldFormType').value = editBtn.dataset.type;
|
||||
document.getElementById('fieldFormDefault').value = editBtn.dataset.default;
|
||||
document.getElementById('fieldFormOptions').value = editBtn.dataset.options || '';
|
||||
document.getElementById('fieldFormRequired').checked = editBtn.dataset.required === 'true';
|
||||
document.getElementById('fieldFormSortOrder').value = editBtn.dataset.sortOrder;
|
||||
document.getElementById('fieldFormSubmit').textContent = 'Save';
|
||||
fieldForm.action = window.itemTypeFieldsBaseAction + '/' + editBtn.dataset.id;
|
||||
var cancelBtn = document.getElementById('fieldFormCancel');
|
||||
cancelBtn.classList.remove('d-none');
|
||||
fieldForm.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
return;
|
||||
}
|
||||
var cancelBtn2 = e.target.closest('#fieldFormCancel');
|
||||
if (cancelBtn2) {
|
||||
document.getElementById('fieldFormTitle').textContent = 'Add Field';
|
||||
fieldForm.reset();
|
||||
document.getElementById('fieldFormSubmit').textContent = 'Add';
|
||||
fieldForm.action = window.itemTypeFieldsBaseAction;
|
||||
cancelBtn2.classList.add('d-none');
|
||||
}
|
||||
});
|
||||
}
|
||||
})();
|
||||
+7
File diff suppressed because one or more lines are too long
@@ -0,0 +1,54 @@
|
||||
{{define "layout"}}<!doctype html>
|
||||
<html lang="en" data-bs-theme="light">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>inventree</title>
|
||||
<link rel="stylesheet" href="/static/css/bootstrap.min.css">
|
||||
<link rel="stylesheet" href="/static/icons/bootstrap-icons.css">
|
||||
<link rel="stylesheet" href="/static/css/app.css">
|
||||
</head>
|
||||
<body class="role-{{.User.Role}}">
|
||||
<nav class="navbar navbar-expand-lg app-navbar border-bottom px-3">
|
||||
<a class="navbar-brand fw-semibold d-flex align-items-center gap-2" href="/inventory">
|
||||
<i class="bi bi-diagram-3-fill"></i> inventree
|
||||
</a>
|
||||
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navMain">
|
||||
<span class="navbar-toggler-icon"></span>
|
||||
</button>
|
||||
<div class="collapse navbar-collapse" id="navMain">
|
||||
<ul class="navbar-nav me-auto">
|
||||
<li class="nav-item"><a class="nav-link" href="/inventory">Inventory</a></li>
|
||||
{{if .User.IsAdmin}}
|
||||
<li class="nav-item"><a class="nav-link" href="/item-types">Item Types</a></li>
|
||||
<li class="nav-item"><a class="nav-link" href="/users">Users</a></li>
|
||||
<li class="nav-item"><a class="nav-link" href="/settings">Settings</a></li>
|
||||
{{end}}
|
||||
</ul>
|
||||
<ul class="navbar-nav">
|
||||
<li class="nav-item dropdown">
|
||||
<a class="nav-link dropdown-toggle d-flex align-items-center gap-1" href="#" role="button" data-bs-toggle="dropdown">
|
||||
<i class="bi bi-person-circle"></i> {{.User.Username}}
|
||||
{{if .User.IsAdmin}}<span class="badge text-bg-secondary ms-1">admin</span>{{end}}
|
||||
</a>
|
||||
<ul class="dropdown-menu dropdown-menu-end">
|
||||
<li>
|
||||
<form action="/logout" method="post" class="px-3 py-1">
|
||||
<button type="submit" class="btn btn-sm btn-outline-secondary w-100">Sign out</button>
|
||||
</form>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<main>
|
||||
{{template "content" .}}
|
||||
</main>
|
||||
|
||||
<script src="/static/js/bootstrap.bundle.min.js"></script>
|
||||
<script src="/static/js/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
@@ -0,0 +1,43 @@
|
||||
{{define "login"}}<!doctype html>
|
||||
<html lang="en" data-bs-theme="light">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Sign in · inventree</title>
|
||||
<link rel="stylesheet" href="/static/css/bootstrap.min.css">
|
||||
<link rel="stylesheet" href="/static/icons/bootstrap-icons.css">
|
||||
<link rel="stylesheet" href="/static/css/app.css">
|
||||
</head>
|
||||
<body class="login-body d-flex align-items-center">
|
||||
<div class="container">
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-11 col-sm-8 col-md-5 col-lg-4">
|
||||
<div class="text-center mb-4">
|
||||
<div class="display-6"><i class="bi bi-diagram-3-fill"></i></div>
|
||||
<h1 class="h4 mt-2 mb-0">inventree</h1>
|
||||
<div class="text-body-secondary small">IT asset inventory</div>
|
||||
</div>
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-body p-4">
|
||||
{{if .Error}}<div class="alert alert-danger py-2">{{.Error}}</div>{{end}}
|
||||
<form method="post" action="/login">
|
||||
<input type="hidden" name="next" value="{{.Next}}">
|
||||
<div class="mb-3">
|
||||
<label class="form-label" for="username">Username</label>
|
||||
<input class="form-control" type="text" id="username" name="username" autofocus required autocomplete="username">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label" for="password">Password</label>
|
||||
<input class="form-control" type="password" id="password" name="password" required autocomplete="current-password">
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary w-100">Sign in</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-center text-body-secondary small mt-3">Default login: admin / admin</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
@@ -0,0 +1,43 @@
|
||||
{{define "content"}}
|
||||
<div class="inventory-shell d-flex">
|
||||
<aside id="tree-pane" class="tree-pane border-end d-flex flex-column">
|
||||
<div class="p-2 border-bottom">
|
||||
<div class="input-group input-group-sm mb-2">
|
||||
<span class="input-group-text"><i class="bi bi-search"></i></span>
|
||||
<input type="search" id="tree-search" class="form-control" placeholder="Search inventory…">
|
||||
</div>
|
||||
<div class="d-flex gap-1 flex-wrap">
|
||||
<button class="btn btn-sm btn-outline-secondary" id="btn-expand-all" title="Expand all folders"><i class="bi bi-arrows-expand"></i> Expand</button>
|
||||
<button class="btn btn-sm btn-outline-secondary" id="btn-collapse-all" title="Collapse all folders"><i class="bi bi-arrows-collapse"></i> Collapse</button>
|
||||
{{if .User.IsAdmin}}
|
||||
<button class="btn btn-sm btn-primary ms-auto" id="btn-new-root-folder" data-parent-id="" title="New top-level folder">
|
||||
<i class="bi bi-folder-plus"></i>
|
||||
</button>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
<div class="tree-scroll flex-fill overflow-auto">
|
||||
<ul class="tree list-unstyled mb-0" id="tree-root">
|
||||
<li class="tree-node tree-node-root" data-type="root">
|
||||
<div class="tree-row selected" data-type="root" tabindex="0">
|
||||
<span class="tree-toggle-spacer"></span>
|
||||
<i class="bi bi-house-door-fill tree-icon text-primary"></i>
|
||||
<span class="tree-label">Inventory</span>
|
||||
</div>
|
||||
</li>
|
||||
<li>
|
||||
<ul class="tree-children list-unstyled" id="top-level-children" data-expanded="true">
|
||||
{{template "tree_children" .Tree}}
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<section id="main-panel" class="main-panel flex-fill overflow-auto" data-context-type="root" data-context-id="">
|
||||
{{template "panel_root" .Root}}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{{template "modals" .}}
|
||||
{{end}}
|
||||
@@ -0,0 +1,120 @@
|
||||
{{define "content"}}
|
||||
<div class="container-fluid p-3" style="max-width: 900px">
|
||||
<div class="d-flex align-items-center mb-3">
|
||||
<h4 class="mb-0">{{if .ItemType}}Edit Item Type{{else}}New Item Type{{end}}</h4>
|
||||
<a href="/item-types" class="btn btn-sm btn-outline-secondary ms-auto">Back to Item Types</a>
|
||||
</div>
|
||||
|
||||
{{if .Error}}<div class="alert alert-danger">{{.Error}}</div>{{end}}
|
||||
|
||||
<div class="card mb-4">
|
||||
<div class="card-body">
|
||||
<form method="post" action="{{if .ItemType}}/item-types/{{.ItemType.ID}}{{else}}/item-types{{end}}">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Name</label>
|
||||
<input type="text" class="form-control" name="name" value="{{if .ItemType}}{{.ItemType.Name}}{{end}}" required>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Icon <span class="text-body-secondary">(Bootstrap Icons class)</span></label>
|
||||
<input type="text" class="form-control" name="icon" value="{{if .ItemType}}{{.ItemType.Icon}}{{end}}" placeholder="bi-box-seam">
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label class="form-label">Description</label>
|
||||
<textarea class="form-control" name="description" rows="2">{{if .ItemType}}{{.ItemType.Description}}{{end}}</textarea>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Sort Order</label>
|
||||
<input type="number" class="form-control" name="sort_order" value="{{if .ItemType}}{{.ItemType.SortOrder}}{{else}}0{{end}}">
|
||||
</div>
|
||||
<div class="col-md-3 d-flex align-items-end">
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" name="enabled" id="enabled" {{if .ItemType}}{{if .ItemType.Enabled}}checked{{end}}{{else}}checked{{end}}>
|
||||
<label class="form-check-label" for="enabled">Enabled</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary mt-3">Save</button>
|
||||
{{if .ItemType}}
|
||||
<button type="submit" formaction="/item-types/{{.ItemType.ID}}/delete" class="btn btn-outline-danger mt-3" onclick="return confirm('Delete this item type? This is only possible if no assets use it.')">Delete Item Type</button>
|
||||
{{end}}
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{if .ItemType}}
|
||||
<h5 class="mb-2">Custom Fields</h5>
|
||||
<div class="card mb-4">
|
||||
<div class="table-responsive">
|
||||
<table class="table mb-0 align-middle">
|
||||
<thead><tr><th>Name</th><th>Key</th><th>Type</th><th>Required</th><th>Default</th><th>Options</th><th>Order</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{{range .ItemType.Fields}}
|
||||
<tr>
|
||||
<td>{{.Name}}</td>
|
||||
<td><code>{{.FieldKey}}</code></td>
|
||||
<td>{{.FieldType.Label}}</td>
|
||||
<td>{{if .Required}}<i class="bi bi-check-lg text-success"></i>{{end}}</td>
|
||||
<td>{{.DefaultValue}}</td>
|
||||
<td class="text-body-secondary small">{{range .Options}}{{.}}<br>{{end}}</td>
|
||||
<td>{{.SortOrder}}</td>
|
||||
<td class="text-nowrap">
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary field-edit-btn"
|
||||
data-id="{{.ID}}" data-name="{{.Name}}" data-type="{{.FieldType}}"
|
||||
data-required="{{.Required}}" data-default="{{.DefaultValue}}"
|
||||
data-options="{{range $i, $o := .Options}}{{if $i}} {{end}}{{$o}}{{end}}" data-sort-order="{{.SortOrder}}">
|
||||
Edit
|
||||
</button>
|
||||
<form method="post" action="/item-types/{{$.ItemType.ID}}/fields/{{.ID}}/delete" class="d-inline" onsubmit="return confirm('Delete this field? Existing values will be lost.')">
|
||||
<button type="submit" class="btn btn-sm btn-outline-danger">Delete</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
{{if not .ItemType.Fields}}
|
||||
<tr><td colspan="8" class="text-body-secondary">No custom fields yet.</td></tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="card-body border-top">
|
||||
<h6 id="fieldFormTitle">Add Field</h6>
|
||||
<form method="post" action="/item-types/{{.ItemType.ID}}/fields" id="fieldForm" class="row g-2 align-items-end">
|
||||
<div class="col-md-3">
|
||||
<label class="form-label small mb-1">Name</label>
|
||||
<input type="text" class="form-control form-control-sm" name="name" id="fieldFormName" required>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label class="form-label small mb-1">Type</label>
|
||||
<select class="form-select form-select-sm" name="field_type" id="fieldFormType">
|
||||
{{range .FieldTypes}}<option value="{{.}}">{{.Label}}</option>{{end}}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label class="form-label small mb-1">Default Value</label>
|
||||
<input type="text" class="form-control form-control-sm" name="default_value" id="fieldFormDefault">
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label small mb-1">Dropdown Options <span class="text-body-secondary">(one per line)</span></label>
|
||||
<textarea class="form-control form-control-sm" name="options" id="fieldFormOptions" rows="1"></textarea>
|
||||
</div>
|
||||
<div class="col-md-1">
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" name="required" id="fieldFormRequired">
|
||||
<label class="form-check-label small" for="fieldFormRequired">Required</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-1">
|
||||
<button type="submit" class="btn btn-sm btn-primary w-100" id="fieldFormSubmit">Add</button>
|
||||
</div>
|
||||
<input type="hidden" name="sort_order" id="fieldFormSortOrder" value="0">
|
||||
</form>
|
||||
<button type="button" class="btn btn-sm btn-link d-none" id="fieldFormCancel">Cancel edit</button>
|
||||
</div>
|
||||
</div>
|
||||
<script>window.itemTypeFieldsBaseAction = "/item-types/{{.ItemType.ID}}/fields";</script>
|
||||
{{else}}
|
||||
<p class="text-body-secondary">Save the item type first to add custom fields.</p>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
@@ -0,0 +1,27 @@
|
||||
{{define "content"}}
|
||||
<div class="container-fluid p-3">
|
||||
<div class="d-flex align-items-center mb-3">
|
||||
<h4 class="mb-0">Item Types</h4>
|
||||
<a href="/item-types/new" class="btn btn-primary btn-sm ms-auto"><i class="bi bi-plus-lg"></i> New Item Type</a>
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover align-middle">
|
||||
<thead><tr><th>Icon</th><th>Name</th><th>Description</th><th>Custom Fields</th><th>Status</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{{range .ItemTypes}}
|
||||
<tr>
|
||||
<td><i class="bi {{iconOr .Icon "bi-box-seam"}} fs-5"></i></td>
|
||||
<td><a href="/item-types/{{.ID}}/edit">{{.Name}}</a></td>
|
||||
<td class="text-body-secondary">{{.Description}}</td>
|
||||
<td>{{len .Fields}}</td>
|
||||
<td>{{if .Enabled}}<span class="badge text-bg-success">Enabled</span>{{else}}<span class="badge text-bg-secondary">Disabled</span>{{end}}</td>
|
||||
<td class="text-end"><a href="/item-types/{{.ID}}/edit" class="btn btn-sm btn-outline-secondary">Edit</a></td>
|
||||
</tr>
|
||||
{{else}}
|
||||
<tr><td colspan="6" class="text-body-secondary">No item types defined yet.</td></tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
@@ -0,0 +1,86 @@
|
||||
{{define "content"}}
|
||||
<div class="container-fluid p-3" style="max-width: 800px">
|
||||
<h4 class="mb-3">Settings</h4>
|
||||
|
||||
<div class="card mb-4">
|
||||
<div class="card-header py-2 fw-semibold">General</div>
|
||||
<div class="card-body">
|
||||
<form method="post" action="/settings/general" class="row g-3">
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Site Name</label>
|
||||
<input type="text" class="form-control" name="site_name" value="{{.SiteName}}">
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Asset ID Prefix</label>
|
||||
<input type="text" class="form-control" name="asset_id_prefix" value="{{.AssetPrefix}}">
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Asset ID Padding</label>
|
||||
<input type="number" class="form-control" name="asset_id_padding" value="{{.AssetPadding}}" min="1" max="12">
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<button type="submit" class="btn btn-primary">Save</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header py-2 fw-semibold">Statuses</div>
|
||||
<div class="table-responsive">
|
||||
<table class="table mb-0 align-middle">
|
||||
<thead><tr><th>Order</th><th>Preview</th><th>Name & Color</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{{range $i, $st := .Statuses}}
|
||||
<tr>
|
||||
<td class="text-nowrap">
|
||||
<form method="post" action="/settings/statuses/{{$st.ID}}/reorder" class="d-inline">
|
||||
<input type="hidden" name="dir" value="up">
|
||||
<button type="submit" class="btn btn-sm btn-outline-secondary" {{if eq $i 0}}disabled{{end}}><i class="bi bi-arrow-up"></i></button>
|
||||
</form>
|
||||
<form method="post" action="/settings/statuses/{{$st.ID}}/reorder" class="d-inline">
|
||||
<input type="hidden" name="dir" value="down">
|
||||
<button type="submit" class="btn btn-sm btn-outline-secondary" {{if eq (add $i 1) (len $.Statuses)}}disabled{{end}}><i class="bi bi-arrow-down"></i></button>
|
||||
</form>
|
||||
</td>
|
||||
<td><span class="badge text-bg-{{$st.Color}}">{{$st.Name}}</span></td>
|
||||
<td>
|
||||
<form method="post" action="/settings/statuses/{{$st.ID}}" class="d-flex gap-2 align-items-center">
|
||||
<input type="text" class="form-control form-control-sm" name="name" value="{{$st.Name}}" style="max-width:160px" required>
|
||||
<select class="form-select form-select-sm" name="color" style="max-width:130px">
|
||||
{{range (list "primary" "secondary" "success" "danger" "warning" "info" "dark")}}
|
||||
<option value="{{.}}" {{if eq . $st.Color}}selected{{end}}>{{.}}</option>
|
||||
{{end}}
|
||||
</select>
|
||||
<button type="submit" class="btn btn-sm btn-outline-secondary">Save</button>
|
||||
</form>
|
||||
</td>
|
||||
<td>
|
||||
<form method="post" action="/settings/statuses/{{$st.ID}}/delete" onsubmit="return confirm('Delete this status?')">
|
||||
<button type="submit" class="btn btn-sm btn-outline-danger">Delete</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="card-body border-top">
|
||||
<h6>Add Status</h6>
|
||||
<form method="post" action="/settings/statuses" class="d-flex gap-2 align-items-end">
|
||||
<div>
|
||||
<label class="form-label small mb-1">Name</label>
|
||||
<input type="text" class="form-control form-control-sm" name="name" required>
|
||||
</div>
|
||||
<div>
|
||||
<label class="form-label small mb-1">Color</label>
|
||||
<select class="form-select form-select-sm" name="color">
|
||||
{{range (list "primary" "secondary" "success" "danger" "warning" "info" "dark")}}<option value="{{.}}">{{.}}</option>{{end}}
|
||||
</select>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-sm btn-primary">Add</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
@@ -0,0 +1,61 @@
|
||||
{{define "content"}}
|
||||
<div class="container-fluid p-3" style="max-width: 600px">
|
||||
<div class="d-flex align-items-center mb-3">
|
||||
<h4 class="mb-0">{{if .EditUser}}Edit User{{else}}New User{{end}}</h4>
|
||||
<a href="/users" class="btn btn-sm btn-outline-secondary ms-auto">Back to Users</a>
|
||||
</div>
|
||||
|
||||
{{if .Error}}<div class="alert alert-danger">{{.Error}}</div>{{end}}
|
||||
|
||||
<div class="card mb-3">
|
||||
<div class="card-body">
|
||||
<form method="post" action="{{if .EditUser}}/users/{{.EditUser.ID}}{{else}}/users{{end}}">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Username</label>
|
||||
<input type="text" class="form-control" name="username" value="{{if .EditUser}}{{.EditUser.Username}}{{end}}" required autofocus>
|
||||
</div>
|
||||
{{if not .EditUser}}
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Password</label>
|
||||
<input type="password" class="form-control" name="password" required autocomplete="new-password">
|
||||
</div>
|
||||
{{end}}
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Role</label>
|
||||
<select class="form-select" name="role">
|
||||
<option value="user" {{if .EditUser}}{{if eq .EditUser.Role "user"}}selected{{end}}{{end}}>User</option>
|
||||
<option value="admin" {{if .EditUser}}{{if eq .EditUser.Role "admin"}}selected{{end}}{{end}}>Admin</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-check mb-3">
|
||||
<input class="form-check-input" type="checkbox" name="enabled" id="enabled" {{if .EditUser}}{{if .EditUser.Enabled}}checked{{end}}{{else}}checked{{end}}>
|
||||
<label class="form-check-label" for="enabled">Enabled</label>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary">Save</button>
|
||||
</form>
|
||||
{{if .EditUser}}
|
||||
<form method="post" action="/users/{{.EditUser.ID}}/delete" class="d-inline mt-2" onsubmit="return confirm('Delete this user?')">
|
||||
<button type="submit" class="btn btn-outline-danger">Delete User</button>
|
||||
</form>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{if .EditUser}}
|
||||
<div class="card">
|
||||
<div class="card-header py-2 fw-semibold">Change Password</div>
|
||||
<div class="card-body">
|
||||
<form method="post" action="/users/{{.EditUser.ID}}/password" class="row g-2 align-items-end">
|
||||
<div class="col">
|
||||
<label class="form-label">New Password</label>
|
||||
<input type="password" class="form-control" name="password" autocomplete="new-password">
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<button type="submit" class="btn btn-outline-primary">Set Password</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
@@ -0,0 +1,24 @@
|
||||
{{define "content"}}
|
||||
<div class="container-fluid p-3">
|
||||
<div class="d-flex align-items-center mb-3">
|
||||
<h4 class="mb-0">Users</h4>
|
||||
<a href="/users/new" class="btn btn-primary btn-sm ms-auto"><i class="bi bi-person-plus"></i> New User</a>
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover align-middle">
|
||||
<thead><tr><th>Username</th><th>Role</th><th>Status</th><th>Created</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{{range .Users}}
|
||||
<tr>
|
||||
<td><a href="/users/{{.ID}}/edit">{{.Username}}</a></td>
|
||||
<td>{{if .IsAdmin}}<span class="badge text-bg-primary">Admin</span>{{else}}<span class="badge text-bg-secondary">User</span>{{end}}</td>
|
||||
<td>{{if .Enabled}}<span class="badge text-bg-success">Enabled</span>{{else}}<span class="badge text-bg-secondary">Disabled</span>{{end}}</td>
|
||||
<td>{{fmtDateShort .CreatedAt}}</td>
|
||||
<td class="text-end"><a href="/users/{{.ID}}/edit" class="btn btn-sm btn-outline-secondary">Edit</a></td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
@@ -0,0 +1,34 @@
|
||||
{{define "asset_fields_only"}}
|
||||
{{if .}}
|
||||
<h6 class="mt-3 mb-2">Type-Specific Fields</h6>
|
||||
{{range .}}
|
||||
{{$fv := .}}
|
||||
<div class="mb-2">
|
||||
<label class="form-label" for="field-{{.Def.ID}}">{{.Def.Name}}{{if .Def.Required}} <span class="text-danger">*</span>{{end}}</label>
|
||||
{{if eq .Def.FieldType "long_text"}}
|
||||
<textarea class="form-control" id="field-{{.Def.ID}}" name="field_{{.Def.ID}}" rows="3" {{if .Def.Required}}required{{end}}>{{.Value}}</textarea>
|
||||
{{else if eq .Def.FieldType "boolean"}}
|
||||
<select class="form-select" id="field-{{.Def.ID}}" name="field_{{.Def.ID}}">
|
||||
<option value="false" {{if ne .Value "true"}}selected{{end}}>No</option>
|
||||
<option value="true" {{if eq .Value "true"}}selected{{end}}>Yes</option>
|
||||
</select>
|
||||
{{else if eq .Def.FieldType "dropdown"}}
|
||||
<select class="form-select" id="field-{{.Def.ID}}" name="field_{{.Def.ID}}" {{if .Def.Required}}required{{end}}>
|
||||
<option value="">—</option>
|
||||
{{range .Def.Options}}<option value="{{.}}" {{if eq . $fv.Value}}selected{{end}}>{{.}}</option>{{end}}
|
||||
</select>
|
||||
{{else if eq .Def.FieldType "date"}}
|
||||
<input type="date" class="form-control" id="field-{{.Def.ID}}" name="field_{{.Def.ID}}" value="{{.Value}}" {{if .Def.Required}}required{{end}}>
|
||||
{{else if eq .Def.FieldType "number"}}
|
||||
<input type="number" step="any" class="form-control" id="field-{{.Def.ID}}" name="field_{{.Def.ID}}" value="{{.Value}}" {{if .Def.Required}}required{{end}}>
|
||||
{{else if eq .Def.FieldType "url"}}
|
||||
<input type="url" class="form-control" id="field-{{.Def.ID}}" name="field_{{.Def.ID}}" value="{{.Value}}" {{if .Def.Required}}required{{end}}>
|
||||
{{else if eq .Def.FieldType "ip_address"}}
|
||||
<input type="text" pattern="^[0-9a-fA-F:\.]+$" class="form-control" id="field-{{.Def.ID}}" name="field_{{.Def.ID}}" value="{{.Value}}" placeholder="192.168.1.1" {{if .Def.Required}}required{{end}}>
|
||||
{{else}}
|
||||
<input type="text" class="form-control" id="field-{{.Def.ID}}" name="field_{{.Def.ID}}" value="{{.Value}}" {{if .Def.Required}}required{{end}}>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
{{end}}
|
||||
{{end}}
|
||||
@@ -0,0 +1,81 @@
|
||||
{{define "asset_form"}}
|
||||
<div class="panel-content p-3">
|
||||
<nav aria-label="breadcrumb">
|
||||
<ol class="breadcrumb mb-2">
|
||||
<li class="breadcrumb-item"><a href="#" class="breadcrumb-link" data-type="root">Inventory</a></li>
|
||||
<li class="breadcrumb-item active">{{if .Asset}}Edit {{.Asset.Name}}{{else}}New Asset{{end}}</li>
|
||||
</ol>
|
||||
</nav>
|
||||
|
||||
<form id="assetForm" data-mode="{{if .Asset}}edit{{else}}create{{end}}" {{if .Asset}}data-asset-id="{{.Asset.ID}}"{{end}}>
|
||||
<div class="row g-3">
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Asset ID</label>
|
||||
<input type="text" class="form-control" name="asset_id" value="{{if .Asset}}{{.Asset.AssetTag}}{{end}}" placeholder="Auto-generated if left blank">
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Name <span class="text-danger">*</span></label>
|
||||
<input type="text" class="form-control" name="name" value="{{if .Asset}}{{.Asset.Name}}{{end}}" required autofocus>
|
||||
</div>
|
||||
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Item Type <span class="text-danger">*</span></label>
|
||||
{{if .Asset}}
|
||||
<input type="text" class="form-control" value="{{.Asset.ItemTypeName}}" disabled>
|
||||
<div class="form-text">Item type cannot be changed after creation.</div>
|
||||
{{else}}
|
||||
<select class="form-select" name="item_type_id" id="assetFormItemType" required>
|
||||
{{range .ItemTypes}}<option value="{{.ID}}" {{if eq .ID $.SelectedIT}}selected{{end}}>{{.Name}}</option>{{end}}
|
||||
</select>
|
||||
{{end}}
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Folder</label>
|
||||
{{$selectedFolder := deref .FolderID}}
|
||||
{{if .Asset}}{{$selectedFolder = deref .Asset.FolderID}}{{end}}
|
||||
<select class="form-select" name="folder_id">
|
||||
<option value="">Inventory (top level)</option>
|
||||
{{range .FolderOptions}}
|
||||
<option value="{{.ID}}" {{if eq $selectedFolder .ID}}selected{{end}}>
|
||||
{{range seq .Depth}} {{end}}{{.Path}}
|
||||
</option>
|
||||
{{end}}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Status <span class="text-danger">*</span></label>
|
||||
<select class="form-select" name="status_id" required>
|
||||
{{range .Statuses}}<option value="{{.ID}}" {{if $.Asset}}{{if eq .ID $.Asset.StatusID}}selected{{end}}{{end}}>{{.Name}}</option>{{end}}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Assigned User</label>
|
||||
{{$selectedUser := 0}}
|
||||
{{if .Asset}}{{$selectedUser = deref .Asset.AssignedUserID}}{{end}}
|
||||
<select class="form-select" name="assigned_user_id">
|
||||
<option value="">Unassigned</option>
|
||||
{{range .Users}}
|
||||
<option value="{{.ID}}" {{if eq $selectedUser .ID}}selected{{end}}>{{.Username}}</option>
|
||||
{{end}}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="col-12">
|
||||
<label class="form-label">Notes</label>
|
||||
<textarea class="form-control" name="notes" rows="3">{{if .Asset}}{{.Asset.Notes}}{{end}}</textarea>
|
||||
</div>
|
||||
|
||||
<div class="col-12" id="asset-fields-container">
|
||||
{{template "asset_fields_only" .Fields}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="d-flex gap-2 mt-4">
|
||||
<button type="submit" class="btn btn-primary">{{if .Asset}}Save Changes{{else}}Create Asset{{end}}</button>
|
||||
<button type="button" class="btn btn-outline-secondary" data-action="cancel-asset-form" {{if .Asset}}data-id="{{.Asset.ID}}"{{end}}>Cancel</button>
|
||||
</div>
|
||||
<div class="alert alert-danger mt-3 d-none" id="assetFormError"></div>
|
||||
</form>
|
||||
</div>
|
||||
{{end}}
|
||||
@@ -0,0 +1,15 @@
|
||||
{{define "breadcrumb"}}
|
||||
<nav aria-label="breadcrumb">
|
||||
<ol class="breadcrumb mb-0">
|
||||
<li class="breadcrumb-item"><a href="#" class="breadcrumb-link" data-type="root">Inventory</a></li>
|
||||
{{$last := sub (len .) 1}}
|
||||
{{range $i, $b := .}}
|
||||
{{if eq $i $last}}
|
||||
<li class="breadcrumb-item active" aria-current="page">{{$b.Name}}</li>
|
||||
{{else}}
|
||||
<li class="breadcrumb-item"><a href="#" class="breadcrumb-link" data-type="folder" data-id="{{$b.ID}}">{{$b.Name}}</a></li>
|
||||
{{end}}
|
||||
{{end}}
|
||||
</ol>
|
||||
</nav>
|
||||
{{end}}
|
||||
@@ -0,0 +1,112 @@
|
||||
{{define "modals"}}
|
||||
{{if .User.IsAdmin}}
|
||||
<!-- Create / rename folder -->
|
||||
<div class="modal fade" id="folderModal" tabindex="-1">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<form id="folderForm">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="folderModalTitle">New Folder</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<input type="hidden" name="folder_id" id="folderFormId">
|
||||
<input type="hidden" name="parent_id" id="folderFormParentId">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Name</label>
|
||||
<input type="text" class="form-control" name="name" id="folderFormName" required autofocus>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Description</label>
|
||||
<textarea class="form-control" name="description" id="folderFormDescription" rows="2"></textarea>
|
||||
</div>
|
||||
<div class="row g-2">
|
||||
<div class="col">
|
||||
<label class="form-label">Icon <span class="text-body-secondary">(Bootstrap Icons class, optional)</span></label>
|
||||
<input type="text" class="form-control" name="icon" id="folderFormIcon" placeholder="bi-folder-fill">
|
||||
</div>
|
||||
<div class="col-4">
|
||||
<label class="form-label">Sort Order</label>
|
||||
<input type="number" class="form-control" name="sort_order" id="folderFormSortOrder" value="0">
|
||||
</div>
|
||||
</div>
|
||||
<div class="invalid-feedback d-block d-none" id="folderFormError"></div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary">Save Folder</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Delete folder confirmation -->
|
||||
<div class="modal fade" id="deleteFolderModal" tabindex="-1">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title"><i class="bi bi-exclamation-triangle-fill text-danger"></i> Delete Folder</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p>Delete <strong id="deleteFolderName"></strong>?</p>
|
||||
<div id="deleteFolderEmptyNotice" class="d-none">
|
||||
<p class="text-body-secondary">This folder is empty and can be deleted safely.</p>
|
||||
</div>
|
||||
<div id="deleteFolderNotEmptyNotice" class="d-none">
|
||||
<div class="alert alert-warning py-2">
|
||||
This folder contains <span id="deleteFolderCounts"></span>. Choose what to do with its contents.
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="radio" name="delete_mode" id="modeRelocate" value="relocate" checked>
|
||||
<label class="form-check-label" for="modeRelocate">Move contents to…</label>
|
||||
</div>
|
||||
<select class="form-select mt-1 mb-2" id="deleteFolderDestination"></select>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="radio" name="delete_mode" id="modeCascade" value="cascade">
|
||||
<label class="form-check-label text-danger" for="modeCascade">Permanently delete everything inside</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
<button type="button" class="btn btn-danger" id="confirmDeleteFolderBtn">Delete Folder</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Import CSV -->
|
||||
<div class="modal fade" id="importModal" tabindex="-1">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<form id="importForm">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Import Assets from CSV</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">CSV File</label>
|
||||
<input type="file" class="form-control" name="file" id="importFile" accept=".csv" required>
|
||||
</div>
|
||||
<div class="form-check mb-2">
|
||||
<input class="form-check-input" type="checkbox" name="create_missing_folders" id="importCreateFolders" value="1" checked>
|
||||
<label class="form-check-label" for="importCreateFolders">Create missing folders referenced in the Folder column</label>
|
||||
</div>
|
||||
<p class="text-body-secondary small mb-0">
|
||||
Columns: Asset ID, Name, Item Type, Folder, Status, Assigned User, Notes, plus <code>cf_<field_key></code> for custom fields.
|
||||
</p>
|
||||
<div id="importResult" class="mt-3"></div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Close</button>
|
||||
<button type="submit" class="btn btn-primary" id="importSubmitBtn">Import</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
{{end}}
|
||||
@@ -0,0 +1,104 @@
|
||||
{{define "panel_asset"}}
|
||||
<div class="panel-content p-3" data-asset-id="{{.Asset.ID}}">
|
||||
<div class="d-flex align-items-start mb-2">
|
||||
<nav aria-label="breadcrumb">
|
||||
<ol class="breadcrumb mb-0">
|
||||
<li class="breadcrumb-item"><a href="#" class="breadcrumb-link" data-type="root">Inventory</a></li>
|
||||
{{range .Asset.FolderPath}}
|
||||
<li class="breadcrumb-item"><a href="#" class="breadcrumb-link" data-type="folder" data-id="{{.ID}}">{{.Name}}</a></li>
|
||||
{{end}}
|
||||
<li class="breadcrumb-item active" aria-current="page">{{.Asset.Name}}</li>
|
||||
</ol>
|
||||
</nav>
|
||||
{{if .CanEdit}}
|
||||
<div class="ms-auto d-flex gap-2">
|
||||
<button class="btn btn-sm btn-outline-secondary" data-action="edit-asset" data-id="{{.Asset.ID}}"><i class="bi bi-pencil"></i> Edit</button>
|
||||
<button class="btn btn-sm btn-outline-secondary" data-action="move-asset" data-bs-toggle="modal" data-bs-target="#moveAssetModal-{{.Asset.ID}}"><i class="bi bi-arrows-move"></i> Move</button>
|
||||
<button class="btn btn-sm btn-outline-secondary" data-action="duplicate-asset" data-id="{{.Asset.ID}}"><i class="bi bi-files"></i> Duplicate</button>
|
||||
<button class="btn btn-sm btn-outline-danger" data-action="delete-asset" data-id="{{.Asset.ID}}" data-name="{{.Asset.Name}}"><i class="bi bi-trash"></i> Delete</button>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
<div class="d-flex align-items-center gap-2 mb-3">
|
||||
<i class="bi {{iconOr .Asset.ItemTypeIcon "bi-box-seam"}} fs-3 text-body-secondary"></i>
|
||||
<div>
|
||||
<h4 class="mb-0">{{.Asset.Name}}</h4>
|
||||
<div class="text-body-secondary">{{.Asset.AssetTag}} · {{.Asset.ItemTypeName}}</div>
|
||||
</div>
|
||||
<span class="badge text-bg-{{.Asset.StatusColor}} ms-2 fs-6">{{.Asset.StatusName}}</span>
|
||||
</div>
|
||||
|
||||
<div class="row g-3">
|
||||
<div class="col-md-6">
|
||||
<div class="card mb-3">
|
||||
<div class="card-header py-2 fw-semibold">Details</div>
|
||||
<table class="table table-sm mb-0">
|
||||
<tbody>
|
||||
<tr><th class="w-25">Assigned User</th><td>{{if .Asset.AssignedUsername}}{{.Asset.AssignedUsername}}{{else}}<span class="text-body-secondary">Unassigned</span>{{end}}</td></tr>
|
||||
<tr><th>Created</th><td>{{fmtDate .Asset.CreatedAt}}</td></tr>
|
||||
<tr><th>Updated</th><td>{{fmtDate .Asset.UpdatedAt}}</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{{if .Asset.Notes}}
|
||||
<div class="card mb-3">
|
||||
<div class="card-header py-2 fw-semibold">Notes</div>
|
||||
<div class="card-body py-2" style="white-space:pre-wrap">{{.Asset.Notes}}</div>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
{{if .Asset.CustomFields}}
|
||||
<div class="card mb-3">
|
||||
<div class="card-header py-2 fw-semibold">{{.Asset.ItemTypeName}} Fields</div>
|
||||
<table class="table table-sm mb-0">
|
||||
<tbody>
|
||||
{{range .Asset.CustomFields}}
|
||||
<tr>
|
||||
<th class="w-40">{{.Def.Name}}</th>
|
||||
<td>
|
||||
{{if eq .Def.FieldType "boolean"}}
|
||||
{{if eq .Value "true"}}<i class="bi bi-check-circle-fill text-success"></i>{{else}}<i class="bi bi-x-circle text-body-secondary"></i>{{end}}
|
||||
{{else if eq .Def.FieldType "url"}}
|
||||
{{if .Value}}<a href="{{.Value}}" target="_blank" rel="noopener">{{.Value}}</a>{{end}}
|
||||
{{else}}
|
||||
{{if .Value}}{{.Value}}{{else}}<span class="text-body-secondary">—</span>{{end}}
|
||||
{{end}}
|
||||
</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{if .CanEdit}}
|
||||
<div class="modal fade" id="moveAssetModal-{{.Asset.ID}}" tabindex="-1">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Move "{{.Asset.Name}}"</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<label class="form-label">Destination Folder</label>
|
||||
<select class="form-select" id="moveAssetDestination-{{.Asset.ID}}">
|
||||
<option value="">Inventory (top level)</option>
|
||||
{{range .FolderOptions}}
|
||||
<option value="{{.ID}}">{{range seq .Depth}} {{end}}{{.Path}}</option>
|
||||
{{end}}
|
||||
</select>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
<button type="button" class="btn btn-primary" data-action="confirm-move-asset" data-id="{{.Asset.ID}}" data-select="moveAssetDestination-{{.Asset.ID}}">Move</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
{{end}}
|
||||
@@ -0,0 +1,80 @@
|
||||
{{define "panel_folder"}}
|
||||
<div class="panel-content p-3" data-folder-id="{{.Folder.ID}}">
|
||||
<div class="d-flex align-items-start mb-2">
|
||||
{{template "breadcrumb" .Breadcrumb}}
|
||||
<div class="ms-auto d-flex gap-2 admin-only">
|
||||
<button class="btn btn-sm btn-outline-secondary" data-action="new-subfolder" data-id="{{.Folder.ID}}"><i class="bi bi-folder-plus"></i> Subfolder</button>
|
||||
<button class="btn btn-sm btn-outline-secondary" data-action="rename-folder" data-id="{{.Folder.ID}}" data-name="{{.Folder.Name}}" data-description="{{.Folder.Description}}" data-icon="{{.Folder.Icon}}" data-sort-order="{{.Folder.SortOrder}}"><i class="bi bi-pencil"></i> Rename</button>
|
||||
<button class="btn btn-sm btn-outline-danger" data-action="delete-folder" data-id="{{.Folder.ID}}"><i class="bi bi-trash"></i> Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h4 class="mb-1"><i class="bi {{iconOr .Folder.Icon "bi-folder-fill"}} text-warning-emphasis me-1"></i>{{.Folder.Name}}</h4>
|
||||
{{if .Folder.Description}}<p class="text-body-secondary">{{.Folder.Description}}</p>{{end}}
|
||||
|
||||
{{if .Subfolders}}
|
||||
<h6 class="mt-3 mb-2">Subfolders</h6>
|
||||
<div class="list-group mb-3">
|
||||
{{range .Subfolders}}
|
||||
<a href="#" class="list-group-item list-group-item-action folder-link d-flex align-items-center" data-id="{{.ID}}">
|
||||
<i class="bi {{iconOr .Icon "bi-folder-fill"}} text-warning-emphasis me-2"></i>
|
||||
{{.Name}}
|
||||
<span class="ms-auto text-body-secondary small">{{.FolderCount}} folder(s), {{.AssetCount}} asset(s)</span>
|
||||
</a>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<div class="d-flex align-items-center flex-wrap gap-2 mt-3 mb-2">
|
||||
<h6 class="mb-0 me-2">Assets</h6>
|
||||
<form class="d-flex gap-2 flex-wrap align-items-center panel-filter-form" data-folder-id="{{.Folder.ID}}">
|
||||
<select class="form-select form-select-sm" name="item_type_id" style="width:auto">
|
||||
<option value="">All Types</option>
|
||||
{{range .ItemTypes}}<option value="{{.ID}}" {{if eq .ID $.FilterType}}selected{{end}}>{{.Name}}</option>{{end}}
|
||||
</select>
|
||||
<select class="form-select form-select-sm" name="status_id" style="width:auto">
|
||||
<option value="">All Statuses</option>
|
||||
{{range .Statuses}}<option value="{{.ID}}" {{if eq .ID $.FilterStat}}selected{{end}}>{{.Name}}</option>{{end}}
|
||||
</select>
|
||||
<input type="search" class="form-control form-control-sm" name="q" value="{{.Query}}" placeholder="Filter assets…" style="width:160px">
|
||||
</form>
|
||||
<div class="ms-auto d-flex gap-2">
|
||||
<a class="btn btn-sm btn-outline-secondary" href="/export/assets.csv?folder_id={{.Folder.ID}}&recursive=1"><i class="bi bi-download"></i> Export</a>
|
||||
<button class="btn btn-sm btn-primary admin-only" data-action="new-asset" data-folder-id="{{.Folder.ID}}"><i class="bi bi-plus-lg"></i> Add Asset</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{if .Assets}}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm table-hover asset-table">
|
||||
<thead><tr><th>Asset ID</th><th>Name</th><th>Type</th><th>Status</th><th>Assigned</th></tr></thead>
|
||||
<tbody>
|
||||
{{range .Assets}}
|
||||
<tr class="asset-row" data-id="{{.ID}}">
|
||||
<td>{{.AssetTag}}</td>
|
||||
<td><i class="bi {{iconOr .ItemTypeIcon "bi-box-seam"}} me-1"></i>{{.Name}}</td>
|
||||
<td>{{.ItemTypeName}}</td>
|
||||
<td><span class="badge text-bg-{{.StatusColor}}">{{.StatusName}}</span></td>
|
||||
<td>{{if .AssignedUsername}}{{.AssignedUsername}}{{else}}<span class="text-body-secondary">—</span>{{end}}</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{{$pages := ceilDiv .Total .PageSize}}
|
||||
{{if gt $pages 1}}
|
||||
<nav>
|
||||
<ul class="pagination pagination-sm">
|
||||
{{range $i := seqFrom1 $pages}}
|
||||
<li class="page-item {{if eq $i $.Page}}active{{end}}">
|
||||
<a class="page-link panel-page-link" href="#" data-folder-id="{{$.Folder.ID}}" data-page="{{$i}}">{{$i}}</a>
|
||||
</li>
|
||||
{{end}}
|
||||
</ul>
|
||||
</nav>
|
||||
{{end}}
|
||||
{{else}}
|
||||
<p class="text-body-secondary">No assets in this folder yet.</p>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
@@ -0,0 +1,109 @@
|
||||
{{define "panel_root"}}
|
||||
<div class="panel-content p-3">
|
||||
<nav aria-label="breadcrumb">
|
||||
<ol class="breadcrumb mb-2">
|
||||
<li class="breadcrumb-item active" aria-current="page">Inventory</li>
|
||||
</ol>
|
||||
</nav>
|
||||
|
||||
<div class="row g-2 mb-3">
|
||||
<div class="col-6 col-md-2">
|
||||
<div class="card stat-card">
|
||||
<div class="card-body py-2">
|
||||
<div class="stat-value">{{.Stats.Total}}</div>
|
||||
<div class="stat-label">Total Assets</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{range .Stats.ByStatus}}
|
||||
<div class="col-6 col-md-2">
|
||||
<div class="card stat-card">
|
||||
<div class="card-body py-2">
|
||||
<div class="stat-value">{{.Count}}</div>
|
||||
<div class="stat-label"><span class="badge text-bg-{{.Color}}">{{.Name}}</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
<div class="row g-3 mb-3">
|
||||
<div class="col-md-6">
|
||||
<div class="card h-100">
|
||||
<div class="card-header py-2 fw-semibold">Assets by Type</div>
|
||||
<ul class="list-group list-group-flush">
|
||||
{{range .Stats.ByType}}
|
||||
<li class="list-group-item d-flex justify-content-between align-items-center py-1">
|
||||
<span><i class="bi {{iconOr .Icon "bi-box-seam"}} me-2"></i>{{.Name}}</span>
|
||||
<span class="badge text-bg-secondary rounded-pill">{{.Count}}</span>
|
||||
</li>
|
||||
{{else}}
|
||||
<li class="list-group-item text-body-secondary py-2">No assets yet.</li>
|
||||
{{end}}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="card h-100">
|
||||
<div class="card-header py-2 fw-semibold">Recently Added</div>
|
||||
<ul class="list-group list-group-flush">
|
||||
{{range .Stats.RecentlyAdded}}
|
||||
<li class="list-group-item py-1">
|
||||
<a href="#" class="asset-link text-decoration-none" data-id="{{.ID}}">
|
||||
<i class="bi {{iconOr .ItemTypeIcon "bi-box-seam"}} me-2"></i>{{.Name}}
|
||||
<span class="text-body-secondary small">{{.AssetTag}}</span>
|
||||
</a>
|
||||
</li>
|
||||
{{else}}
|
||||
<li class="list-group-item text-body-secondary py-2">No assets yet.</li>
|
||||
{{end}}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="d-flex align-items-center mb-2">
|
||||
<h6 class="mb-0">Top-Level Folders</h6>
|
||||
<div class="ms-auto d-flex gap-2">
|
||||
<a class="btn btn-sm btn-outline-secondary" href="/export/assets.csv"><i class="bi bi-download"></i> Export CSV</a>
|
||||
<button class="btn btn-sm btn-outline-secondary admin-only" data-bs-toggle="modal" data-bs-target="#importModal"><i class="bi bi-upload"></i> Import CSV</button>
|
||||
<button class="btn btn-sm btn-primary admin-only" id="btn-new-folder-root"><i class="bi bi-folder-plus"></i> New Folder</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{if .Folders}}
|
||||
<div class="list-group mb-3">
|
||||
{{range .Folders}}
|
||||
<a href="#" class="list-group-item list-group-item-action folder-link d-flex align-items-center" data-id="{{.ID}}">
|
||||
<i class="bi {{iconOr .Icon "bi-folder-fill"}} text-warning-emphasis me-2"></i>
|
||||
{{.Name}}
|
||||
<span class="text-body-secondary small ms-2">{{if .Description}}{{.Description}}{{end}}</span>
|
||||
<span class="ms-auto text-body-secondary small">{{.FolderCount}} folder(s), {{.AssetCount}} asset(s)</span>
|
||||
</a>
|
||||
{{end}}
|
||||
</div>
|
||||
{{else}}
|
||||
<p class="text-body-secondary">No folders yet. {{if true}}<span class="admin-only">Create one to start organizing your inventory.</span>{{end}}</p>
|
||||
{{end}}
|
||||
|
||||
{{if .UnfiledAssets}}
|
||||
<h6 class="mb-2">Unfiled Assets</h6>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm table-hover asset-table">
|
||||
<thead><tr><th>Asset ID</th><th>Name</th><th>Type</th><th>Status</th><th>Assigned</th></tr></thead>
|
||||
<tbody>
|
||||
{{range .UnfiledAssets}}
|
||||
<tr class="asset-row" data-id="{{.ID}}">
|
||||
<td>{{.AssetTag}}</td>
|
||||
<td><i class="bi {{iconOr .ItemTypeIcon "bi-box-seam"}} me-1"></i>{{.Name}}</td>
|
||||
<td>{{.ItemTypeName}}</td>
|
||||
<td><span class="badge text-bg-{{.StatusColor}}">{{.StatusName}}</span></td>
|
||||
<td>{{if .AssignedUsername}}{{.AssignedUsername}}{{else}}<span class="text-body-secondary">—</span>{{end}}</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
@@ -0,0 +1,33 @@
|
||||
{{define "panel_search"}}
|
||||
<div class="panel-content p-3">
|
||||
<nav aria-label="breadcrumb">
|
||||
<ol class="breadcrumb mb-2">
|
||||
<li class="breadcrumb-item"><a href="#" class="breadcrumb-link" data-type="root">Inventory</a></li>
|
||||
<li class="breadcrumb-item active">Search: "{{.Query}}"</li>
|
||||
</ol>
|
||||
</nav>
|
||||
|
||||
<h5 class="mb-3">{{len .Results}} result(s) for "{{.Query}}"</h5>
|
||||
|
||||
{{if .Results}}
|
||||
<div class="list-group">
|
||||
{{range .Results}}
|
||||
<a href="#" class="list-group-item list-group-item-action asset-link" data-id="{{.ID}}">
|
||||
<div class="d-flex align-items-center">
|
||||
<i class="bi {{iconOr .ItemTypeIcon "bi-box-seam"}} me-2 fs-5 text-body-secondary"></i>
|
||||
<div>
|
||||
<div class="fw-semibold">{{.AssetTag}} — {{.Name}}</div>
|
||||
<div class="text-body-secondary small">
|
||||
Inventory{{range .FolderPath}} / {{.Name}}{{end}}
|
||||
</div>
|
||||
</div>
|
||||
<span class="badge text-bg-{{.StatusColor}} ms-auto">{{.StatusName}}</span>
|
||||
</div>
|
||||
</a>
|
||||
{{end}}
|
||||
</div>
|
||||
{{else}}
|
||||
<p class="text-body-secondary">No matches found.</p>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
@@ -0,0 +1,44 @@
|
||||
{{define "tree_children"}}
|
||||
{{range .}}
|
||||
{{if eq .Type "folder"}}
|
||||
<li class="tree-node" data-type="folder" data-id="{{.ID}}" draggable="true">
|
||||
<div class="tree-row" data-type="folder" data-id="{{.ID}}" tabindex="0">
|
||||
{{if .HasChildren}}
|
||||
<button type="button" class="tree-toggle{{if .Expanded}} expanded{{end}}" data-id="{{.ID}}" aria-label="Toggle">
|
||||
<i class="bi bi-chevron-right"></i>
|
||||
</button>
|
||||
{{else}}
|
||||
<span class="tree-toggle-spacer"></span>
|
||||
{{end}}
|
||||
<i class="bi {{iconOr .Icon "bi-folder-fill"}} tree-icon text-warning-emphasis"></i>
|
||||
<span class="tree-label">{{.Name}}</span>
|
||||
<span class="tree-actions admin-only">
|
||||
<button type="button" class="btn-tree-action" data-action="menu" data-id="{{.ID}}" data-type="folder" title="Actions">
|
||||
<i class="bi bi-three-dots"></i>
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
<ul class="tree-children list-unstyled{{if not .Expanded}} d-none{{end}}" data-expanded="{{.Expanded}}" data-loaded="{{.Expanded}}">
|
||||
{{if .Expanded}}{{template "tree_children" .Children}}{{end}}
|
||||
</ul>
|
||||
</li>
|
||||
{{else if eq .Type "asset"}}
|
||||
<li class="tree-node" data-type="asset" data-id="{{.ID}}" draggable="true">
|
||||
<div class="tree-row" data-type="asset" data-id="{{.ID}}" tabindex="0">
|
||||
<span class="tree-toggle-spacer"></span>
|
||||
<i class="bi {{iconOr .Icon "bi-box-seam"}} tree-icon text-body-secondary"></i>
|
||||
<span class="tree-label">{{.Name}}</span>
|
||||
<span class="badge rounded-pill text-bg-{{.StatusColor}} tree-status-badge">{{.StatusName}}</span>
|
||||
</div>
|
||||
</li>
|
||||
{{else}}
|
||||
<li class="tree-node tree-node-more">
|
||||
<div class="tree-row tree-row-more text-body-secondary fst-italic">
|
||||
<span class="tree-toggle-spacer"></span>
|
||||
<i class="bi bi-three-dots"></i>
|
||||
<span class="tree-label">{{.Name}}</span>
|
||||
</div>
|
||||
</li>
|
||||
{{end}}
|
||||
{{end}}
|
||||
{{end}}
|
||||
Reference in New Issue
Block a user