Add asset checkout, quantity/uniqueness, contacts, and QR labels
Checkout:
- assets.AssignUser + POST /app/assets/{id}/checkout assign/unassign an
asset's user independent of the full edit form. New shared
#checkoutModal, wired from a "Check Out..." context-menu action, backed
by GET /app/assets/{id} and GET /app/users/options JSON endpoints.
Quantity / uniqueness:
- Item types gain an IsUnique flag (checked by default, matching prior
behavior). Assets gain a Quantity column, fixed at 1 for unique types
and adjustable via +/- steppers (POST /app/assets/{id}/quantity) for
non-unique ones. Flipping a type back to unique clamps existing assets'
quantity back to 1. CSV import/export gained a Quantity column.
Migration 0003_quantity.sql.
Contacts:
- New models.RoleContact: an assignable person who cannot sign in
(User.CanLogin). Migration 0004_contacts.sql rebuilds the users table
(SQLite can't alter a CHECK constraint in place) to allow the role.
Users form/list updated; contact creation auto-generates an unusable
password since the login path is blocked by role regardless.
QR asset tags and printable labels:
- Each asset's QR code (via github.com/skip2/go-qrcode, pure Go) encodes
an absolute /scan/<tag> URL; GET /scan/{tag} resolves it and redirects
into /inventory?asset=<id>, which app.js picks up on load to
auto-select that asset - works from a phone camera or a USB
scanner-as-keyboard-wedge pointed at the address bar.
- GET /labels renders a print-ready sheet (right-click an asset for one
label, a folder for every asset in it) with a template switcher between
Avery 5160 (verified dimensions) and 61526 asset tag labels (best-effort
layout, flagged as such on the page).
Also refreshed README to reflect the right-click-first tree UX and all of
the above.
This commit is contained in:
@@ -0,0 +1,158 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"html/template"
|
||||
"net/http"
|
||||
|
||||
"inventree/internal/assets"
|
||||
"inventree/internal/models"
|
||||
)
|
||||
|
||||
// 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
|
||||
// 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
|
||||
// full (often expensive/waterproof) sheet to the printer.
|
||||
Approximate bool
|
||||
}
|
||||
|
||||
var labelTemplates = map[string]LabelTemplate{
|
||||
"5160": {
|
||||
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,
|
||||
},
|
||||
"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,
|
||||
},
|
||||
}
|
||||
|
||||
const defaultLabelTemplate = "5160"
|
||||
|
||||
func (t LabelTemplate) css() template.CSS {
|
||||
return template.CSS(fmt.Sprintf(`
|
||||
.label-sheet {
|
||||
width: 8.5in;
|
||||
box-sizing: border-box;
|
||||
padding: %.4fin %.4fin;
|
||||
margin: 0 auto;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(%d, %.4fin);
|
||||
column-gap: %.4fin;
|
||||
row-gap: %.4fin;
|
||||
justify-content: center;
|
||||
}
|
||||
.label {
|
||||
width: %.4fin;
|
||||
height: %.4fin;
|
||||
box-sizing: border-box;
|
||||
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; }
|
||||
`,
|
||||
t.MarginTopIn, t.MarginLeftIn, t.Cols, t.LabelWidthIn, t.ColGapIn, t.RowGapIn,
|
||||
t.LabelWidthIn, t.LabelHeightIn, t.QRSizeIn, t.QRSizeIn))
|
||||
}
|
||||
|
||||
type labelItem struct {
|
||||
Asset models.Asset
|
||||
// template.URL marks this as pre-vetted safe (it's server-generated,
|
||||
// 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
|
||||
}
|
||||
|
||||
type labelsPageData struct {
|
||||
User *models.User
|
||||
Labels []labelItem
|
||||
SheetCSS template.CSS
|
||||
Templates map[string]LabelTemplate
|
||||
TemplateKeys []string
|
||||
Selected LabelTemplate
|
||||
AssetID string
|
||||
FolderID string
|
||||
Recursive bool
|
||||
}
|
||||
|
||||
// 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 (a *App) handleLabelsPage(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
origin := requestOrigin(r)
|
||||
|
||||
tmpl, ok := labelTemplates[r.URL.Query().Get("template")]
|
||||
if !ok {
|
||||
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"
|
||||
|
||||
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
|
||||
}
|
||||
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 err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
list = batch
|
||||
}
|
||||
|
||||
labels := make([]labelItem, 0, len(list))
|
||||
for _, as := range list {
|
||||
qr, 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)})
|
||||
}
|
||||
|
||||
a.Render(w, http.StatusOK, "labels", labelsPageData{
|
||||
User: currentUser(r), Labels: labels, SheetCSS: tmpl.css(),
|
||||
Templates: labelTemplates, TemplateKeys: []string{"5160", "61526"},
|
||||
Selected: tmpl, AssetID: assetIDParam, FolderID: folderIDParam, Recursive: recursive,
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user