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:
@@ -15,6 +15,7 @@ type assetRequest struct {
|
||||
StatusID int64 `json:"status_id"`
|
||||
AssignedUserID *int64 `json:"assigned_user_id"`
|
||||
Notes string `json:"notes"`
|
||||
Quantity int `json:"quantity"`
|
||||
Fields map[string]string `json:"fields"`
|
||||
}
|
||||
|
||||
@@ -38,7 +39,7 @@ func (a *App) handleAssetCreate(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
asset := &models.Asset{
|
||||
AssetTag: req.AssetTag, Name: req.Name, ItemTypeID: req.ItemTypeID, FolderID: req.FolderID,
|
||||
StatusID: req.StatusID, AssignedUserID: req.AssignedUserID, Notes: req.Notes,
|
||||
StatusID: req.StatusID, AssignedUserID: req.AssignedUserID, Notes: req.Notes, Quantity: req.Quantity,
|
||||
}
|
||||
if err := a.Assets.Create(r.Context(), asset, req.fieldValues()); err != nil {
|
||||
jsonErr(w, http.StatusBadRequest, err)
|
||||
@@ -65,7 +66,7 @@ func (a *App) handleAssetUpdate(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
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,
|
||||
StatusID: req.StatusID, AssignedUserID: req.AssignedUserID, Notes: req.Notes, Quantity: req.Quantity,
|
||||
}
|
||||
if err := a.Assets.Update(r.Context(), asset, req.fieldValues()); err != nil {
|
||||
jsonErr(w, http.StatusBadRequest, err)
|
||||
@@ -127,3 +128,69 @@ func (a *App) handleAssetMove(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
jsonOK(w, map[string]any{"id": id, "folder_id": req.FolderID})
|
||||
}
|
||||
|
||||
// handleAssetGet returns a single asset as JSON, for lightweight lookups
|
||||
// (e.g. the checkout modal) that don't need the full HTML detail panel.
|
||||
func (a *App) handleAssetGet(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
|
||||
}
|
||||
jsonOK(w, asset)
|
||||
}
|
||||
|
||||
type assetCheckoutRequest struct {
|
||||
AssignedUserID *int64 `json:"assigned_user_id"`
|
||||
}
|
||||
|
||||
// handleAssetCheckout assigns (checks out) or unassigns (checks in) an
|
||||
// asset's user without touching any other field.
|
||||
func (a *App) handleAssetCheckout(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := pathID(r, "id")
|
||||
if err != nil {
|
||||
jsonErr(w, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
var req assetCheckoutRequest
|
||||
if err := readJSON(r, &req); err != nil {
|
||||
jsonErr(w, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
if err := a.Assets.AssignUser(r.Context(), id, req.AssignedUserID); err != nil {
|
||||
jsonErr(w, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
jsonOK(w, map[string]any{"id": id, "assigned_user_id": req.AssignedUserID})
|
||||
}
|
||||
|
||||
type assetQuantityRequest struct {
|
||||
Delta int `json:"delta"`
|
||||
}
|
||||
|
||||
// handleAssetQuantity increments or decrements a non-unique asset's
|
||||
// quantity in place, for the +/- steppers on the asset panel and folder
|
||||
// tables.
|
||||
func (a *App) handleAssetQuantity(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := pathID(r, "id")
|
||||
if err != nil {
|
||||
jsonErr(w, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
var req assetQuantityRequest
|
||||
if err := readJSON(r, &req); err != nil {
|
||||
jsonErr(w, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
asset, err := a.Assets.AdjustQuantity(r.Context(), id, req.Delta)
|
||||
if err != nil {
|
||||
jsonErr(w, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
jsonOK(w, map[string]any{"id": id, "quantity": asset.Quantity})
|
||||
}
|
||||
|
||||
@@ -13,6 +13,12 @@ type InventoryPageData struct {
|
||||
Tree []models.TreeNode
|
||||
ItemTypes []models.ItemType
|
||||
Statuses []models.Status
|
||||
|
||||
// AutoOpenAssetID and ScanError come from a redirect out of
|
||||
// /scan/<tag> (a QR/barcode label scan): either the asset to select
|
||||
// on load, or the tag that didn't match anything.
|
||||
AutoOpenAssetID string
|
||||
ScanError string
|
||||
}
|
||||
|
||||
func (a *App) handleInventoryPage(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -42,6 +48,7 @@ func (a *App) handleInventoryPage(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
a.Render(w, http.StatusOK, "inventory", InventoryPageData{
|
||||
User: u, Tree: tree, ItemTypes: itemTypes, Statuses: statusList,
|
||||
AutoOpenAssetID: r.URL.Query().Get("asset"), ScanError: r.URL.Query().Get("scan_error"),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -208,14 +215,16 @@ func (a *App) handlePanelAsset(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// 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
|
||||
Asset *models.Asset // nil when creating
|
||||
FolderID *int64
|
||||
ItemTypes []models.ItemType
|
||||
Statuses []models.Status
|
||||
Users []models.User
|
||||
FolderOptions []FolderOption
|
||||
SelectedIT int64
|
||||
SelectedITUnique bool
|
||||
Quantity int
|
||||
Fields []models.AssetFieldValue
|
||||
}
|
||||
|
||||
func (a *App) handlePanelAssetNew(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -247,15 +256,17 @@ func (a *App) handlePanelAssetNew(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
var selectedIT int64
|
||||
var selectedITUnique bool
|
||||
var fields []models.AssetFieldValue
|
||||
if len(itemTypes) > 0 {
|
||||
selectedIT = itemTypes[0].ID
|
||||
selectedITUnique = itemTypes[0].IsUnique
|
||||
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,
|
||||
SelectedIT: selectedIT, SelectedITUnique: selectedITUnique, Quantity: 1, Fields: fields,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -294,13 +305,23 @@ func (a *App) handlePanelAssetEdit(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
a.RenderFragment(w, http.StatusOK, "asset_form", AssetFormData{
|
||||
Asset: asset, ItemTypes: itemTypes, Statuses: statusList, Users: userList, FolderOptions: folderOpts,
|
||||
SelectedIT: asset.ItemTypeID, Fields: asset.CustomFields,
|
||||
SelectedIT: asset.ItemTypeID, SelectedITUnique: asset.ItemTypeIsUnique, Quantity: asset.Quantity, 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.
|
||||
// AssetDynamicFieldsData backs the "asset_fields_only" partial: the
|
||||
// quantity input (only relevant for non-unique item types) plus the
|
||||
// type's custom fields. It's swapped in whenever the item type dropdown
|
||||
// changes on the "new asset" form, and also used for the initial render.
|
||||
type AssetDynamicFieldsData struct {
|
||||
IsUnique bool
|
||||
Quantity int
|
||||
Fields []models.AssetFieldValue
|
||||
}
|
||||
|
||||
// handleAssetFieldsFragment renders just the dynamic quantity/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")
|
||||
@@ -309,5 +330,7 @@ func (a *App) handleAssetFieldsFragment(w http.ResponseWriter, r *http.Request)
|
||||
writeErr(w, http.StatusNotFound, err.Error())
|
||||
return
|
||||
}
|
||||
a.RenderFragment(w, http.StatusOK, "asset_fields_only", fieldValuesForCreate(it.Fields))
|
||||
a.RenderFragment(w, http.StatusOK, "asset_fields_only", AssetDynamicFieldsData{
|
||||
IsUnique: it.IsUnique, Quantity: 1, Fields: fieldValuesForCreate(it.Fields),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -41,6 +41,7 @@ func (a *App) handleItemTypeCreate(w http.ResponseWriter, r *http.Request) {
|
||||
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),
|
||||
IsUnique: r.FormValue("is_unique") == "on",
|
||||
}
|
||||
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()})
|
||||
@@ -83,6 +84,7 @@ func (a *App) handleItemTypeUpdate(w http.ResponseWriter, r *http.Request) {
|
||||
it.Icon = r.FormValue("icon")
|
||||
it.Enabled = r.FormValue("enabled") == "on"
|
||||
it.SortOrder = atoiDefault(r.FormValue("sort_order"), it.SortOrder)
|
||||
it.IsUnique = r.FormValue("is_unique") == "on"
|
||||
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
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
)
|
||||
|
||||
// handleScan resolves a scanned or printed asset tag (the payload of a
|
||||
// label's QR code) and redirects into the inventory page with that asset
|
||||
// selected. Scanning with a phone camera opens this URL directly; a USB
|
||||
// barcode scanner focused on the browser's address bar works the same way.
|
||||
func (a *App) handleScan(w http.ResponseWriter, r *http.Request) {
|
||||
tag := r.PathValue("tag")
|
||||
asset, err := a.Assets.GetByTag(r.Context(), tag)
|
||||
if err != nil {
|
||||
http.Redirect(w, r, "/inventory?scan_error="+url.QueryEscape(tag), http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, fmt.Sprintf("/inventory?asset=%d", asset.ID), http.StatusSeeOther)
|
||||
}
|
||||
@@ -1,12 +1,38 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"inventree/internal/models"
|
||||
)
|
||||
|
||||
// parseRole maps a form's role value to a models.Role, defaulting to
|
||||
// RoleUser for anything unrecognized.
|
||||
func parseRole(s string) models.Role {
|
||||
switch s {
|
||||
case "admin":
|
||||
return models.RoleAdmin
|
||||
case "contact":
|
||||
return models.RoleContact
|
||||
default:
|
||||
return models.RoleUser
|
||||
}
|
||||
}
|
||||
|
||||
// randomPassword generates an unguessable password for contact accounts,
|
||||
// which can never sign in (see models.User.CanLogin) but still need some
|
||||
// non-empty password_hash in storage.
|
||||
func randomPassword() (string, error) {
|
||||
b := make([]byte, 24)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return hex.EncodeToString(b), nil
|
||||
}
|
||||
|
||||
type usersListData struct {
|
||||
User *models.User
|
||||
Users []models.User
|
||||
@@ -21,6 +47,28 @@ func (a *App) handleUsersList(w http.ResponseWriter, r *http.Request) {
|
||||
a.Render(w, http.StatusOK, "users_list", usersListData{User: currentUser(r), Users: list})
|
||||
}
|
||||
|
||||
// handleUserOptions returns enabled users as JSON, for pickers like the
|
||||
// asset checkout modal (a currently-assigned but since-disabled user still
|
||||
// displays correctly elsewhere; they just won't appear as a new choice).
|
||||
func (a *App) handleUserOptions(w http.ResponseWriter, r *http.Request) {
|
||||
list, err := a.Users.List(r.Context())
|
||||
if err != nil {
|
||||
jsonErr(w, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
type option struct {
|
||||
ID int64 `json:"id"`
|
||||
Username string `json:"username"`
|
||||
}
|
||||
out := make([]option, 0, len(list))
|
||||
for _, u := range list {
|
||||
if u.Enabled {
|
||||
out = append(out, option{ID: u.ID, Username: u.Username})
|
||||
}
|
||||
}
|
||||
jsonOK(w, out)
|
||||
}
|
||||
|
||||
type userFormData struct {
|
||||
User *models.User
|
||||
EditUser *models.User
|
||||
@@ -36,15 +84,20 @@ func (a *App) handleUserCreate(w http.ResponseWriter, r *http.Request) {
|
||||
writeErr(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
role := models.RoleUser
|
||||
if r.FormValue("role") == "admin" {
|
||||
role = models.RoleAdmin
|
||||
}
|
||||
role := parseRole(r.FormValue("role"))
|
||||
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 role != models.RoleContact {
|
||||
a.Render(w, http.StatusBadRequest, "users_form", userFormData{User: currentUser(r), EditUser: u, Error: "Password is required."})
|
||||
return
|
||||
}
|
||||
generated, err := randomPassword()
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
password = generated
|
||||
}
|
||||
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()})
|
||||
@@ -83,10 +136,7 @@ func (a *App) handleUserUpdate(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
u.Username = r.FormValue("username")
|
||||
u.Role = models.RoleUser
|
||||
if r.FormValue("role") == "admin" {
|
||||
u.Role = models.RoleAdmin
|
||||
}
|
||||
u.Role = parseRole(r.FormValue("role"))
|
||||
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()})
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"net/http"
|
||||
|
||||
qrcode "github.com/skip2/go-qrcode"
|
||||
)
|
||||
|
||||
// qrDataURI renders content as a QR code PNG and returns it as a data: URI
|
||||
// suitable for an <img src>, so labels need no separate image requests.
|
||||
func qrDataURI(content string, size int) (string, error) {
|
||||
png, err := qrcode.Encode(content, qrcode.Medium, size)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return "data:image/png;base64," + base64.StdEncoding.EncodeToString(png), 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 {
|
||||
scheme := "http"
|
||||
if r.TLS != nil {
|
||||
scheme = "https"
|
||||
}
|
||||
if proto := r.Header.Get("X-Forwarded-Proto"); proto != "" {
|
||||
scheme = proto
|
||||
}
|
||||
return scheme + "://" + r.Host
|
||||
}
|
||||
@@ -49,16 +49,26 @@ func (a *App) Router() http.Handler {
|
||||
mux.HandleFunc("DELETE /app/folders/{id}", a.requireAdmin(a.handleFolderDelete))
|
||||
|
||||
// Asset mutations (JSON in/out)
|
||||
mux.HandleFunc("GET /app/assets/{id}", a.requireAdmin(a.handleAssetGet))
|
||||
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))
|
||||
mux.HandleFunc("POST /app/assets/{id}/checkout", a.requireAdmin(a.handleAssetCheckout))
|
||||
mux.HandleFunc("POST /app/assets/{id}/quantity", a.requireAdmin(a.handleAssetQuantity))
|
||||
|
||||
mux.HandleFunc("GET /app/users/options", a.requireAdmin(a.handleUserOptions))
|
||||
|
||||
// CSV import/export
|
||||
mux.HandleFunc("GET /export/assets.csv", a.requireLogin(a.handleExportCSV))
|
||||
mux.HandleFunc("POST /import/assets.csv", a.requireAdmin(a.handleImportCSV))
|
||||
|
||||
// QR asset tags: scan a printed label to jump to that asset, or print
|
||||
// a sheet of labels for a folder/asset.
|
||||
mux.HandleFunc("GET /scan/{tag}", a.requireLogin(a.handleScan))
|
||||
mux.HandleFunc("GET /labels", a.requireLogin(a.handleLabelsPage))
|
||||
|
||||
// Settings (admin, traditional pages) - Item Types, Users, and general
|
||||
// settings all live under one unified /settings section with shared
|
||||
// sub-navigation, rather than as separate top-level nav items.
|
||||
|
||||
Reference in New Issue
Block a user