feat: asset tags/ids (#142)

* add schema

* run db migration

* bulk seed asset IDs

* breaking: update runtime options

* conditionally increment asset IDs

* update API endpoints

* fix import asset id assignment

* refactor display + marshal/unmarshal

* add docs page

* add to form field

* hide 000-000 values

* update ENV vars
This commit is contained in:
Hayden 2022-11-13 14:17:55 -09:00 committed by GitHub
parent 976f68252d
commit 6dc2ae1bea
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
32 changed files with 905 additions and 72 deletions

View file

@ -0,0 +1,35 @@
package v1
import (
"net/http"
"github.com/hay-kot/homebox/backend/internal/core/services"
"github.com/hay-kot/homebox/backend/internal/sys/validate"
"github.com/hay-kot/homebox/backend/pkgs/server"
"github.com/rs/zerolog/log"
)
type EnsureAssetIDResult struct {
Completed int `json:"completed"`
}
// HandleGroupInvitationsCreate godoc
// @Summary Get the current user
// @Tags Group
// @Produce json
// @Success 200 {object} EnsureAssetIDResult
// @Router /v1/actions/ensure-asset-ids [Post]
// @Security Bearer
func (ctrl *V1Controller) HandleEnsureAssetID() server.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) error {
ctx := services.NewContext(r.Context())
totalCompleted, err := ctrl.svc.Items.EnsureAssetID(ctx, ctx.GID)
if err != nil {
log.Err(err).Msg("failed to ensure asset id")
return validate.NewRequestError(err, http.StatusInternalServerError)
}
return server.Respond(w, http.StatusOK, EnsureAssetIDResult{Completed: totalCompleted})
}
}

View file

