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)) }