Bulk multi-select actions, barcode format, batch printing, site name on login

Multi-select (Ctrl/Cmd+click toggle, Shift+click range, right-click the
active selection for bulk actions - same conventions as a native file
manager):
- Print Labels (N) / Export CSV (N) for any mix of individually-picked
  assets and whole folders. Labels/export now take asset_ids/folder_ids
  instead of a single asset_id/folder_id.
- Edit Status..., Check Out..., Duplicate, Move to..., and Delete, bulk
  versions of the existing single-asset actions. Checkout/Move modals
  were generalized to accept an array of ids instead of just one; status
  editing got its own lightweight POST /app/assets/{id}/status endpoint
  (assets.SetStatus) so a bulk status change doesn't need to resend every
  other field the way a full PATCH would.

Labels:
- Choose QR (a /scan/<tag> URL, for a phone camera) or Code128 barcode
  (the bare Asset ID, for a USB scanner typing into the search box) via a
  format selector on the print page. New barcodeDataURI using
  github.com/boombuler/barcode alongside the existing QR generator.
- "Start at #" lets printing resume partway into an already-used sheet
  instead of wasting it on a fresh one.
- Added the Avery 5260 label template (same grid as 5160, rounded
  corners, so it isn't flagged approximate).

Also: the configured Site Name now shows on the login page too, not just
the authenticated navbar.
This commit is contained in:
Owen
2026-08-17 17:12:17 -05:00
parent 4e4313723e
commit 14e2f3bb77
15 changed files with 573 additions and 113 deletions
+25
View File
@@ -169,6 +169,31 @@ func (a *App) handleAssetCheckout(w http.ResponseWriter, r *http.Request) {
jsonOK(w, map[string]any{"id": id, "assigned_user_id": req.AssignedUserID})
}
type assetStatusRequest struct {
StatusID int64 `json:"status_id"`
}
// handleAssetSetStatus changes only an asset's status - used by the bulk
// "Edit Status" multi-select action so it doesn't have to resend every
// other field the way a full PATCH does.
func (a *App) handleAssetSetStatus(w http.ResponseWriter, r *http.Request) {
id, err := pathID(r, "id")
if err != nil {
jsonErr(w, http.StatusBadRequest, err)
return
}
var req assetStatusRequest
if err := readJSON(r, &req); err != nil {
jsonErr(w, http.StatusBadRequest, err)
return
}
if err := a.Assets.SetStatus(r.Context(), id, req.StatusID); err != nil {
jsonErr(w, http.StatusInternalServerError, err)
return
}
jsonOK(w, map[string]any{"id": id, "status_id": req.StatusID})
}
type assetQuantityRequest struct {
Delta int `json:"delta"`
}
+35 -4
View File
@@ -10,6 +10,41 @@ import (
func (a *App) handleExportCSV(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
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+"\"")
assetIDsParam := r.URL.Query().Get("asset_ids")
folderIDsParam := r.URL.Query().Get("folder_ids")
if assetIDsParam != "" || folderIDsParam != "" {
// A multi-select batch (an arbitrary mix of individually-picked
// assets and whole folders) doesn't map onto a single ListFilter.
seen := map[int64]bool{}
var ids []int64
for _, id := range parseIDList(assetIDsParam) {
if !seen[id] {
seen[id] = true
ids = append(ids, id)
}
}
for _, fid := range parseIDList(folderIDsParam) {
batch, _, err := a.Assets.List(ctx, assets.ListFilter{FolderID: &fid, FolderSet: true, IncludeSub: true, Page: 1, PageSize: 2000})
if err != nil {
continue
}
for _, as := range batch {
if !seen[as.ID] {
seen[as.ID] = true
ids = append(ids, as.ID)
}
}
}
if err := a.Assets.ExportByIDs(ctx, w, ids); err != nil {
a.Log.Error("csv export failed", "error", err)
}
return
}
filter := assets.ListFilter{
ItemTypeID: queryInt64(r, "item_type_id"),
StatusID: queryInt64(r, "status_id"),
@@ -21,10 +56,6 @@ func (a *App) handleExportCSV(w http.ResponseWriter, r *http.Request) {
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)
}
+124 -52
View File
@@ -4,6 +4,8 @@ import (
"fmt"
"html/template"
"net/http"
"strconv"
"strings"
"inventree/internal/assets"
"inventree/internal/models"
@@ -12,17 +14,19 @@ import (
// LabelTemplate describes a sheet of adhesive labels: cell size, grid, and
// page margins, all in inches, used to lay out printable asset tags.
type LabelTemplate struct {
Key string
DisplayName string
LabelWidthIn float64
LabelHeightIn float64
Cols int
Rows int
MarginTopIn float64
MarginLeftIn float64
ColGapIn float64
RowGapIn float64
QRSizeIn float64
Key string
DisplayName string
LabelWidthIn float64
LabelHeightIn float64
Cols int
Rows int
MarginTopIn float64
MarginLeftIn float64
ColGapIn float64
RowGapIn float64
QRSizeIn float64
BarcodeWidthIn float64
BarcodeHeightIn float64
// Approximate is true for templates whose margins/grid aren't from a
// verified manufacturer spec sheet - print one test page on plain
// paper and hold it up to a real label sheet before committing a
@@ -35,7 +39,7 @@ var labelTemplates = map[string]LabelTemplate{
Key: "5160", DisplayName: `Avery 5160 - 1" x 2-5/8" (30/sheet)`,
LabelWidthIn: 2.625, LabelHeightIn: 1, Cols: 3, Rows: 10,
MarginTopIn: 0.5, MarginLeftIn: 0.1875, ColGapIn: 0.125, RowGapIn: 0,
QRSizeIn: 0.85,
QRSizeIn: 0.85, BarcodeWidthIn: 2.3, BarcodeHeightIn: 0.4,
},
"5260": {
// Same 30-per-sheet grid as 5160 (1" x 2-5/8"); 5260 is just the
@@ -43,13 +47,13 @@ var labelTemplates = map[string]LabelTemplate{
Key: "5260", DisplayName: `Avery 5260 - 1" x 2-5/8" (30/sheet, rounded corners)`,
LabelWidthIn: 2.625, LabelHeightIn: 1, Cols: 3, Rows: 10,
MarginTopIn: 0.5, MarginLeftIn: 0.1875, ColGapIn: 0.125, RowGapIn: 0,
QRSizeIn: 0.85,
QRSizeIn: 0.85, BarcodeWidthIn: 2.3, BarcodeHeightIn: 0.4,
},
"61526": {
Key: "61526", DisplayName: `Asset Tag Labels 61526 - 3/4" x 2" (~48/sheet)`,
LabelWidthIn: 2, LabelHeightIn: 0.75, Cols: 4, Rows: 12,
MarginTopIn: 1, MarginLeftIn: 0.25, ColGapIn: 0, RowGapIn: 0,
QRSizeIn: 0.62, Approximate: true,
QRSizeIn: 0.62, BarcodeWidthIn: 1.7, BarcodeHeightIn: 0.3, Approximate: true,
},
}
@@ -68,21 +72,35 @@ func (t LabelTemplate) css() template.CSS {
row-gap: %.4fin;
justify-content: center;
}
.label {
.label, .label-blank {
width: %.4fin;
height: %.4fin;
box-sizing: border-box;
}
.label {
padding: 0.06in 0.08in;
display: flex;
align-items: center;
gap: 0.08in;
overflow: hidden;
page-break-inside: avoid;
}
.label img { width: %.4fin; height: %.4fin; flex-shrink: 0; }
.label.format-qr {
display: flex;
align-items: center;
gap: 0.08in;
}
.label.format-qr img { width: %.4fin; height: %.4fin; flex-shrink: 0; }
.label.format-barcode {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.03in;
}
.label.format-barcode img { width: %.4fin; height: %.4fin; }
`,
t.MarginTopIn, t.MarginLeftIn, t.Cols, t.LabelWidthIn, t.ColGapIn, t.RowGapIn,
t.LabelWidthIn, t.LabelHeightIn, t.QRSizeIn, t.QRSizeIn))
t.LabelWidthIn, t.LabelHeightIn,
t.QRSizeIn, t.QRSizeIn,
t.BarcodeWidthIn, t.BarcodeHeightIn))
}
type labelItem struct {
@@ -91,7 +109,7 @@ type labelItem struct {
// never user input), so html/template's URL filter doesn't replace it
// with the "#ZgotmplZ" safety placeholder the way it would a plain
// string containing a data: URI.
QRData template.URL
ImageData template.URL
}
type labelsPageData struct {
@@ -101,17 +119,34 @@ type labelsPageData struct {
Templates map[string]LabelTemplate
TemplateKeys []string
Selected LabelTemplate
AssetID string
FolderID string
Recursive bool
Format string // "qr" or "barcode"
AssetIDs string
FolderIDs string
StartPos int
BlankCells []int // len == StartPos-1, just for {{range}} to emit that many placeholder cells
}
// handleLabelsPage renders a print-ready sheet of QR-coded asset labels:
// either a single asset (?asset_id=) or every asset in a folder
// (?folder_id=&recursive=1), or the whole inventory with neither param.
// Each QR code encodes an absolute /scan/<tag> URL so a phone camera (or a
// USB barcode scanner focused on a browser address bar) opens straight to
// that asset.
func parseIDList(s string) []int64 {
var out []int64
for _, part := range strings.Split(s, ",") {
part = strings.TrimSpace(part)
if part == "" {
continue
}
if id, err := strconv.ParseInt(part, 10, 64); err == nil {
out = append(out, id)
}
}
return out
}
// handleLabelsPage renders a print-ready sheet of QR-coded asset labels for
// any combination of individual assets (?asset_ids=1,2,3) and whole folders
// (?folder_ids=5,9, expanded recursively), or the entire inventory when
// neither is given. ?start=N (1-indexed) leaves the first N-1 cells blank,
// so a partially-used sheet can be resumed instead of wasted. Each QR code
// encodes an absolute /scan/<tag> URL so a phone camera (or a USB barcode
// scanner focused on a browser address bar) opens straight to that asset.
func (a *App) handleLabelsPage(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
origin := requestOrigin(r)
@@ -121,46 +156,83 @@ func (a *App) handleLabelsPage(w http.ResponseWriter, r *http.Request) {
tmpl = labelTemplates[defaultLabelTemplate]
}
var list []models.Asset
assetIDParam := r.URL.Query().Get("asset_id")
folderIDParam := r.URL.Query().Get("folder_id")
recursive := r.URL.Query().Get("recursive") == "1"
startPos := queryInt(r, "start", 1)
if startPos < 1 {
startPos = 1
}
if assetID, err := queryOptionalID(r, "asset_id"); err == nil && assetID != nil {
asset, err := a.Assets.Get(ctx, *assetID)
if err != nil {
writeErr(w, http.StatusNotFound, err.Error())
return
assetIDsParam := r.URL.Query().Get("asset_ids")
folderIDsParam := r.URL.Query().Get("folder_ids")
seen := map[int64]bool{}
var list []models.Asset
addAsset := func(as *models.Asset) {
if !seen[as.ID] {
seen[as.ID] = true
list = append(list, *as)
}
list = []models.Asset{*asset}
} else {
filter := assets.ListFilter{Page: 1, PageSize: 2000}
if folderID, err := queryOptionalID(r, "folder_id"); err == nil && folderID != nil {
filter.FolderID = folderID
filter.FolderSet = true
filter.IncludeSub = recursive
}
batch, _, err := a.Assets.List(ctx, filter)
}
if assetIDsParam == "" && folderIDsParam == "" {
batch, _, err := a.Assets.List(ctx, assets.ListFilter{Page: 1, PageSize: 2000})
if err != nil {
writeErr(w, http.StatusInternalServerError, err.Error())
return
}
list = batch
for i := range batch {
addAsset(&batch[i])
}
} else {
for _, id := range parseIDList(assetIDsParam) {
if as, err := a.Assets.Get(ctx, id); err == nil {
addAsset(as)
}
}
for _, fid := range parseIDList(folderIDsParam) {
batch, _, err := a.Assets.List(ctx, assets.ListFilter{
FolderID: &fid, FolderSet: true, IncludeSub: true, Page: 1, PageSize: 2000,
})
if err != nil {
continue
}
for i := range batch {
addAsset(&batch[i])
}
}
}
format := r.URL.Query().Get("format")
if format != "barcode" {
format = "qr"
}
labels := make([]labelItem, 0, len(list))
for _, as := range list {
qr, err := qrDataURI(origin+"/scan/"+as.AssetTag, 220)
var (
data string
err error
)
if format == "barcode" {
// A linear barcode is meant for a dedicated scanner typing the
// raw tag into whatever's focused (e.g. the app's search box),
// so it encodes the bare Asset ID rather than a URL.
data, err = barcodeDataURI(as.AssetTag, int(tmpl.BarcodeWidthIn*300), int(tmpl.BarcodeHeightIn*300))
} else {
// A QR code is meant for a phone camera, so it encodes a full
// URL that opens straight to the asset.
data, err = qrDataURI(origin+"/scan/"+as.AssetTag, 220)
}
if err != nil {
writeErr(w, http.StatusInternalServerError, err.Error())
return
}
labels = append(labels, labelItem{Asset: as, QRData: template.URL(qr)})
labels = append(labels, labelItem{Asset: as, ImageData: template.URL(data)})
}
a.Render(w, http.StatusOK, "labels", labelsPageData{
User: currentUser(r), Labels: labels, SheetCSS: tmpl.css(),
Templates: labelTemplates, TemplateKeys: []string{"5160", "5260", "61526"},
Selected: tmpl, AssetID: assetIDParam, FolderID: folderIDParam, Recursive: recursive,
Selected: tmpl, Format: format, AssetIDs: assetIDsParam, FolderIDs: folderIDsParam,
StartPos: startPos, BlankCells: make([]int, startPos-1),
})
}
+22
View File
@@ -1,9 +1,13 @@
package web
import (
"bytes"
"encoding/base64"
"image/png"
"net/http"
"github.com/boombuler/barcode"
"github.com/boombuler/barcode/code128"
qrcode "github.com/skip2/go-qrcode"
)
@@ -17,6 +21,24 @@ func qrDataURI(content string, size int) (string, error) {
return "data:image/png;base64," + base64.StdEncoding.EncodeToString(png), nil
}
// barcodeDataURI renders content as a Code128 linear barcode PNG, scaled to
// widthPx x heightPx, and returns it as a data: URI.
func barcodeDataURI(content string, widthPx, heightPx int) (string, error) {
bc, err := code128.Encode(content)
if err != nil {
return "", err
}
scaled, err := barcode.Scale(bc, widthPx, heightPx)
if err != nil {
return "", err
}
var buf bytes.Buffer
if err := png.Encode(&buf, scaled); err != nil {
return "", err
}
return "data:image/png;base64," + base64.StdEncoding.EncodeToString(buf.Bytes()), nil
}
// requestOrigin reconstructs the scheme+host the request arrived on, for
// building absolute URLs (e.g. QR codes need a full URL, not a path).
func requestOrigin(r *http.Request) string {
+1
View File
@@ -56,6 +56,7 @@ func (a *App) Router() http.Handler {
mux.HandleFunc("POST /app/assets/{id}/duplicate", a.requireAdmin(a.handleAssetDuplicate))
mux.HandleFunc("POST /app/assets/{id}/move", a.requireAdmin(a.handleAssetMove))
mux.HandleFunc("POST /app/assets/{id}/checkout", a.requireAdmin(a.handleAssetCheckout))
mux.HandleFunc("POST /app/assets/{id}/status", a.requireAdmin(a.handleAssetSetStatus))
mux.HandleFunc("POST /app/assets/{id}/quantity", a.requireAdmin(a.handleAssetQuantity))
mux.HandleFunc("GET /app/users/options", a.requireAdmin(a.handleUserOptions))