@ -65,8 +65,8 @@ func (ctrl *V1Controller) HandleItemsCreate() server.HandlerFunc {
return validate.NewRequestError(err, http.StatusInternalServerError) return validate.NewRequestError(err, http.StatusInternalServerError)
} }
user := services.UseUserCtx(r.Context()) ctx := services.NewContext(r.Context())
item, err := ctrl.repo.Items.Create(r.Context(), user.GroupID, createData) item, err := ctrl.svc.Items.Create(ctx, createData)
if err != nil { if err != nil {
log.Err(err).Msg("failed to create item") log.Err(err).Msg("failed to create item")
return validate.NewRequestError(err, http.StatusInternalServerError) return validate.NewRequestError(err, http.StatusInternalServerError)

View file

@ -112,7 +112,10 @@ func run(cfg *config.Config) error {
app.db = c app.db = c
app.repos = repo.New(c, cfg.Storage.Data) app.repos = repo.New(c, cfg.Storage.Data)
app.services = services.New(app.repos) app.services = services.New(
app.repos,
services.WithAutoIncrementAssetID(cfg.Options.AutoIncrementAssetID),
)
// ========================================================================= // =========================================================================
// Start Server\ // Start Server\

View file

@ -51,7 +51,7 @@ func (a *app) mountRoutes(repos *repo.AllRepos) {
a.services, a.services,
a.repos, a.repos,
v1.WithMaxUploadSize(a.conf.Web.MaxUploadSize), v1.WithMaxUploadSize(a.conf.Web.MaxUploadSize),
v1.WithRegistration(a.conf.AllowRegistration), v1.WithRegistration(a.conf.Options.AllowRegistration),
v1.WithDemoStatus(a.conf.Demo), // Disable Password Change in Demo Mode v1.WithDemoStatus(a.conf.Demo), // Disable Password Change in Demo Mode
) )
@ -82,6 +82,8 @@ func (a *app) mountRoutes(repos *repo.AllRepos) {
a.server.Get(v1Base("/groups"), v1Ctrl.HandleGroupGet(), a.mwAuthToken) a.server.Get(v1Base("/groups"), v1Ctrl.HandleGroupGet(), a.mwAuthToken)
a.server.Put(v1Base("/groups"), v1Ctrl.HandleGroupUpdate(), a.mwAuthToken) a.server.Put(v1Base("/groups"), v1Ctrl.HandleGroupUpdate(), a.mwAuthToken)
a.server.Post(v1Base("/actions/ensure-asset-ids"), v1Ctrl.HandleEnsureAssetID(), a.mwAuthToken)
a.server.Get(v1Base("/locations"), v1Ctrl.HandleLocationGetAll(), a.mwAuthToken) a.server.Get(v1Base("/locations"), v1Ctrl.HandleLocationGetAll(), a.mwAuthToken)
a.server.Post(v1Base("/locations"), v1Ctrl.HandleLocationCreate(), a.mwAuthToken) a.server.Post(v1Base("/locations"), v1Ctrl.HandleLocationCreate(), a.mwAuthToken)
a.server.Get(v1Base("/locations/{id}"), v1Ctrl.HandleLocationGet(), a.mwAuthToken) a.server.Get(v1Base("/locations/{id}"), v1Ctrl.HandleLocationGet(), a.mwAuthToken)

View file

@ -21,6 +21,30 @@ const docTemplate = `{
"host": "{{.Host}}", "host": "{{.Host}}",
"basePath": "{{.BasePath}}", "basePath": "{{.BasePath}}",
"paths": { "paths": {
"/v1/actions/ensure-asset-ids": {
"post": {
"security": [
{
"Bearer": []
}
],
"produces": [
"application/json"
],
"tags": [
"Group"
],
"summary": "Get the current user",
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/v1.EnsureAssetIDResult"
}
}
}
}
},
"/v1/groups": { "/v1/groups": {
"get": { "get": {
"security": [ "security": [
@ -1326,6 +1350,10 @@ const docTemplate = `{
"archived": { "archived": {
"type": "boolean" "type": "boolean"
}, },
"assetId": {
"type": "string",
"example": "0"
},
"attachments": { "attachments": {
"type": "array", "type": "array",
"items": { "items": {
@ -1479,6 +1507,9 @@ const docTemplate = `{
"archived": { "archived": {
"type": "boolean" "type": "boolean"
}, },
"assetId": {
"type": "string"
},
"description": { "description": {
"type": "string" "type": "string"
}, },
@ -1891,6 +1922,14 @@ const docTemplate = `{
} }
} }
}, },
"v1.EnsureAssetIDResult": {
"type": "object",
"properties": {
"completed": {
"type": "integer"
}
}
},
"v1.GroupInvitation": { "v1.GroupInvitation": {
"type": "object", "type": "object",
"properties": { "properties": {

View file

@ -13,6 +13,30 @@
}, },
"basePath": "/api", "basePath": "/api",
"paths": { "paths": {
"/v1/actions/ensure-asset-ids": {
"post": {
"security": [
{
"Bearer": []
}
],
"produces": [
"application/json"
],
"tags": [
"Group"
],
"summary": "Get the current user",
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/v1.EnsureAssetIDResult"
}
}
}
}
},
"/v1/groups": { "/v1/groups": {
"get": { "get": {
"security": [ "security": [
@ -1318,6 +1342,10 @@
"archived": { "archived": {
"type": "boolean" "type": "boolean"
}, },
"assetId": {
"type": "string",
"example": "0"
},
"attachments": { "attachments": {
"type": "array", "type": "array",
"items": { "items": {
@ -1471,6 +1499,9 @@
"archived": { "archived": {
"type": "boolean" "type": "boolean"
}, },
"assetId": {
"type": "string"
},
"description": { "description": {
"type": "string" "type": "string"
}, },
@ -1883,6 +1914,14 @@
} }
} }
}, },
"v1.EnsureAssetIDResult": {
"type": "object",
"properties": {
"completed": {
"type": "integer"
}
}
},
"v1.GroupInvitation": { "v1.GroupInvitation": {
"type": "object", "type": "object",
"properties": { "properties": {

View file

@ -98,6 +98,9 @@ definitions:
properties: properties:
archived: archived:
type: boolean type: boolean
assetId:
example: "0"
type: string
attachments: attachments:
items: items:
$ref: '#/definitions/repo.ItemAttachment' $ref: '#/definitions/repo.ItemAttachment'
@ -204,6 +207,8 @@ definitions:
properties: properties:
archived: archived:
type: boolean type: boolean
assetId:
type: string
description: description:
type: string type: string
fields: fields:
@ -477,6 +482,11 @@ definitions:
new: new:
type: string type: string
type: object type: object
v1.EnsureAssetIDResult:
properties:
completed:
type: integer
type: object
v1.GroupInvitation: v1.GroupInvitation:
properties: properties:
expiresAt: expiresAt:
@ -516,6 +526,20 @@ info:
title: Go API Templates title: Go API Templates
version: "1.0" version: "1.0"
paths: paths:
/v1/actions/ensure-asset-ids:
post:
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/v1.EnsureAssetIDResult'
security:
- Bearer: []
summary: Get the current user
tags:
- Group
/v1/groups: /v1/groups:
get: get:
produces: produces:

View file

@ -8,17 +8,38 @@ type AllServices struct {
Items *ItemService Items *ItemService
} }
func New(repos *repo.AllRepos) *AllServices { type OptionsFunc func(*options)
type options struct {
autoIncrementAssetID bool
}
func WithAutoIncrementAssetID(v bool) func(*options) {
return func(o *options) {
o.autoIncrementAssetID = v
}
}
func New(repos *repo.AllRepos, opts ...OptionsFunc) *AllServices {
if repos == nil { if repos == nil {
panic("repos cannot be nil") panic("repos cannot be nil")
} }
options := &options{
autoIncrementAssetID: true,
}
for _, opt := range opts {
opt(options)
}
return &AllServices{ return &AllServices{
User: &UserService{repos}, User: &UserService{repos},
Group: &GroupService{repos}, Group: &GroupService{repos},
Items: &ItemService{ Items: &ItemService{
repo: repos, repo: repos,
at: attachmentTokens{}, at: attachmentTokens{},
autoIncrementAssetID: options.autoIncrementAssetID,
}, },
} }
} }

View file

@ -21,8 +21,49 @@ type ItemService struct {
// at is a map of tokens to attachment IDs. This is used to store the attachment ID // at is a map of tokens to attachment IDs. This is used to store the attachment ID
// for issued URLs // for issued URLs
at attachmentTokens at attachmentTokens
autoIncrementAssetID bool
} }
func (svc *ItemService) Create(ctx Context, item repo.ItemCreate) (repo.ItemOut, error) {
if svc.autoIncrementAssetID {
highest, err := svc.repo.Items.GetHighestAssetID(ctx, ctx.GID)
if err != nil {
return repo.ItemOut{}, err
}
item.AssetID = repo.AssetID(highest + 1)
}
return svc.repo.Items.Create(ctx, ctx.GID, item)
}
func (svc *ItemService) EnsureAssetID(ctx context.Context, GID uuid.UUID) (int, error) {
items, err := svc.repo.Items.GetAllZeroAssetID(ctx, GID)
if err != nil {
return 0, err
}
highest, err := svc.repo.Items.GetHighestAssetID(ctx, GID)
if err != nil {
return 0, err
}
finished := 0
for _, item := range items {
highest++
err = svc.repo.Items.SetAssetID(ctx, GID, item.ID, repo.AssetID(highest))
if err != nil {
return 0, err
}
finished++
}
return finished, nil
}
func (svc *ItemService) CsvImport(ctx context.Context, GID uuid.UUID, data [][]string) (int, error) { func (svc *ItemService) CsvImport(ctx context.Context, GID uuid.UUID, data [][]string) (int, error) {
loaded := []csvRow{} loaded := []csvRow{}
@ -114,6 +155,14 @@ func (svc *ItemService) CsvImport(ctx context.Context, GID uuid.UUID, data [][]s
} }
} }
highest := repo.AssetID(-1)
if svc.autoIncrementAssetID {
highest, err = svc.repo.Items.GetHighestAssetID(ctx, GID)
if err != nil {
return 0, err
}
}
// Create the items // Create the items
var count int var count int
for _, row := range loaded { for _, row := range loaded {
@ -139,13 +188,20 @@ func (svc *ItemService) CsvImport(ctx context.Context, GID uuid.UUID, data [][]s
Str("location", row.Location). Str("location", row.Location).
Msgf("Creating Item: %s", row.Item.Name) Msgf("Creating Item: %s", row.Item.Name)
result, err := svc.repo.Items.Create(ctx, GID, repo.ItemCreate{ data := repo.ItemCreate{
ImportRef: row.Item.ImportRef, ImportRef: row.Item.ImportRef,
Name: row.Item.Name, Name: row.Item.Name,
Description: row.Item.Description, Description: row.Item.Description,
LabelIDs: labelIDs, LabelIDs: labelIDs,
LocationID: locationID, LocationID: locationID,
}) }
if svc.autoIncrementAssetID {
highest++
data.AssetID = highest
}
result, err := svc.repo.Items.Create(ctx, GID, data)
if err != nil { if err != nil {
return count, err return count, err
@ -156,6 +212,7 @@ func (svc *ItemService) CsvImport(ctx context.Context, GID uuid.UUID, data [][]s
// Edges // Edges
LocationID: locationID, LocationID: locationID,
LabelIDs: labelIDs, LabelIDs: labelIDs,
AssetID: data.AssetID,
// General Fields // General Fields
ID: result.ID, ID: result.ID,

View file

@ -37,6 +37,8 @@ type Item struct {
Insured bool `json:"insured,omitempty"` Insured bool `json:"insured,omitempty"`
// Archived holds the value of the "archived" field. // Archived holds the value of the "archived" field.
Archived bool `json:"archived,omitempty"` Archived bool `json:"archived,omitempty"`
// AssetID holds the value of the "asset_id" field.
AssetID int `json:"asset_id,omitempty"`
// SerialNumber holds the value of the "serial_number" field. // SerialNumber holds the value of the "serial_number" field.
SerialNumber string `json:"serial_number,omitempty"` SerialNumber string `json:"serial_number,omitempty"`
// ModelNumber holds the value of the "model_number" field. // ModelNumber holds the value of the "model_number" field.
@ -176,7 +178,7 @@ func (*Item) scanValues(columns []string) ([]any, error) {
values[i] = new(sql.NullBool) values[i] = new(sql.NullBool)
case item.FieldPurchasePrice, item.FieldSoldPrice: case item.FieldPurchasePrice, item.FieldSoldPrice:
values[i] = new(sql.NullFloat64) values[i] = new(sql.NullFloat64)
case item.FieldQuantity: case item.FieldQuantity, item.FieldAssetID:
values[i] = new(sql.NullInt64) values[i] = new(sql.NullInt64)
case item.FieldName, item.FieldDescription, item.FieldImportRef, item.FieldNotes, item.FieldSerialNumber, item.FieldModelNumber, item.FieldManufacturer, item.FieldWarrantyDetails, item.FieldPurchaseFrom, item.FieldSoldTo, item.FieldSoldNotes: case item.FieldName, item.FieldDescription, item.FieldImportRef, item.FieldNotes, item.FieldSerialNumber, item.FieldModelNumber, item.FieldManufacturer, item.FieldWarrantyDetails, item.FieldPurchaseFrom, item.FieldSoldTo, item.FieldSoldNotes:
values[i] = new(sql.NullString) values[i] = new(sql.NullString)
@ -265,6 +267,12 @@ func (i *Item) assignValues(columns []string, values []any) error {
} else if value.Valid { } else if value.Valid {
i.Archived = value.Bool i.Archived = value.Bool
} }
case item.FieldAssetID:
if value, ok := values[j].(*sql.NullInt64); !ok {
return fmt.Errorf("unexpected type %T for field asset_id", values[j])
} else if value.Valid {
i.AssetID = int(value.Int64)
}
case item.FieldSerialNumber: case item.FieldSerialNumber:
if value, ok := values[j].(*sql.NullString); !ok { if value, ok := values[j].(*sql.NullString); !ok {
return fmt.Errorf("unexpected type %T for field serial_number", values[j]) return fmt.Errorf("unexpected type %T for field serial_number", values[j])
@ -454,6 +462,9 @@ func (i *Item) String() string {
builder.WriteString("archived=") builder.WriteString("archived=")
builder.WriteString(fmt.Sprintf("%v", i.Archived)) builder.WriteString(fmt.Sprintf("%v", i.Archived))
builder.WriteString(", ") builder.WriteString(", ")
builder.WriteString("asset_id=")
builder.WriteString(fmt.Sprintf("%v", i.AssetID))
builder.WriteString(", ")
builder.WriteString("serial_number=") builder.WriteString("serial_number=")
builder.WriteString(i.SerialNumber) builder.WriteString(i.SerialNumber)
builder.WriteString(", ") builder.WriteString(", ")

View file

@ -31,6 +31,8 @@ const (
FieldInsured = "insured" FieldInsured = "insured"
// FieldArchived holds the string denoting the archived field in the database. // FieldArchived holds the string denoting the archived field in the database.
FieldArchived = "archived" FieldArchived = "archived"
// FieldAssetID holds the string denoting the asset_id field in the database.
FieldAssetID = "asset_id"
// FieldSerialNumber holds the string denoting the serial_number field in the database. // FieldSerialNumber holds the string denoting the serial_number field in the database.
FieldSerialNumber = "serial_number" FieldSerialNumber = "serial_number"
// FieldModelNumber holds the string denoting the model_number field in the database. // FieldModelNumber holds the string denoting the model_number field in the database.
@ -128,6 +130,7 @@ var Columns = []string{
FieldQuantity, FieldQuantity,
FieldInsured, FieldInsured,
FieldArchived, FieldArchived,
FieldAssetID,
FieldSerialNumber, FieldSerialNumber,
FieldModelNumber, FieldModelNumber,
FieldManufacturer, FieldManufacturer,
@ -193,6 +196,8 @@ var (
DefaultInsured bool DefaultInsured bool
// DefaultArchived holds the default value on creation for the "archived" field. // DefaultArchived holds the default value on creation for the "archived" field.
DefaultArchived bool DefaultArchived bool
// DefaultAssetID holds the default value on creation for the "asset_id" field.
DefaultAssetID int
// SerialNumberValidator is a validator for the "serial_number" field. It is called by the builders before save. // SerialNumberValidator is a validator for the "serial_number" field. It is called by the builders before save.
SerialNumberValidator func(string) error SerialNumberValidator func(string) error
// ModelNumberValidator is a validator for the "model_number" field. It is called by the builders before save. // ModelNumberValidator is a validator for the "model_number" field. It is called by the builders before save.

View file

@ -145,6 +145,13 @@ func Archived(v bool) predicate.Item {
}) })
} }
// AssetID applies equality check predicate on the "asset_id" field. It's identical to AssetIDEQ.
func AssetID(v int) predicate.Item {
return predicate.Item(func(s *sql.Selector) {
s.Where(sql.EQ(s.C(FieldAssetID), v))
})
}
// SerialNumber applies equality check predicate on the "serial_number" field. It's identical to SerialNumberEQ. // SerialNumber applies equality check predicate on the "serial_number" field. It's identical to SerialNumberEQ.
func SerialNumber(v string) predicate.Item { func SerialNumber(v string) predicate.Item {
return predicate.Item(func(s *sql.Selector) { return predicate.Item(func(s *sql.Selector) {
@ -894,6 +901,70 @@ func ArchivedNEQ(v bool) predicate.Item {
}) })
} }
// AssetIDEQ applies the EQ predicate on the "asset_id" field.
func AssetIDEQ(v int) predicate.Item {
return predicate.Item(func(s *sql.Selector) {
s.Where(sql.EQ(s.C(FieldAssetID), v))
})
}
// AssetIDNEQ applies the NEQ predicate on the "asset_id" field.
func AssetIDNEQ(v int) predicate.Item {
return predicate.Item(func(s *sql.Selector) {
s.Where(sql.NEQ(s.C(FieldAssetID), v))
})
}
// AssetIDIn applies the In predicate on the "asset_id" field.
func AssetIDIn(vs ...int) predicate.Item {
v := make([]any, len(vs))
for i := range v {
v[i] = vs[i]
}
return predicate.Item(func(s *sql.Selector) {
s.Where(sql.In(s.C(FieldAssetID), v...))
})
}
// AssetIDNotIn applies the NotIn predicate on the "asset_id" field.
func AssetIDNotIn(vs ...int) predicate.Item {
v := make([]any, len(vs))
for i := range v {
v[i] = vs[i]
}
return predicate.Item(func(s *sql.Selector) {
s.Where(sql.NotIn(s.C(FieldAssetID), v...))
})
}
// AssetIDGT applies the GT predicate on the "asset_id" field.
func AssetIDGT(v int) predicate.Item {
return predicate.Item(func(s *sql.Selector) {
s.Where(sql.GT(s.C(FieldAssetID), v))
})
}
// AssetIDGTE applies the GTE predicate on the "asset_id" field.
func AssetIDGTE(v int) predicate.Item {
return predicate.Item(func(s *sql.Selector) {
s.Where(sql.GTE(s.C(FieldAssetID), v))
})
}
// AssetIDLT applies the LT predicate on the "asset_id" field.
func AssetIDLT(v int) predicate.Item {
return predicate.Item(func(s *sql.Selector) {
s.Where(sql.LT(s.C(FieldAssetID), v))
})
}
// AssetIDLTE applies the LTE predicate on the "asset_id" field.
func AssetIDLTE(v int) predicate.Item {
return predicate.Item(func(s *sql.Selector) {
s.Where(sql.LTE(s.C(FieldAssetID), v))
})
}
// SerialNumberEQ applies the EQ predicate on the "serial_number" field. // SerialNumberEQ applies the EQ predicate on the "serial_number" field.
func SerialNumberEQ(v string) predicate.Item { func SerialNumberEQ(v string) predicate.Item {
return predicate.Item(func(s *sql.Selector) { return predicate.Item(func(s *sql.Selector) {

View file

@ -144,6 +144,20 @@ func (ic *ItemCreate) SetNillableArchived(b *bool) *ItemCreate {
return ic return ic
} }
// SetAssetID sets the "asset_id" field.
func (ic *ItemCreate) SetAssetID(i int) *ItemCreate {
ic.mutation.SetAssetID(i)
return ic
}
// SetNillableAssetID sets the "asset_id" field if the given value is not nil.
func (ic *ItemCreate) SetNillableAssetID(i *int) *ItemCreate {
if i != nil {
ic.SetAssetID(*i)
}
return ic
}
// SetSerialNumber sets the "serial_number" field. // SetSerialNumber sets the "serial_number" field.
func (ic *ItemCreate) SetSerialNumber(s string) *ItemCreate { func (ic *ItemCreate) SetSerialNumber(s string) *ItemCreate {
ic.mutation.SetSerialNumber(s) ic.mutation.SetSerialNumber(s)
@ -546,6 +560,10 @@ func (ic *ItemCreate) defaults() {
v := item.DefaultArchived v := item.DefaultArchived
ic.mutation.SetArchived(v) ic.mutation.SetArchived(v)
} }
if _, ok := ic.mutation.AssetID(); !ok {
v := item.DefaultAssetID
ic.mutation.SetAssetID(v)
}
if _, ok := ic.mutation.LifetimeWarranty(); !ok { if _, ok := ic.mutation.LifetimeWarranty(); !ok {
v := item.DefaultLifetimeWarranty v := item.DefaultLifetimeWarranty
ic.mutation.SetLifetimeWarranty(v) ic.mutation.SetLifetimeWarranty(v)
@ -604,6 +622,9 @@ func (ic *ItemCreate) check() error {
if _, ok := ic.mutation.Archived(); !ok { if _, ok := ic.mutation.Archived(); !ok {
return &ValidationError{Name: "archived", err: errors.New(`ent: missing required field "Item.archived"`)} return &ValidationError{Name: "archived", err: errors.New(`ent: missing required field "Item.archived"`)}
} }
if _, ok := ic.mutation.AssetID(); !ok {
return &ValidationError{Name: "asset_id", err: errors.New(`ent: missing required field "Item.asset_id"`)}
}
if v, ok := ic.mutation.SerialNumber(); ok { if v, ok := ic.mutation.SerialNumber(); ok {
if err := item.SerialNumberValidator(v); err != nil { if err := item.SerialNumberValidator(v); err != nil {
return &ValidationError{Name: "serial_number", err: fmt.Errorf(`ent: validator failed for field "Item.serial_number": %w`, err)} return &ValidationError{Name: "serial_number", err: fmt.Errorf(`ent: validator failed for field "Item.serial_number": %w`, err)}
@ -749,6 +770,14 @@ func (ic *ItemCreate) createSpec() (*Item, *sqlgraph.CreateSpec) {
}) })
_node.Archived = value _node.Archived = value
} }
if value, ok := ic.mutation.AssetID(); ok {
_spec.Fields = append(_spec.Fields, &sqlgraph.FieldSpec{
Type: field.TypeInt,
Value: value,
Column: item.FieldAssetID,
})
_node.AssetID = value
}
if value, ok := ic.mutation.SerialNumber(); ok { if value, ok := ic.mutation.SerialNumber(); ok {
_spec.Fields = append(_spec.Fields, &sqlgraph.FieldSpec{ _spec.Fields = append(_spec.Fields, &sqlgraph.FieldSpec{
Type: field.TypeString, Type: field.TypeString,

View file

@ -135,6 +135,27 @@ func (iu *ItemUpdate) SetNillableArchived(b *bool) *ItemUpdate {
return iu return iu
} }
// SetAssetID sets the "asset_id" field.
func (iu *ItemUpdate) SetAssetID(i int) *ItemUpdate {
iu.mutation.ResetAssetID()
iu.mutation.SetAssetID(i)
return iu
}
// SetNillableAssetID sets the "asset_id" field if the given value is not nil.
func (iu *ItemUpdate) SetNillableAssetID(i *int) *ItemUpdate {
if i != nil {
iu.SetAssetID(*i)
}
return iu
}
// AddAssetID adds i to the "asset_id" field.
func (iu *ItemUpdate) AddAssetID(i int) *ItemUpdate {
iu.mutation.AddAssetID(i)
return iu
}
// SetSerialNumber sets the "serial_number" field. // SetSerialNumber sets the "serial_number" field.
func (iu *ItemUpdate) SetSerialNumber(s string) *ItemUpdate { func (iu *ItemUpdate) SetSerialNumber(s string) *ItemUpdate {
iu.mutation.SetSerialNumber(s) iu.mutation.SetSerialNumber(s)
@ -816,6 +837,20 @@ func (iu *ItemUpdate) sqlSave(ctx context.Context) (n int, err error) {
Column: item.FieldArchived, Column: item.FieldArchived,
}) })
} }
if value, ok := iu.mutation.AssetID(); ok {
_spec.Fields.Set = append(_spec.Fields.Set, &sqlgraph.FieldSpec{
Type: field.TypeInt,
Value: value,
Column: item.FieldAssetID,
})
}
if value, ok := iu.mutation.AddedAssetID(); ok {
_spec.Fields.Add = append(_spec.Fields.Add, &sqlgraph.FieldSpec{
Type: field.TypeInt,
Value: value,
Column: item.FieldAssetID,
})
}
if value, ok := iu.mutation.SerialNumber(); ok { if value, ok := iu.mutation.SerialNumber(); ok {
_spec.Fields.Set = append(_spec.Fields.Set, &sqlgraph.FieldSpec{ _spec.Fields.Set = append(_spec.Fields.Set, &sqlgraph.FieldSpec{
Type: field.TypeString, Type: field.TypeString,
@ -1422,6 +1457,27 @@ func (iuo *ItemUpdateOne) SetNillableArchived(b *bool) *ItemUpdateOne {
return iuo return iuo
} }
// SetAssetID sets the "asset_id" field.
func (iuo *ItemUpdateOne) SetAssetID(i int) *ItemUpdateOne {
iuo.mutation.ResetAssetID()
iuo.mutation.SetAssetID(i)
return iuo
}
// SetNillableAssetID sets the "asset_id" field if the given value is not nil.
func (iuo *ItemUpdateOne) SetNillableAssetID(i *int) *ItemUpdateOne {
if i != nil {
iuo.SetAssetID(*i)
}
return iuo
}
// AddAssetID adds i to the "asset_id" field.
func (iuo *ItemUpdateOne) AddAssetID(i int) *ItemUpdateOne {
iuo.mutation.AddAssetID(i)
return iuo
}
// SetSerialNumber sets the "serial_number" field. // SetSerialNumber sets the "serial_number" field.
func (iuo *ItemUpdateOne) SetSerialNumber(s string) *ItemUpdateOne { func (iuo *ItemUpdateOne) SetSerialNumber(s string) *ItemUpdateOne {
iuo.mutation.SetSerialNumber(s) iuo.mutation.SetSerialNumber(s)
@ -2133,6 +2189,20 @@ func (iuo *ItemUpdateOne) sqlSave(ctx context.Context) (_node *Item, err error)
Column: item.FieldArchived, Column: item.FieldArchived,
}) })
} }
if value, ok := iuo.mutation.AssetID(); ok {
_spec.Fields.Set = append(_spec.Fields.Set, &sqlgraph.FieldSpec{
Type: field.TypeInt,
Value: value,
Column: item.FieldAssetID,
})
}
if value, ok := iuo.mutation.AddedAssetID(); ok {
_spec.Fields.Add = append(_spec.Fields.Add, &sqlgraph.FieldSpec{
Type: field.TypeInt,
Value: value,
Column: item.FieldAssetID,
})
}
if value, ok := iuo.mutation.SerialNumber(); ok { if value, ok := iuo.mutation.SerialNumber(); ok {
_spec.Fields.Set = append(_spec.Fields.Set, &sqlgraph.FieldSpec{ _spec.Fields.Set = append(_spec.Fields.Set, &sqlgraph.FieldSpec{
Type: field.TypeString, Type: field.TypeString,

View file

@ -171,6 +171,7 @@ var (
{Name: "quantity", Type: field.TypeInt, Default: 1}, {Name: "quantity", Type: field.TypeInt, Default: 1},
{Name: "insured", Type: field.TypeBool, Default: false}, {Name: "insured", Type: field.TypeBool, Default: false},
{Name: "archived", Type: field.TypeBool, Default: false}, {Name: "archived", Type: field.TypeBool, Default: false},
{Name: "asset_id", Type: field.TypeInt, Default: 0},
{Name: "serial_number", Type: field.TypeString, Nullable: true, Size: 255}, {Name: "serial_number", Type: field.TypeString, Nullable: true, Size: 255},
{Name: "model_number", Type: field.TypeString, Nullable: true, Size: 255}, {Name: "model_number", Type: field.TypeString, Nullable: true, Size: 255},
{Name: "manufacturer", Type: field.TypeString, Nullable: true, Size: 255}, {Name: "manufacturer", Type: field.TypeString, Nullable: true, Size: 255},
@ -196,19 +197,19 @@ var (
ForeignKeys: []*schema.ForeignKey{ ForeignKeys: []*schema.ForeignKey{
{ {
Symbol: "items_groups_items", Symbol: "items_groups_items",
Columns: []*schema.Column{ItemsColumns[23]}, Columns: []*schema.Column{ItemsColumns[24]},
RefColumns: []*schema.Column{GroupsColumns[0]}, RefColumns: []*schema.Column{GroupsColumns[0]},
OnDelete: schema.Cascade, OnDelete: schema.Cascade,
}, },
{ {
Symbol: "items_items_children", Symbol: "items_items_children",
Columns: []*schema.Column{ItemsColumns[24]}, Columns: []*schema.Column{ItemsColumns[25]},
RefColumns: []*schema.Column{ItemsColumns[0]}, RefColumns: []*schema.Column{ItemsColumns[0]},
OnDelete: schema.SetNull, OnDelete: schema.SetNull,
}, },
{ {
Symbol: "items_locations_items", Symbol: "items_locations_items",
Columns: []*schema.Column{ItemsColumns[25]}, Columns: []*schema.Column{ItemsColumns[26]},
RefColumns: []*schema.Column{LocationsColumns[0]}, RefColumns: []*schema.Column{LocationsColumns[0]},
OnDelete: schema.Cascade, OnDelete: schema.Cascade,
}, },
@ -222,23 +223,28 @@ var (
{ {
Name: "item_manufacturer", Name: "item_manufacturer",
Unique: false, Unique: false,
Columns: []*schema.Column{ItemsColumns[12]}, Columns: []*schema.Column{ItemsColumns[13]},
}, },
{ {
Name: "item_model_number", Name: "item_model_number",
Unique: false, Unique: false,
Columns: []*schema.Column{ItemsColumns[11]}, Columns: []*schema.Column{ItemsColumns[12]},
}, },
{ {
Name: "item_serial_number", Name: "item_serial_number",
Unique: false, Unique: false,
Columns: []*schema.Column{ItemsColumns[10]}, Columns: []*schema.Column{ItemsColumns[11]},
}, },
{ {
Name: "item_archived", Name: "item_archived",
Unique: false, Unique: false,
Columns: []*schema.Column{ItemsColumns[9]}, Columns: []*schema.Column{ItemsColumns[9]},
}, },
{
Name: "item_asset_id",
Unique: false,
Columns: []*schema.Column{ItemsColumns[10]},
},
}, },
} }
// ItemFieldsColumns holds the columns for the "item_fields" table. // ItemFieldsColumns holds the columns for the "item_fields" table.

View file

@ -4134,6 +4134,8 @@ type ItemMutation struct {
addquantity *int addquantity *int
insured *bool insured *bool
archived *bool archived *bool
asset_id *int
addasset_id *int
serial_number *string serial_number *string
model_number *string model_number *string
manufacturer *string manufacturer *string
@ -4660,6 +4662,62 @@ func (m *ItemMutation) ResetArchived() {
m.archived = nil m.archived = nil
} }
// SetAssetID sets the "asset_id" field.
func (m *ItemMutation) SetAssetID(i int) {
m.asset_id = &i
m.addasset_id = nil
}
// AssetID returns the value of the "asset_id" field in the mutation.
func (m *ItemMutation) AssetID() (r int, exists bool) {
v := m.asset_id
if v == nil {
return
}
return *v, true
}
// OldAssetID returns the old "asset_id" field's value of the Item entity.
// If the Item object wasn't provided to the builder, the object is fetched from the database.
// An error is returned if the mutation operation is not UpdateOne, or the database query fails.
func (m *ItemMutation) OldAssetID(ctx context.Context) (v int, err error) {
if !m.op.Is(OpUpdateOne) {
return v, errors.New("OldAssetID is only allowed on UpdateOne operations")
}
if m.id == nil || m.oldValue == nil {
return v, errors.New("OldAssetID requires an ID field in the mutation")
}
oldValue, err := m.oldValue(ctx)
if err != nil {
return v, fmt.Errorf("querying old value for OldAssetID: %w", err)
}
return oldValue.AssetID, nil
}
// AddAssetID adds i to the "asset_id" field.
func (m *ItemMutation) AddAssetID(i int) {
if m.addasset_id != nil {
*m.addasset_id += i
} else {
m.addasset_id = &i
}
}
// AddedAssetID returns the value that was added to the "asset_id" field in this mutation.
func (m *ItemMutation) AddedAssetID() (r int, exists bool) {
v := m.addasset_id
if v == nil {
return
}
return *v, true
}
// ResetAssetID resets all changes to the "asset_id" field.
func (m *ItemMutation) ResetAssetID() {
m.asset_id = nil
m.addasset_id = nil
}
// SetSerialNumber sets the "serial_number" field. // SetSerialNumber sets the "serial_number" field.
func (m *ItemMutation) SetSerialNumber(s string) { func (m *ItemMutation) SetSerialNumber(s string) {
m.serial_number = &s m.serial_number = &s
@ -5650,7 +5708,7 @@ func (m *ItemMutation) Type() string {
// order to get all numeric fields that were incremented/decremented, call // order to get all numeric fields that were incremented/decremented, call
// AddedFields(). // AddedFields().
func (m *ItemMutation) Fields() []string { func (m *ItemMutation) Fields() []string {
fields := make([]string, 0, 22) fields := make([]string, 0, 23)
if m.created_at != nil { if m.created_at != nil {
fields = append(fields, item.FieldCreatedAt) fields = append(fields, item.FieldCreatedAt)
} }
@ -5678,6 +5736,9 @@ func (m *ItemMutation) Fields() []string {
if m.archived != nil { if m.archived != nil {
fields = append(fields, item.FieldArchived) fields = append(fields, item.FieldArchived)
} }
if m.asset_id != nil {
fields = append(fields, item.FieldAssetID)
}
if m.serial_number != nil { if m.serial_number != nil {
fields = append(fields, item.FieldSerialNumber) fields = append(fields, item.FieldSerialNumber)
} }
@ -5743,6 +5804,8 @@ func (m *ItemMutation) Field(name string) (ent.Value, bool) {
return m.Insured() return m.Insured()
case item.FieldArchived: case item.FieldArchived:
return m.Archived() return m.Archived()
case item.FieldAssetID:
return m.AssetID()
case item.FieldSerialNumber: case item.FieldSerialNumber:
return m.SerialNumber() return m.SerialNumber()
case item.FieldModelNumber: case item.FieldModelNumber:
@ -5796,6 +5859,8 @@ func (m *ItemMutation) OldField(ctx context.Context, name string) (ent.Value, er
return m.OldInsured(ctx) return m.OldInsured(ctx)
case item.FieldArchived: case item.FieldArchived:
return m.OldArchived(ctx) return m.OldArchived(ctx)
case item.FieldAssetID:
return m.OldAssetID(ctx)
case item.FieldSerialNumber: case item.FieldSerialNumber:
return m.OldSerialNumber(ctx) return m.OldSerialNumber(ctx)
case item.FieldModelNumber: case item.FieldModelNumber:
@ -5894,6 +5959,13 @@ func (m *ItemMutation) SetField(name string, value ent.Value) error {
} }
m.SetArchived(v) m.SetArchived(v)
return nil return nil
case item.FieldAssetID:
v, ok := value.(int)
if !ok {
return fmt.Errorf("unexpected type %T for field %s", value, name)
}
m.SetAssetID(v)
return nil
case item.FieldSerialNumber: case item.FieldSerialNumber:
v, ok := value.(string) v, ok := value.(string)
if !ok { if !ok {
@ -5996,6 +6068,9 @@ func (m *ItemMutation) AddedFields() []string {
if m.addquantity != nil { if m.addquantity != nil {
fields = append(fields, item.FieldQuantity) fields = append(fields, item.FieldQuantity)
} }
if m.addasset_id != nil {
fields = append(fields, item.FieldAssetID)
}
if m.addpurchase_price != nil { if m.addpurchase_price != nil {
fields = append(fields, item.FieldPurchasePrice) fields = append(fields, item.FieldPurchasePrice)
} }
@ -6012,6 +6087,8 @@ func (m *ItemMutation) AddedField(name string) (ent.Value, bool) {
switch name { switch name {
case item.FieldQuantity: case item.FieldQuantity:
return m.AddedQuantity() return m.AddedQuantity()
case item.FieldAssetID:
return m.AddedAssetID()
case item.FieldPurchasePrice: case item.FieldPurchasePrice:
return m.AddedPurchasePrice() return m.AddedPurchasePrice()
case item.FieldSoldPrice: case item.FieldSoldPrice:
@ -6032,6 +6109,13 @@ func (m *ItemMutation) AddField(name string, value ent.Value) error {
} }
m.AddQuantity(v) m.AddQuantity(v)
return nil return nil
case item.FieldAssetID:
v, ok := value.(int)
if !ok {
return fmt.Errorf("unexpected type %T for field %s", value, name)
}
m.AddAssetID(v)
return nil
case item.FieldPurchasePrice: case item.FieldPurchasePrice:
v, ok := value.(float64) v, ok := value.(float64)
if !ok { if !ok {
@ -6181,6 +6265,9 @@ func (m *ItemMutation) ResetField(name string) error {
case item.FieldArchived: case item.FieldArchived:
m.ResetArchived() m.ResetArchived()
return nil return nil
case item.FieldAssetID:
m.ResetAssetID()
return nil
case item.FieldSerialNumber: case item.FieldSerialNumber:
m.ResetSerialNumber() m.ResetSerialNumber()
return nil return nil

View file

@ -275,36 +275,40 @@ func init() {
itemDescArchived := itemFields[4].Descriptor() itemDescArchived := itemFields[4].Descriptor()
// item.DefaultArchived holds the default value on creation for the archived field. // item.DefaultArchived holds the default value on creation for the archived field.
item.DefaultArchived = itemDescArchived.Default.(bool) item.DefaultArchived = itemDescArchived.Default.(bool)
// itemDescAssetID is the schema descriptor for asset_id field.
itemDescAssetID := itemFields[5].Descriptor()
// item.DefaultAssetID holds the default value on creation for the asset_id field.
item.DefaultAssetID = itemDescAssetID.Default.(int)
// itemDescSerialNumber is the schema descriptor for serial_number field. // itemDescSerialNumber is the schema descriptor for serial_number field.
itemDescSerialNumber := itemFields[5].Descriptor() itemDescSerialNumber := itemFields[6].Descriptor()
// item.SerialNumberValidator is a validator for the "serial_number" field. It is called by the builders before save. // item.SerialNumberValidator is a validator for the "serial_number" field. It is called by the builders before save.
item.SerialNumberValidator = itemDescSerialNumber.Validators[0].(func(string) error) item.SerialNumberValidator = itemDescSerialNumber.Validators[0].(func(string) error)
// itemDescModelNumber is the schema descriptor for model_number field. // itemDescModelNumber is the schema descriptor for model_number field.
itemDescModelNumber := itemFields[6].Descriptor() itemDescModelNumber := itemFields[7].Descriptor()
// item.ModelNumberValidator is a validator for the "model_number" field. It is called by the builders before save. // item.ModelNumberValidator is a validator for the "model_number" field. It is called by the builders before save.
item.ModelNumberValidator = itemDescModelNumber.Validators[0].(func(string) error) item.ModelNumberValidator = itemDescModelNumber.Validators[0].(func(string) error)
// itemDescManufacturer is the schema descriptor for manufacturer field. // itemDescManufacturer is the schema descriptor for manufacturer field.
itemDescManufacturer := itemFields[7].Descriptor() itemDescManufacturer := itemFields[8].Descriptor()
// item.ManufacturerValidator is a validator for the "manufacturer" field. It is called by the builders before save. // item.ManufacturerValidator is a validator for the "manufacturer" field. It is called by the builders before save.
item.ManufacturerValidator = itemDescManufacturer.Validators[0].(func(string) error) item.ManufacturerValidator = itemDescManufacturer.Validators[0].(func(string) error)
// itemDescLifetimeWarranty is the schema descriptor for lifetime_warranty field. // itemDescLifetimeWarranty is the schema descriptor for lifetime_warranty field.
itemDescLifetimeWarranty := itemFields[8].Descriptor() itemDescLifetimeWarranty := itemFields[9].Descriptor()
// item.DefaultLifetimeWarranty holds the default value on creation for the lifetime_warranty field. // item.DefaultLifetimeWarranty holds the default value on creation for the lifetime_warranty field.
item.DefaultLifetimeWarranty = itemDescLifetimeWarranty.Default.(bool) item.DefaultLifetimeWarranty = itemDescLifetimeWarranty.Default.(bool)
// itemDescWarrantyDetails is the schema descriptor for warranty_details field. // itemDescWarrantyDetails is the schema descriptor for warranty_details field.
itemDescWarrantyDetails := itemFields[10].Descriptor() itemDescWarrantyDetails := itemFields[11].Descriptor()
// item.WarrantyDetailsValidator is a validator for the "warranty_details" field. It is called by the builders before save. // item.WarrantyDetailsValidator is a validator for the "warranty_details" field. It is called by the builders before save.
item.WarrantyDetailsValidator = itemDescWarrantyDetails.Validators[0].(func(string) error) item.WarrantyDetailsValidator = itemDescWarrantyDetails.Validators[0].(func(string) error)
// itemDescPurchasePrice is the schema descriptor for purchase_price field. // itemDescPurchasePrice is the schema descriptor for purchase_price field.
itemDescPurchasePrice := itemFields[13].Descriptor() itemDescPurchasePrice := itemFields[14].Descriptor()
// item.DefaultPurchasePrice holds the default value on creation for the purchase_price field. // item.DefaultPurchasePrice holds the default value on creation for the purchase_price field.
item.DefaultPurchasePrice = itemDescPurchasePrice.Default.(float64) item.DefaultPurchasePrice = itemDescPurchasePrice.Default.(float64)
// itemDescSoldPrice is the schema descriptor for sold_price field. // itemDescSoldPrice is the schema descriptor for sold_price field.
itemDescSoldPrice := itemFields[16].Descriptor() itemDescSoldPrice := itemFields[17].Descriptor()
// item.DefaultSoldPrice holds the default value on creation for the sold_price field. // item.DefaultSoldPrice holds the default value on creation for the sold_price field.
item.DefaultSoldPrice = itemDescSoldPrice.Default.(float64) item.DefaultSoldPrice = itemDescSoldPrice.Default.(float64)
// itemDescSoldNotes is the schema descriptor for sold_notes field. // itemDescSoldNotes is the schema descriptor for sold_notes field.
itemDescSoldNotes := itemFields[17].Descriptor() itemDescSoldNotes := itemFields[18].Descriptor()
// item.SoldNotesValidator is a validator for the "sold_notes" field. It is called by the builders before save. // item.SoldNotesValidator is a validator for the "sold_notes" field. It is called by the builders before save.
item.SoldNotesValidator = itemDescSoldNotes.Validators[0].(func(string) error) item.SoldNotesValidator = itemDescSoldNotes.Validators[0].(func(string) error)
// itemDescID is the schema descriptor for id field. // itemDescID is the schema descriptor for id field.

View file

@ -29,6 +29,7 @@ func (Item) Indexes() []ent.Index {
index.Fields("model_number"), index.Fields("model_number"),
index.Fields("serial_number"), index.Fields("serial_number"),
index.Fields("archived"), index.Fields("archived"),
index.Fields("asset_id"),
} }
} }
@ -48,6 +49,8 @@ func (Item) Fields() []ent.Field {
Default(false), Default(false),
field.Bool("archived"). field.Bool("archived").
Default(false), Default(false),
field.Int("asset_id").
Default(0),
// ------------------------------------ // ------------------------------------
// item identification // item identification

View file

@ -0,0 +1,24 @@
-- disable the enforcement of foreign-keys constraints
PRAGMA foreign_keys = off;
-- create "new_items" table
CREATE TABLE `new_items` (`id` uuid NOT NULL, `created_at` datetime NOT NULL, `updated_at` datetime NOT NULL, `name` text NOT NULL, `description` text NULL, `import_ref` text NULL, `notes` text NULL, `quantity` integer NOT NULL DEFAULT 1, `insured` bool NOT NULL DEFAULT false, `archived` bool NOT NULL DEFAULT false, `asset_id` integer NOT NULL DEFAULT 0, `serial_number` text NULL, `model_number` text NULL, `manufacturer` text NULL, `lifetime_warranty` bool NOT NULL DEFAULT false, `warranty_expires` datetime NULL, `warranty_details` text NULL, `purchase_time` datetime NULL, `purchase_from` text NULL, `purchase_price` real NOT NULL DEFAULT 0, `sold_time` datetime NULL, `sold_to` text NULL, `sold_price` real NOT NULL DEFAULT 0, `sold_notes` text NULL, `group_items` uuid NOT NULL, `item_children` uuid NULL, `location_items` uuid NULL, PRIMARY KEY (`id`), CONSTRAINT `items_groups_items` FOREIGN KEY (`group_items`) REFERENCES `groups` (`id`) ON DELETE CASCADE, CONSTRAINT `items_items_children` FOREIGN KEY (`item_children`) REFERENCES `items` (`id`) ON DELETE SET NULL, CONSTRAINT `items_locations_items` FOREIGN KEY (`location_items`) REFERENCES `locations` (`id`) ON DELETE CASCADE);
-- copy rows from old table "items" to new temporary table "new_items"
INSERT INTO `new_items` (`id`, `created_at`, `updated_at`, `name`, `description`, `import_ref`, `notes`, `quantity`, `insured`, `archived`, `serial_number`, `model_number`, `manufacturer`, `lifetime_warranty`, `warranty_expires`, `warranty_details`, `purchase_time`, `purchase_from`, `purchase_price`, `sold_time`, `sold_to`, `sold_price`, `sold_notes`, `group_items`, `item_children`, `location_items`) SELECT `id`, `created_at`, `updated_at`, `name`, `description`, `import_ref`, `notes`, `quantity`, `insured`, `archived`, `serial_number`, `model_number`, `manufacturer`, `lifetime_warranty`, `warranty_expires`, `warranty_details`, `purchase_time`, `purchase_from`, `purchase_price`, `sold_time`, `sold_to`, `sold_price`, `sold_notes`, `group_items`, `item_children`, `location_items` FROM `items`;
-- drop "items" table after copying rows
DROP TABLE `items`;
-- rename temporary table "new_items" to "items"
ALTER TABLE `new_items` RENAME TO `items`;
-- create index "item_name" to table: "items"
CREATE INDEX `item_name` ON `items` (`name`);
-- create index "item_manufacturer" to table: "items"
CREATE INDEX `item_manufacturer` ON `items` (`manufacturer`);
-- create index "item_model_number" to table: "items"
CREATE INDEX `item_model_number` ON `items` (`model_number`);
-- create index "item_serial_number" to table: "items"
CREATE INDEX `item_serial_number` ON `items` (`serial_number`);
-- create index "item_archived" to table: "items"
CREATE INDEX `item_archived` ON `items` (`archived`);
-- create index "item_asset_id" to table: "items"
CREATE INDEX `item_asset_id` ON `items` (`asset_id`);
-- enable back the enforcement of foreign-keys constraints
PRAGMA foreign_keys = on;

View file

@ -1,6 +1,7 @@
h1:i76VRMDIPdcmQtXTe9bzrgITAzLGjjVy9y8XaXIchAs= h1:z1tbZ3fYByqxL78Z+ov8mfQVjXcwsZeEcT0i+2DZ8a8=
20220929052825_init.sql h1:ZlCqm1wzjDmofeAcSX3jE4h4VcdTNGpRg2eabztDy9Q= 20220929052825_init.sql h1:ZlCqm1wzjDmofeAcSX3jE4h4VcdTNGpRg2eabztDy9Q=
20221001210956_group_invitations.sql h1:YQKJFtE39wFOcRNbZQ/d+ZlHwrcfcsZlcv/pLEYdpjw= 20221001210956_group_invitations.sql h1:YQKJFtE39wFOcRNbZQ/d+ZlHwrcfcsZlcv/pLEYdpjw=
20221009173029_add_user_roles.sql h1:vWmzAfgEWQeGk0Vn70zfVPCcfEZth3E0JcvyKTjpYyU= 20221009173029_add_user_roles.sql h1:vWmzAfgEWQeGk0Vn70zfVPCcfEZth3E0JcvyKTjpYyU=
20221020043305_allow_nesting_types.sql h1:4AyJpZ7l7SSJtJAQETYY802FHJ64ufYPJTqvwdiGn3M= 20221020043305_allow_nesting_types.sql h1:4AyJpZ7l7SSJtJAQETYY802FHJ64ufYPJTqvwdiGn3M=
20221101041931_add_archived_field.sql h1:L2WxiOh1svRn817cNURgqnEQg6DIcodZ1twK4tvxW94= 20221101041931_add_archived_field.sql h1:L2WxiOh1svRn817cNURgqnEQg6DIcodZ1twK4tvxW94=
20221113012312_add_asset_id_field.sql h1:DjD7e1PS8OfxGBWic8h0nO/X6CNnHEMqQjDCaaQ3M3Q=

View file

@ -0,0 +1,30 @@
package repo
import (
"bytes"
"fmt"
"strconv"
)
type AssetID int
func (aid AssetID) MarshalJSON() ([]byte, error) {
aidStr := fmt.Sprintf("%06d", aid)
aidStr = fmt.Sprintf("%s-%s", aidStr[:3], aidStr[3:])
return []byte(fmt.Sprintf(`"%s"`, aidStr)), nil
}
func (aid *AssetID) UnmarshalJSON(d []byte) error {
d = bytes.Replace(d, []byte(`"`), []byte(``), -1)
d = bytes.Replace(d, []byte(`-`), []byte(``), -1)
aidInt, err := strconv.Atoi(string(d))
if err != nil {
return err
}
*aid = AssetID(aidInt)
return nil
}

View file

@ -0,0 +1,115 @@
package repo
import (
"encoding/json"
"reflect"
"testing"
)
func TestAssetID_MarshalJSON(t *testing.T) {
tests := []struct {
name string
aid AssetID
want []byte
wantErr bool
}{
{
name: "basic test",
aid: 123,
want: []byte(`"000-123"`),
},
{
name: "zero test",
aid: 0,
want: []byte(`"000-000"`),
},
{
name: "large int",
aid: 123456789,
want: []byte(`"123-456789"`),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := tt.aid.MarshalJSON()
if (err != nil) != tt.wantErr {
t.Errorf("AssetID.MarshalJSON() error = %v, wantErr %v", err, tt.wantErr)
return
}
if !reflect.DeepEqual(got, tt.want) {
t.Errorf("AssetID.MarshalJSON() = %v, want %v", got, tt.want)
}
})
}
}
func TestAssetID_UnmarshalJSON(t *testing.T) {
type args struct {
data []byte
}
tests := []struct {
name string
aid *AssetID
args args
want AssetID
wantErr bool
}{
{
name: "basic test",
aid: new(AssetID),
want: 123,
args: args{
data: []byte(`{"AssetID":"000123"}`),
},
},
{
name: "dashed format",
aid: new(AssetID),
want: 123,
args: args{
data: []byte(`{"AssetID":"000-123"}`),
},
},
{
name: "no leading zeros",
aid: new(AssetID),
want: 123,
args: args{
data: []byte(`{"AssetID":"123"}`),
},
},
{
name: "trailing zeros",
aid: new(AssetID),
want: 123000,
args: args{
data: []byte(`{"AssetID":"000123000"}`),
},
},
{
name: "large int",
aid: new(AssetID),
want: 123456789,
args: args{
data: []byte(`{"AssetID":"123456789"}`),
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
st := struct {
AssetID AssetID `json:"AssetID"`
}{}
err := json.Unmarshal(tt.args.data, &st)
if (err != nil) != tt.wantErr {
t.Errorf("AssetID.UnmarshalJSON() error = %v, wantErr %v", err, tt.wantErr)
return
}
if st.AssetID != tt.want {
t.Errorf("AssetID.UnmarshalJSON() = %v, want %v", st.AssetID, tt.want)
}
})
}
}

View file

@ -44,6 +44,7 @@ type (
ParentID uuid.UUID `json:"parentId" extensions:"x-nullable"` ParentID uuid.UUID `json:"parentId" extensions:"x-nullable"`
Name string `json:"name"` Name string `json:"name"`
Description string `json:"description"` Description string `json:"description"`
AssetID AssetID `json:"-"`
// Edges // Edges
LocationID uuid.UUID `json:"locationId"` LocationID uuid.UUID `json:"locationId"`
@ -52,6 +53,7 @@ type (
ItemUpdate struct { ItemUpdate struct {
ParentID uuid.UUID `json:"parentId" extensions:"x-nullable,x-omitempty"` ParentID uuid.UUID `json:"parentId" extensions:"x-nullable,x-omitempty"`
ID uuid.UUID `json:"id"` ID uuid.UUID `json:"id"`
AssetID AssetID `json:"assetId"`
Name string `json:"name"` Name string `json:"name"`
Description string `json:"description"` Description string `json:"description"`
Quantity int `json:"quantity"` Quantity int `json:"quantity"`
@ -107,6 +109,7 @@ type (
ItemOut struct { ItemOut struct {
Parent *ItemSummary `json:"parent,omitempty" extensions:"x-nullable,x-omitempty"` Parent *ItemSummary `json:"parent,omitempty" extensions:"x-nullable,x-omitempty"`
ItemSummary ItemSummary
AssetID AssetID `json:"assetId,string"`
SerialNumber string `json:"serialNumber"` SerialNumber string `json:"serialNumber"`
ModelNumber string `json:"modelNumber"` ModelNumber string `json:"modelNumber"`
@ -215,6 +218,7 @@ func mapItemOut(item *ent.Item) ItemOut {
return ItemOut{ return ItemOut{
Parent: parent, Parent: parent,
AssetID: AssetID(item.AssetID),
ItemSummary: mapItemSummary(item), ItemSummary: mapItemSummary(item),
LifetimeWarranty: item.LifetimeWarranty, LifetimeWarranty: item.LifetimeWarranty,
WarrantyExpires: item.WarrantyExpires, WarrantyExpires: item.WarrantyExpires,
@ -359,13 +363,53 @@ func (e *ItemsRepository) GetAll(ctx context.Context, gid uuid.UUID) ([]ItemSumm
All(ctx)) All(ctx))
} }
func (e *ItemsRepository) GetAllZeroAssetID(ctx context.Context, GID uuid.UUID) ([]ItemSummary, error) {
q := e.db.Item.Query().Where(
item.HasGroupWith(group.ID(GID)),
item.AssetID(0),
).Order(
ent.Asc(item.FieldCreatedAt),
)
return mapItemsSummaryErr(q.All(ctx))
}
func (e *ItemsRepository) GetHighestAssetID(ctx context.Context, GID uuid.UUID) (AssetID, error) {
q := e.db.Item.Query().Where(
item.HasGroupWith(group.ID(GID)),
).Order(
ent.Desc(item.FieldAssetID),
).Limit(1)
result, err := q.First(ctx)
if err != nil {
if ent.IsNotFound(err) {
return 0, nil
}
return 0, err
}
return AssetID(result.AssetID), nil
}
func (e *ItemsRepository) SetAssetID(ctx context.Context, GID uuid.UUID, ID uuid.UUID, assetID AssetID) error {
q := e.db.Item.Update().Where(
item.HasGroupWith(group.ID(GID)),
item.ID(ID),
)
_, err := q.SetAssetID(int(assetID)).Save(ctx)
return err
}
func (e *ItemsRepository) Create(ctx context.Context, gid uuid.UUID, data ItemCreate) (ItemOut, error) { func (e *ItemsRepository) Create(ctx context.Context, gid uuid.UUID, data ItemCreate) (ItemOut, error) {
q := e.db.Item.Create(). q := e.db.Item.Create().
SetImportRef(data.ImportRef). SetImportRef(data.ImportRef).
SetName(data.Name). SetName(data.Name).
SetDescription(data.Description). SetDescription(data.Description).
SetGroupID(gid). SetGroupID(gid).
SetLocationID(data.LocationID) SetLocationID(data.LocationID).
SetAssetID(int(data.AssetID))
if data.LabelIDs != nil && len(data.LabelIDs) > 0 { if data.LabelIDs != nil && len(data.LabelIDs) > 0 {
q.AddLabelIDs(data.LabelIDs...) q.AddLabelIDs(data.LabelIDs...)
@ -414,7 +458,8 @@ func (e *ItemsRepository) UpdateByGroup(ctx context.Context, gid uuid.UUID, data
SetInsured(data.Insured). SetInsured(data.Insured).
SetWarrantyExpires(data.WarrantyExpires). SetWarrantyExpires(data.WarrantyExpires).
SetWarrantyDetails(data.WarrantyDetails). SetWarrantyDetails(data.WarrantyDetails).
SetQuantity(data.Quantity) SetQuantity(data.Quantity).
SetAssetID(int(data.AssetID))
currentLabels, err := e.db.Item.Query().Where(item.ID(data.ID)).QueryLabel().All(ctx) currentLabels, err := e.db.Item.Query().Where(item.ID(data.ID)).QueryLabel().All(ctx)
if err != nil { if err != nil {

View file

@ -16,15 +16,20 @@ const (
) )
type Config struct { type Config struct {
Mode string `yaml:"mode" conf:"default:development"` // development or production Mode string `yaml:"mode" conf:"default:development"` // development or production
Web WebConfig `yaml:"web"` Web WebConfig `yaml:"web"`
Storage Storage `yaml:"storage"` Storage Storage `yaml:"storage"`
Log LoggerConf `yaml:"logger"` Log LoggerConf `yaml:"logger"`
Mailer MailerConf `yaml:"mailer"` Mailer MailerConf `yaml:"mailer"`
Swagger SwaggerConf `yaml:"swagger"` Swagger SwaggerConf `yaml:"swagger"`
Demo bool `yaml:"demo"` Demo bool `yaml:"demo"`
AllowRegistration bool `yaml:"disable_registration" conf:"default:true"` Debug DebugConf `yaml:"debug"`
Debug DebugConf `yaml:"debug"` Options Options `yaml:"options"`
}
type Options struct {
AllowRegistration bool `yaml:"disable_registration" conf:"default:true"`
AutoIncrementAssetID bool `yaml:"auto_increment_asset_id" conf:"default:true"`
} }
type DebugConf struct { type DebugConf struct {

View file

@ -37,24 +37,25 @@ volumes:
## Env Variables & Configuration ## Env Variables & Configuration
| Variable | Default | Description | | Variable | Default | Description |
| ------------------------ | ---------------------- | ---------------------------------------------------------------------------------- | | ------------------------------------ | ---------------------- | ---------------------------------------------------------------------------------- |
| HBOX_MODE | production | application mode used for runtime behavior can be one of: development, production | | HBOX_MODE | production | application mode used for runtime behavior can be one of: development, production |
| HBOX_WEB_PORT | 7745 | port to run the web server on, if you're using docker do not change this | | HBOX_WEB_PORT | 7745 | port to run the web server on, if you're using docker do not change this |
| HBOX_WEB_HOST | | host to run the web server on, if you're using docker do not change this | | HBOX_WEB_HOST | | host to run the web server on, if you're using docker do not change this |
| HBOX_ALLOW_REGISTRATION | true | allow users to register themselves | | HBOX_OPTIONS_ALLOW_REGISTRATION | true | allow users to register themselves |
| HBOX_WEB_MAX_UPLOAD_SIZE | 10 | maximum file upload size supported in MB | | HBOX_OPTIONS_AUTO_INCREMENT_ASSET_ID | true | auto increments the asset_id field for new items |
| HBOX_STORAGE_DATA | /data/ | path to the data directory, do not change this if you're using docker | | HBOX_WEB_MAX_UPLOAD_SIZE | 10 | maximum file upload size supported in MB |
| HBOX_STORAGE_SQLITE_URL | /data/homebox.db?_fk=1 | sqlite database url, in you're using docker do not change this | | HBOX_STORAGE_DATA | /data/ | path to the data directory, do not change this if you're using docker |
| HBOX_LOG_LEVEL | info | log level to use, can be one of: trace, debug, info, warn, error, critical | | HBOX_STORAGE_SQLITE_URL | /data/homebox.db?_fk=1 | sqlite database url, in you're using docker do not change this |
| HBOX_LOG_FORMAT | text | log format to use, can be one of: text, json | | HBOX_LOG_LEVEL | info | log level to use, can be one of: trace, debug, info, warn, error, critical |
| HBOX_MAILER_HOST | | email host to use, if not set no email provider will be used | | HBOX_LOG_FORMAT | text | log format to use, can be one of: text, json |
| HBOX_MAILER_PORT | 587 | email port to use | | HBOX_MAILER_HOST | | email host to use, if not set no email provider will be used |
| HBOX_MAILER_USERNAME | | email user to use | | HBOX_MAILER_PORT | 587 | email port to use |
| HBOX_MAILER_PASSWORD | | email password to use | | HBOX_MAILER_USERNAME | | email user to use |
| HBOX_MAILER_FROM | | email from address to use | | HBOX_MAILER_PASSWORD | | email password to use |
| HBOX_SWAGGER_HOST | 7745 | swagger host to use, if not set swagger will be disabled | | HBOX_MAILER_FROM | | email from address to use |
| HBOX_SWAGGER_SCHEMA | http | swagger schema to use, can be one of: http, https | | HBOX_SWAGGER_HOST | 7745 | swagger host to use, if not set swagger will be disabled |
| HBOX_SWAGGER_SCHEMA | http | swagger schema to use, can be one of: http, https |
!!! tip "CLI Arguments" !!! tip "CLI Arguments"
If you're deploying without docker you can use command line arguments to configure the application. Run `homebox --help` for more information. If you're deploying without docker you can use command line arguments to configure the application. Run `homebox --help` for more information.
@ -63,23 +64,26 @@ volumes:
Usage: api [options] [arguments] Usage: api [options] [arguments]
OPTIONS OPTIONS
--mode/$HBOX_MODE <string> (default: development) --mode/$HBOX_MODE <string> (default: development)
--web-port/$HBOX_WEB_PORT <string> (default: 7745) --web-port/$HBOX_WEB_PORT <string> (default: 7745)
--web-host/$HBOX_WEB_HOST <string> --web-host/$HBOX_WEB_HOST <string>
--web-max-upload-size/$HBOX_WEB_MAX_UPLOAD_SIZE <int> (default: 10) --web-max-upload-size/$HBOX_WEB_MAX_UPLOAD_SIZE <int> (default: 10)
--storage-data/$HBOX_STORAGE_DATA <string> (default: ./.data) --storage-data/$HBOX_STORAGE_DATA <string> (default: ./.data)
--storage-sqlite-url/$HBOX_STORAGE_SQLITE_URL <string> (default: ./.data/homebox.db?_fk=1) --storage-sqlite-url/$HBOX_STORAGE_SQLITE_URL <string> (default: ./.data/homebox.db?_fk=1)
--log-level/$HBOX_LOG_LEVEL <string> (default: info) --log-level/$HBOX_LOG_LEVEL <string> (default: info)
--log-format/$HBOX_LOG_FORMAT <string> (default: text) --log-format/$HBOX_LOG_FORMAT <string> (default: text)
--mailer-host/$HBOX_MAILER_HOST <string> --mailer-host/$HBOX_MAILER_HOST <string>
--mailer-port/$HBOX_MAILER_PORT <int> --mailer-port/$HBOX_MAILER_PORT <int>
--mailer-username/$HBOX_MAILER_USERNAME <string> --mailer-username/$HBOX_MAILER_USERNAME <string>
--mailer-password/$HBOX_MAILER_PASSWORD <string> --mailer-password/$HBOX_MAILER_PASSWORD <string>
--mailer-from/$HBOX_MAILER_FROM <string> --mailer-from/$HBOX_MAILER_FROM <string>
--swagger-host/$HBOX_SWAGGER_HOST <string> (default: localhost:7745) --swagger-host/$HBOX_SWAGGER_HOST <string> (default: localhost:7745)
--swagger-scheme/$HBOX_SWAGGER_SCHEME <string> (default: http) --swagger-scheme/$HBOX_SWAGGER_SCHEME <string> (default: http)
--demo/$HBOX_DEMO <bool> --demo/$HBOX_DEMO <bool>
--allow-registration/$HBOX_ALLOW_REGISTRATION <bool> (default: true) --debug-enabled/$HBOX_DEBUG_ENABLED <bool> (default: false)
--debug-port/$HBOX_DEBUG_PORT <string> (default: 4000)
--options-allow-registration/$HBOX_OPTIONS_ALLOW_REGISTRATION <bool> (default: true)
--options-auto-increment-asset-id/$HBOX_OPTIONS_AUTO_INCREMENT_ASSET_ID <bool> (default: true)
--help/-h --help/-h
display this help message display this help message
``` ```

View file

@ -14,3 +14,21 @@ Custom fields are appended to the main details section of your item.
!!! tip !!! tip
Homebox Custom Fields also have special support for URLs. Provide a URL (`https://google.com`) and it will be automatically converted to a clickable link in the UI. Optionally, you can also use markdown syntax to add a custom text to the button. `[Google](https://google.com)` Homebox Custom Fields also have special support for URLs. Provide a URL (`https://google.com`) and it will be automatically converted to a clickable link in the UI. Optionally, you can also use markdown syntax to add a custom text to the button. `[Google](https://google.com)`
## Managing Asset IDs
Homebox provides the option to auto-set asset IDs, this is the default behavior. These can be used for tracking assets with printable tags or labels. You can disable this behavior via a command line flag or ENV variable. See [configuration](../quick-start#env-variables-configuration) for more details.
Example ID: `000-001`
Asset IDs are partially managed by Homebox, but have a flexible implementation to allow for unique use cases. ID's are non-unique at the database level so there is nothing stopping a user from manually setting duplicate IDs for various items. There are two recommended approaches to manage Asset IDs
### 1. Auto Incrementing IDs
This is the default behavior and likely to one to experience the most consistent behavior. Whenever creating or importing an item, that items receives the next available ID. This is the most consistent approach and is recommended for most users.
### 2. Auto Incrementing ID's with Reset
In some cases you may want to skip some items such as consumables, or items that are loosely tracked. In this case, we recommend that you leave auto-incrementing ID's enabled _however_ when you create a new item that you want to skip, you can go to that item and reset the ID to 0. This will remove it from the auto-incrementing sequence and the next item will receive the next available ID.
!!! tip
If you're migrating from an older version there is a action on the users profile page to assign IDs to all items. This will assign the next available ID to all items in the order of creation. You should _only_ do this once during the migration process. You should be especially cautious of this action if you're using the reset feature described in option number 2

View file

@ -0,0 +1,10 @@
import { BaseAPI, route } from "../base";
import { EnsureAssetIDResult } from "../types/data-contracts";
export class ActionsAPI extends BaseAPI {
ensureAssetIDs() {
return this.http.post<void, EnsureAssetIDResult>({
url: route("/actions/ensure-asset-ids"),
});
}
}

View file

@ -71,6 +71,9 @@ export interface ItemField {
export interface ItemOut { export interface ItemOut {
archived: boolean; archived: boolean;
/** @example 0 */
assetId: string;
attachments: ItemAttachment[]; attachments: ItemAttachment[];
children: ItemSummary[]; children: ItemSummary[];
createdAt: Date; createdAt: Date;
@ -131,6 +134,7 @@ export interface ItemSummary {
export interface ItemUpdate { export interface ItemUpdate {
archived: boolean; archived: boolean;
assetId: string;
description: string; description: string;
fields: ItemField[]; fields: ItemField[];
id: string; id: string;
@ -300,6 +304,10 @@ export interface ChangePassword {
new: string; new: string;
} }
export interface EnsureAssetIDResult {
completed: number;
}
export interface GroupInvitation { export interface GroupInvitation {
expiresAt: Date; expiresAt: Date;
token: string; token: string;

View file

@ -4,6 +4,7 @@ import { LabelsApi } from "./classes/labels";
import { LocationsApi } from "./classes/locations"; import { LocationsApi } from "./classes/locations";
import { GroupApi } from "./classes/group"; import { GroupApi } from "./classes/group";
import { UserApi } from "./classes/users"; import { UserApi } from "./classes/users";
import { ActionsAPI } from "./classes/actions";
import { Requests } from "~~/lib/requests"; import { Requests } from "~~/lib/requests";
export class UserClient extends BaseAPI { export class UserClient extends BaseAPI {
@ -12,6 +13,7 @@ export class UserClient extends BaseAPI {
items: ItemsApi; items: ItemsApi;
group: GroupApi; group: GroupApi;
user: UserApi; user: UserApi;
actions: ActionsAPI;
constructor(requests: Requests) { constructor(requests: Requests) {
super(requests); super(requests);
@ -21,6 +23,7 @@ export class UserClient extends BaseAPI {
this.items = new ItemsApi(requests); this.items = new ItemsApi(requests);
this.group = new GroupApi(requests); this.group = new GroupApi(requests);
this.user = new UserApi(requests); this.user = new UserApi(requests);
this.actions = new ActionsAPI(requests);
Object.freeze(this); Object.freeze(this);
} }

View file

@ -120,6 +120,11 @@
label: "Archived", label: "Archived",
ref: "archived", ref: "archived",
}, },
{
type: "text",
label: "Asset ID",
ref: "assetId",
},
]; ];
const purchaseFields: FormField[] = [ const purchaseFields: FormField[] = [

View file

@ -70,6 +70,19 @@
); );
}); });
const assetID = computed<Details>(() => {
if (item.value?.assetId === "000-000") {
return [];
}
return [
{
name: "Asset ID",
text: item.value?.assetId,
},
];
});
const itemDetails = computed<Details>(() => { const itemDetails = computed<Details>(() => {
return [ return [
{ {
@ -100,6 +113,7 @@
name: "Notes", name: "Notes",
text: item.value?.notes, text: item.value?.notes,
}, },
...assetID.value,
...item.value.fields.map(field => { ...item.value.fields.map(field => {
/** /**
* Support Special URL Syntax * Support Special URL Syntax

View file

@ -163,6 +163,25 @@
passwordChange.current = ""; passwordChange.current = "";
passwordChange.loading = false; passwordChange.loading = false;
} }
async function ensureAssetIDs() {
const { isCanceled } = await confirm.open(
"Are you sure you want to ensure all assets have an ID? This will take a while and cannot be undone."
);
if (isCanceled) {
return;
}
const result = await api.actions.ensureAssetIDs();
if (result.error) {
notify.error("Failed to ensure asset IDs.");
return;
}
notify.success(`${result.data.completed} assets have been updated.`);
}
</script> </script>
<template> <template>
@ -286,6 +305,32 @@
</div> </div>
</BaseCard> </BaseCard>
<BaseCard>
<template #title>
<BaseSectionHeader>
<Icon name="mdi-warning" class="mr-2 -mt-1 text-base-600" />
<span class="text-base-600"> Actions </span>
<template #description>
Apply Actions to your inventory in bulk. These are irreversible actions. Be careful.
</template>
</BaseSectionHeader>
<div class="py-4 border-t-2 border-gray-300">
<div class="grid grid-cols-1 md:grid-cols-4 gap-10">
<div class="col-span-3">
<h4>Manage Asset IDs</h4>
<p class="text-sm">
Ensures that all items in your inventory have a valid asset_id field. This is done by finding the
highest current asset_id field in the database and applying the next value to each item that has an
unset asset_id field. This is done in order of the created_at field.
</p>
</div>
<BaseButton class="btn-primary mt-auto" @click="ensureAssetIDs"> Ensure Asset IDs </BaseButton>
</div>
</div>
</template>
</BaseCard>
<BaseCard> <BaseCard>
<template #title> <template #title>
<BaseSectionHeader> <BaseSectionHeader>