From 6dc2ae1bea5efeee1aa08260883a1f8fd7b1a6e2 Mon Sep 17 00:00:00 2001 From: Hayden <64056131+hay-kot@users.noreply.github.com> Date: Sun, 13 Nov 2022 14:17:55 -0900 Subject: [PATCH] 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 --- .../app/api/handlers/v1/v1_ctrl_actions.go | 35 ++++++ backend/app/api/handlers/v1/v1_ctrl_items.go | 4 +- backend/app/api/main.go | 5 +- backend/app/api/routes.go | 4 +- backend/app/api/static/docs/docs.go | 39 ++++++ backend/app/api/static/docs/swagger.json | 39 ++++++ backend/app/api/static/docs/swagger.yaml | 24 ++++ backend/internal/core/services/all.go | 27 +++- .../internal/core/services/service_items.go | 61 +++++++++- backend/internal/data/ent/item.go | 13 +- backend/internal/data/ent/item/item.go | 5 + backend/internal/data/ent/item/where.go | 71 +++++++++++ backend/internal/data/ent/item_create.go | 29 +++++ backend/internal/data/ent/item_update.go | 70 +++++++++++ backend/internal/data/ent/migrate/schema.go | 18 ++- backend/internal/data/ent/mutation.go | 89 +++++++++++++- backend/internal/data/ent/runtime.go | 20 +-- backend/internal/data/ent/schema/item.go | 3 + .../20221113012312_add_asset_id_field.sql | 24 ++++ .../data/migrations/migrations/atlas.sum | 3 +- backend/internal/data/repo/asset_id_type.go | 30 +++++ .../internal/data/repo/asset_id_type_test.go | 115 ++++++++++++++++++ backend/internal/data/repo/repo_items.go | 49 +++++++- backend/internal/sys/config/conf.go | 23 ++-- docs/docs/quick-start.md | 74 +++++------ docs/docs/tips-tricks.md | 18 +++ frontend/lib/api/classes/actions.ts | 10 ++ frontend/lib/api/types/data-contracts.ts | 8 ++ frontend/lib/api/user.ts | 3 + frontend/pages/item/[id]/edit.vue | 5 + frontend/pages/item/[id]/index.vue | 14 +++ frontend/pages/profile.vue | 45 +++++++ 32 files changed, 905 insertions(+), 72 deletions(-) create mode 100644 backend/app/api/handlers/v1/v1_ctrl_actions.go create mode 100644 backend/internal/data/migrations/migrations/20221113012312_add_asset_id_field.sql create mode 100644 backend/internal/data/repo/asset_id_type.go create mode 100644 backend/internal/data/repo/asset_id_type_test.go create mode 100644 frontend/lib/api/classes/actions.ts diff --git a/backend/app/api/handlers/v1/v1_ctrl_actions.go b/backend/app/api/handlers/v1/v1_ctrl_actions.go new file mode 100644 index 0000000..37e2b72 --- /dev/null +++ b/backend/app/api/handlers/v1/v1_ctrl_actions.go @@ -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}) + } +} diff --git a/backend/app/api/handlers/v1/v1_ctrl_items.go b/backend/app/api/handlers/v1/v1_ctrl_items.go index f4d43d3..f6bc5ff 100644 --- a/backend/app/api/handlers/v1/v1_ctrl_items.go +++ b/backend/app/api/handlers/v1/v1_ctrl_items.go @@ -65,8 +65,8 @@ func (ctrl *V1Controller) HandleItemsCreate() server.HandlerFunc { return validate.NewRequestError(err, http.StatusInternalServerError) } - user := services.UseUserCtx(r.Context()) - item, err := ctrl.repo.Items.Create(r.Context(), user.GroupID, createData) + ctx := services.NewContext(r.Context()) + item, err := ctrl.svc.Items.Create(ctx, createData) if err != nil { log.Err(err).Msg("failed to create item") return validate.NewRequestError(err, http.StatusInternalServerError) diff --git a/backend/app/api/main.go b/backend/app/api/main.go index c62d5df..c505eea 100644 --- a/backend/app/api/main.go +++ b/backend/app/api/main.go @@ -112,7 +112,10 @@ func run(cfg *config.Config) error { app.db = c 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\ diff --git a/backend/app/api/routes.go b/backend/app/api/routes.go index 992e70e..cab1a14 100644 --- a/backend/app/api/routes.go +++ b/backend/app/api/routes.go @@ -51,7 +51,7 @@ func (a *app) mountRoutes(repos *repo.AllRepos) { a.services, a.repos, 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 ) @@ -82,6 +82,8 @@ func (a *app) mountRoutes(repos *repo.AllRepos) { a.server.Get(v1Base("/groups"), v1Ctrl.HandleGroupGet(), 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.Post(v1Base("/locations"), v1Ctrl.HandleLocationCreate(), a.mwAuthToken) a.server.Get(v1Base("/locations/{id}"), v1Ctrl.HandleLocationGet(), a.mwAuthToken) diff --git a/backend/app/api/static/docs/docs.go b/backend/app/api/static/docs/docs.go index f2c877e..6e02411 100644 --- a/backend/app/api/static/docs/docs.go +++ b/backend/app/api/static/docs/docs.go @@ -21,6 +21,30 @@ const docTemplate = `{ "host": "{{.Host}}", "basePath": "{{.BasePath}}", "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": { "get": { "security": [ @@ -1326,6 +1350,10 @@ const docTemplate = `{ "archived": { "type": "boolean" }, + "assetId": { + "type": "string", + "example": "0" + }, "attachments": { "type": "array", "items": { @@ -1479,6 +1507,9 @@ const docTemplate = `{ "archived": { "type": "boolean" }, + "assetId": { + "type": "string" + }, "description": { "type": "string" }, @@ -1891,6 +1922,14 @@ const docTemplate = `{ } } }, + "v1.EnsureAssetIDResult": { + "type": "object", + "properties": { + "completed": { + "type": "integer" + } + } + }, "v1.GroupInvitation": { "type": "object", "properties": { diff --git a/backend/app/api/static/docs/swagger.json b/backend/app/api/static/docs/swagger.json index 7215fae..3be73bd 100644 --- a/backend/app/api/static/docs/swagger.json +++ b/backend/app/api/static/docs/swagger.json @@ -13,6 +13,30 @@ }, "basePath": "/api", "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": { "get": { "security": [ @@ -1318,6 +1342,10 @@ "archived": { "type": "boolean" }, + "assetId": { + "type": "string", + "example": "0" + }, "attachments": { "type": "array", "items": { @@ -1471,6 +1499,9 @@ "archived": { "type": "boolean" }, + "assetId": { + "type": "string" + }, "description": { "type": "string" }, @@ -1883,6 +1914,14 @@ } } }, + "v1.EnsureAssetIDResult": { + "type": "object", + "properties": { + "completed": { + "type": "integer" + } + } + }, "v1.GroupInvitation": { "type": "object", "properties": { diff --git a/backend/app/api/static/docs/swagger.yaml b/backend/app/api/static/docs/swagger.yaml index e479583..72563b9 100644 --- a/backend/app/api/static/docs/swagger.yaml +++ b/backend/app/api/static/docs/swagger.yaml @@ -98,6 +98,9 @@ definitions: properties: archived: type: boolean + assetId: + example: "0" + type: string attachments: items: $ref: '#/definitions/repo.ItemAttachment' @@ -204,6 +207,8 @@ definitions: properties: archived: type: boolean + assetId: + type: string description: type: string fields: @@ -477,6 +482,11 @@ definitions: new: type: string type: object + v1.EnsureAssetIDResult: + properties: + completed: + type: integer + type: object v1.GroupInvitation: properties: expiresAt: @@ -516,6 +526,20 @@ info: title: Go API Templates version: "1.0" 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: get: produces: diff --git a/backend/internal/core/services/all.go b/backend/internal/core/services/all.go index 5147b8a..25e406e 100644 --- a/backend/internal/core/services/all.go +++ b/backend/internal/core/services/all.go @@ -8,17 +8,38 @@ type AllServices struct { 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 { panic("repos cannot be nil") } + options := &options{ + autoIncrementAssetID: true, + } + + for _, opt := range opts { + opt(options) + } + return &AllServices{ User: &UserService{repos}, Group: &GroupService{repos}, Items: &ItemService{ - repo: repos, - at: attachmentTokens{}, + repo: repos, + at: attachmentTokens{}, + autoIncrementAssetID: options.autoIncrementAssetID, }, } } diff --git a/backend/internal/core/services/service_items.go b/backend/internal/core/services/service_items.go index 4af80c8..5055c67 100644 --- a/backend/internal/core/services/service_items.go +++ b/backend/internal/core/services/service_items.go @@ -21,8 +21,49 @@ type ItemService struct { // at is a map of tokens to attachment IDs. This is used to store the attachment ID // for issued URLs 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) { 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 var count int for _, row := range loaded { @@ -139,13 +188,20 @@ func (svc *ItemService) CsvImport(ctx context.Context, GID uuid.UUID, data [][]s Str("location", row.Location). Msgf("Creating Item: %s", row.Item.Name) - result, err := svc.repo.Items.Create(ctx, GID, repo.ItemCreate{ + data := repo.ItemCreate{ ImportRef: row.Item.ImportRef, Name: row.Item.Name, Description: row.Item.Description, LabelIDs: labelIDs, LocationID: locationID, - }) + } + + if svc.autoIncrementAssetID { + highest++ + data.AssetID = highest + } + + result, err := svc.repo.Items.Create(ctx, GID, data) if err != nil { return count, err @@ -156,6 +212,7 @@ func (svc *ItemService) CsvImport(ctx context.Context, GID uuid.UUID, data [][]s // Edges LocationID: locationID, LabelIDs: labelIDs, + AssetID: data.AssetID, // General Fields ID: result.ID, diff --git a/backend/internal/data/ent/item.go b/backend/internal/data/ent/item.go index 802acff..a780945 100644 --- a/backend/internal/data/ent/item.go +++ b/backend/internal/data/ent/item.go @@ -37,6 +37,8 @@ type Item struct { Insured bool `json:"insured,omitempty"` // Archived holds the value of the "archived" field. 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 string `json:"serial_number,omitempty"` // 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) case item.FieldPurchasePrice, item.FieldSoldPrice: values[i] = new(sql.NullFloat64) - case item.FieldQuantity: + case item.FieldQuantity, item.FieldAssetID: 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: values[i] = new(sql.NullString) @@ -265,6 +267,12 @@ func (i *Item) assignValues(columns []string, values []any) error { } else if value.Valid { 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: if value, ok := values[j].(*sql.NullString); !ok { 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(fmt.Sprintf("%v", i.Archived)) builder.WriteString(", ") + builder.WriteString("asset_id=") + builder.WriteString(fmt.Sprintf("%v", i.AssetID)) + builder.WriteString(", ") builder.WriteString("serial_number=") builder.WriteString(i.SerialNumber) builder.WriteString(", ") diff --git a/backend/internal/data/ent/item/item.go b/backend/internal/data/ent/item/item.go index c2991da..ab3b43f 100644 --- a/backend/internal/data/ent/item/item.go +++ b/backend/internal/data/ent/item/item.go @@ -31,6 +31,8 @@ const ( FieldInsured = "insured" // FieldArchived holds the string denoting the archived field in the database. 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 = "serial_number" // FieldModelNumber holds the string denoting the model_number field in the database. @@ -128,6 +130,7 @@ var Columns = []string{ FieldQuantity, FieldInsured, FieldArchived, + FieldAssetID, FieldSerialNumber, FieldModelNumber, FieldManufacturer, @@ -193,6 +196,8 @@ var ( DefaultInsured bool // DefaultArchived holds the default value on creation for the "archived" field. 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 func(string) error // ModelNumberValidator is a validator for the "model_number" field. It is called by the builders before save. diff --git a/backend/internal/data/ent/item/where.go b/backend/internal/data/ent/item/where.go index 2897e35..2174432 100644 --- a/backend/internal/data/ent/item/where.go +++ b/backend/internal/data/ent/item/where.go @@ -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. func SerialNumber(v string) predicate.Item { 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. func SerialNumberEQ(v string) predicate.Item { return predicate.Item(func(s *sql.Selector) { diff --git a/backend/internal/data/ent/item_create.go b/backend/internal/data/ent/item_create.go index f4de18e..36c9720 100644 --- a/backend/internal/data/ent/item_create.go +++ b/backend/internal/data/ent/item_create.go @@ -144,6 +144,20 @@ func (ic *ItemCreate) SetNillableArchived(b *bool) *ItemCreate { 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. func (ic *ItemCreate) SetSerialNumber(s string) *ItemCreate { ic.mutation.SetSerialNumber(s) @@ -546,6 +560,10 @@ func (ic *ItemCreate) defaults() { v := item.DefaultArchived ic.mutation.SetArchived(v) } + if _, ok := ic.mutation.AssetID(); !ok { + v := item.DefaultAssetID + ic.mutation.SetAssetID(v) + } if _, ok := ic.mutation.LifetimeWarranty(); !ok { v := item.DefaultLifetimeWarranty ic.mutation.SetLifetimeWarranty(v) @@ -604,6 +622,9 @@ func (ic *ItemCreate) check() error { if _, ok := ic.mutation.Archived(); !ok { 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 err := item.SerialNumberValidator(v); err != nil { 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 } + 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 { _spec.Fields = append(_spec.Fields, &sqlgraph.FieldSpec{ Type: field.TypeString, diff --git a/backend/internal/data/ent/item_update.go b/backend/internal/data/ent/item_update.go index 4307051..52970f5 100644 --- a/backend/internal/data/ent/item_update.go +++ b/backend/internal/data/ent/item_update.go @@ -135,6 +135,27 @@ func (iu *ItemUpdate) SetNillableArchived(b *bool) *ItemUpdate { 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. func (iu *ItemUpdate) SetSerialNumber(s string) *ItemUpdate { iu.mutation.SetSerialNumber(s) @@ -816,6 +837,20 @@ func (iu *ItemUpdate) sqlSave(ctx context.Context) (n int, err error) { 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 { _spec.Fields.Set = append(_spec.Fields.Set, &sqlgraph.FieldSpec{ Type: field.TypeString, @@ -1422,6 +1457,27 @@ func (iuo *ItemUpdateOne) SetNillableArchived(b *bool) *ItemUpdateOne { 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. func (iuo *ItemUpdateOne) SetSerialNumber(s string) *ItemUpdateOne { iuo.mutation.SetSerialNumber(s) @@ -2133,6 +2189,20 @@ func (iuo *ItemUpdateOne) sqlSave(ctx context.Context) (_node *Item, err error) 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 { _spec.Fields.Set = append(_spec.Fields.Set, &sqlgraph.FieldSpec{ Type: field.TypeString, diff --git a/backend/internal/data/ent/migrate/schema.go b/backend/internal/data/ent/migrate/schema.go index 6fd2d9e..464c3c6 100644 --- a/backend/internal/data/ent/migrate/schema.go +++ b/backend/internal/data/ent/migrate/schema.go @@ -171,6 +171,7 @@ var ( {Name: "quantity", Type: field.TypeInt, Default: 1}, {Name: "insured", 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: "model_number", Type: field.TypeString, Nullable: true, Size: 255}, {Name: "manufacturer", Type: field.TypeString, Nullable: true, Size: 255}, @@ -196,19 +197,19 @@ var ( ForeignKeys: []*schema.ForeignKey{ { Symbol: "items_groups_items", - Columns: []*schema.Column{ItemsColumns[23]}, + Columns: []*schema.Column{ItemsColumns[24]}, RefColumns: []*schema.Column{GroupsColumns[0]}, OnDelete: schema.Cascade, }, { Symbol: "items_items_children", - Columns: []*schema.Column{ItemsColumns[24]}, + Columns: []*schema.Column{ItemsColumns[25]}, RefColumns: []*schema.Column{ItemsColumns[0]}, OnDelete: schema.SetNull, }, { Symbol: "items_locations_items", - Columns: []*schema.Column{ItemsColumns[25]}, + Columns: []*schema.Column{ItemsColumns[26]}, RefColumns: []*schema.Column{LocationsColumns[0]}, OnDelete: schema.Cascade, }, @@ -222,23 +223,28 @@ var ( { Name: "item_manufacturer", Unique: false, - Columns: []*schema.Column{ItemsColumns[12]}, + Columns: []*schema.Column{ItemsColumns[13]}, }, { Name: "item_model_number", Unique: false, - Columns: []*schema.Column{ItemsColumns[11]}, + Columns: []*schema.Column{ItemsColumns[12]}, }, { Name: "item_serial_number", Unique: false, - Columns: []*schema.Column{ItemsColumns[10]}, + Columns: []*schema.Column{ItemsColumns[11]}, }, { Name: "item_archived", Unique: false, 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. diff --git a/backend/internal/data/ent/mutation.go b/backend/internal/data/ent/mutation.go index 73ccbde..2f1ebdc 100644 --- a/backend/internal/data/ent/mutation.go +++ b/backend/internal/data/ent/mutation.go @@ -4134,6 +4134,8 @@ type ItemMutation struct { addquantity *int insured *bool archived *bool + asset_id *int + addasset_id *int serial_number *string model_number *string manufacturer *string @@ -4660,6 +4662,62 @@ func (m *ItemMutation) ResetArchived() { 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. func (m *ItemMutation) SetSerialNumber(s string) { m.serial_number = &s @@ -5650,7 +5708,7 @@ func (m *ItemMutation) Type() string { // order to get all numeric fields that were incremented/decremented, call // AddedFields(). func (m *ItemMutation) Fields() []string { - fields := make([]string, 0, 22) + fields := make([]string, 0, 23) if m.created_at != nil { fields = append(fields, item.FieldCreatedAt) } @@ -5678,6 +5736,9 @@ func (m *ItemMutation) Fields() []string { if m.archived != nil { fields = append(fields, item.FieldArchived) } + if m.asset_id != nil { + fields = append(fields, item.FieldAssetID) + } if m.serial_number != nil { fields = append(fields, item.FieldSerialNumber) } @@ -5743,6 +5804,8 @@ func (m *ItemMutation) Field(name string) (ent.Value, bool) { return m.Insured() case item.FieldArchived: return m.Archived() + case item.FieldAssetID: + return m.AssetID() case item.FieldSerialNumber: return m.SerialNumber() case item.FieldModelNumber: @@ -5796,6 +5859,8 @@ func (m *ItemMutation) OldField(ctx context.Context, name string) (ent.Value, er return m.OldInsured(ctx) case item.FieldArchived: return m.OldArchived(ctx) + case item.FieldAssetID: + return m.OldAssetID(ctx) case item.FieldSerialNumber: return m.OldSerialNumber(ctx) case item.FieldModelNumber: @@ -5894,6 +5959,13 @@ func (m *ItemMutation) SetField(name string, value ent.Value) error { } m.SetArchived(v) 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: v, ok := value.(string) if !ok { @@ -5996,6 +6068,9 @@ func (m *ItemMutation) AddedFields() []string { if m.addquantity != nil { fields = append(fields, item.FieldQuantity) } + if m.addasset_id != nil { + fields = append(fields, item.FieldAssetID) + } if m.addpurchase_price != nil { fields = append(fields, item.FieldPurchasePrice) } @@ -6012,6 +6087,8 @@ func (m *ItemMutation) AddedField(name string) (ent.Value, bool) { switch name { case item.FieldQuantity: return m.AddedQuantity() + case item.FieldAssetID: + return m.AddedAssetID() case item.FieldPurchasePrice: return m.AddedPurchasePrice() case item.FieldSoldPrice: @@ -6032,6 +6109,13 @@ func (m *ItemMutation) AddField(name string, value ent.Value) error { } m.AddQuantity(v) 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: v, ok := value.(float64) if !ok { @@ -6181,6 +6265,9 @@ func (m *ItemMutation) ResetField(name string) error { case item.FieldArchived: m.ResetArchived() return nil + case item.FieldAssetID: + m.ResetAssetID() + return nil case item.FieldSerialNumber: m.ResetSerialNumber() return nil diff --git a/backend/internal/data/ent/runtime.go b/backend/internal/data/ent/runtime.go index af5dc22..25a7680 100644 --- a/backend/internal/data/ent/runtime.go +++ b/backend/internal/data/ent/runtime.go @@ -275,36 +275,40 @@ func init() { itemDescArchived := itemFields[4].Descriptor() // item.DefaultArchived holds the default value on creation for the archived field. 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 := 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 = itemDescSerialNumber.Validators[0].(func(string) error) // 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 = itemDescModelNumber.Validators[0].(func(string) error) // 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 = itemDescManufacturer.Validators[0].(func(string) error) // 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 = itemDescLifetimeWarranty.Default.(bool) // 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 = itemDescWarrantyDetails.Validators[0].(func(string) error) // 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 = itemDescPurchasePrice.Default.(float64) // 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 = itemDescSoldPrice.Default.(float64) // 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 = itemDescSoldNotes.Validators[0].(func(string) error) // itemDescID is the schema descriptor for id field. diff --git a/backend/internal/data/ent/schema/item.go b/backend/internal/data/ent/schema/item.go index f7799f4..388566d 100644 --- a/backend/internal/data/ent/schema/item.go +++ b/backend/internal/data/ent/schema/item.go @@ -29,6 +29,7 @@ func (Item) Indexes() []ent.Index { index.Fields("model_number"), index.Fields("serial_number"), index.Fields("archived"), + index.Fields("asset_id"), } } @@ -48,6 +49,8 @@ func (Item) Fields() []ent.Field { Default(false), field.Bool("archived"). Default(false), + field.Int("asset_id"). + Default(0), // ------------------------------------ // item identification diff --git a/backend/internal/data/migrations/migrations/20221113012312_add_asset_id_field.sql b/backend/internal/data/migrations/migrations/20221113012312_add_asset_id_field.sql new file mode 100644 index 0000000..5bcf3ad --- /dev/null +++ b/backend/internal/data/migrations/migrations/20221113012312_add_asset_id_field.sql @@ -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; diff --git a/backend/internal/data/migrations/migrations/atlas.sum b/backend/internal/data/migrations/migrations/atlas.sum index 2916627..d9c72fb 100644 --- a/backend/internal/data/migrations/migrations/atlas.sum +++ b/backend/internal/data/migrations/migrations/atlas.sum @@ -1,6 +1,7 @@ -h1:i76VRMDIPdcmQtXTe9bzrgITAzLGjjVy9y8XaXIchAs= +h1:z1tbZ3fYByqxL78Z+ov8mfQVjXcwsZeEcT0i+2DZ8a8= 20220929052825_init.sql h1:ZlCqm1wzjDmofeAcSX3jE4h4VcdTNGpRg2eabztDy9Q= 20221001210956_group_invitations.sql h1:YQKJFtE39wFOcRNbZQ/d+ZlHwrcfcsZlcv/pLEYdpjw= 20221009173029_add_user_roles.sql h1:vWmzAfgEWQeGk0Vn70zfVPCcfEZth3E0JcvyKTjpYyU= 20221020043305_allow_nesting_types.sql h1:4AyJpZ7l7SSJtJAQETYY802FHJ64ufYPJTqvwdiGn3M= 20221101041931_add_archived_field.sql h1:L2WxiOh1svRn817cNURgqnEQg6DIcodZ1twK4tvxW94= +20221113012312_add_asset_id_field.sql h1:DjD7e1PS8OfxGBWic8h0nO/X6CNnHEMqQjDCaaQ3M3Q= diff --git a/backend/internal/data/repo/asset_id_type.go b/backend/internal/data/repo/asset_id_type.go new file mode 100644 index 0000000..6aecf9c --- /dev/null +++ b/backend/internal/data/repo/asset_id_type.go @@ -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 + +} diff --git a/backend/internal/data/repo/asset_id_type_test.go b/backend/internal/data/repo/asset_id_type_test.go new file mode 100644 index 0000000..6a692d9 --- /dev/null +++ b/backend/internal/data/repo/asset_id_type_test.go @@ -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) + } + }) + } +} diff --git a/backend/internal/data/repo/repo_items.go b/backend/internal/data/repo/repo_items.go index 71619fa..d01c1dc 100644 --- a/backend/internal/data/repo/repo_items.go +++ b/backend/internal/data/repo/repo_items.go @@ -44,6 +44,7 @@ type ( ParentID uuid.UUID `json:"parentId" extensions:"x-nullable"` Name string `json:"name"` Description string `json:"description"` + AssetID AssetID `json:"-"` // Edges LocationID uuid.UUID `json:"locationId"` @@ -52,6 +53,7 @@ type ( ItemUpdate struct { ParentID uuid.UUID `json:"parentId" extensions:"x-nullable,x-omitempty"` ID uuid.UUID `json:"id"` + AssetID AssetID `json:"assetId"` Name string `json:"name"` Description string `json:"description"` Quantity int `json:"quantity"` @@ -107,6 +109,7 @@ type ( ItemOut struct { Parent *ItemSummary `json:"parent,omitempty" extensions:"x-nullable,x-omitempty"` ItemSummary + AssetID AssetID `json:"assetId,string"` SerialNumber string `json:"serialNumber"` ModelNumber string `json:"modelNumber"` @@ -215,6 +218,7 @@ func mapItemOut(item *ent.Item) ItemOut { return ItemOut{ Parent: parent, + AssetID: AssetID(item.AssetID), ItemSummary: mapItemSummary(item), LifetimeWarranty: item.LifetimeWarranty, WarrantyExpires: item.WarrantyExpires, @@ -359,13 +363,53 @@ func (e *ItemsRepository) GetAll(ctx context.Context, gid uuid.UUID) ([]ItemSumm 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) { q := e.db.Item.Create(). SetImportRef(data.ImportRef). SetName(data.Name). SetDescription(data.Description). SetGroupID(gid). - SetLocationID(data.LocationID) + SetLocationID(data.LocationID). + SetAssetID(int(data.AssetID)) if data.LabelIDs != nil && len(data.LabelIDs) > 0 { q.AddLabelIDs(data.LabelIDs...) @@ -414,7 +458,8 @@ func (e *ItemsRepository) UpdateByGroup(ctx context.Context, gid uuid.UUID, data SetInsured(data.Insured). SetWarrantyExpires(data.WarrantyExpires). 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) if err != nil { diff --git a/backend/internal/sys/config/conf.go b/backend/internal/sys/config/conf.go index 8e55756..5f212eb 100644 --- a/backend/internal/sys/config/conf.go +++ b/backend/internal/sys/config/conf.go @@ -16,15 +16,20 @@ const ( ) type Config struct { - Mode string `yaml:"mode" conf:"default:development"` // development or production - Web WebConfig `yaml:"web"` - Storage Storage `yaml:"storage"` - Log LoggerConf `yaml:"logger"` - Mailer MailerConf `yaml:"mailer"` - Swagger SwaggerConf `yaml:"swagger"` - Demo bool `yaml:"demo"` - AllowRegistration bool `yaml:"disable_registration" conf:"default:true"` - Debug DebugConf `yaml:"debug"` + Mode string `yaml:"mode" conf:"default:development"` // development or production + Web WebConfig `yaml:"web"` + Storage Storage `yaml:"storage"` + Log LoggerConf `yaml:"logger"` + Mailer MailerConf `yaml:"mailer"` + Swagger SwaggerConf `yaml:"swagger"` + Demo bool `yaml:"demo"` + 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 { diff --git a/docs/docs/quick-start.md b/docs/docs/quick-start.md index f43df50..3a5fad3 100644 --- a/docs/docs/quick-start.md +++ b/docs/docs/quick-start.md @@ -37,24 +37,25 @@ volumes: ## Env Variables & Configuration -| Variable | Default | Description | -| ------------------------ | ---------------------- | ---------------------------------------------------------------------------------- | -| 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_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_WEB_MAX_UPLOAD_SIZE | 10 | maximum file upload size supported in MB | -| HBOX_STORAGE_DATA | /data/ | path to the data directory, do not change this if you're using docker | -| HBOX_STORAGE_SQLITE_URL | /data/homebox.db?_fk=1 | sqlite database url, in you're using docker do not change this | -| HBOX_LOG_LEVEL | info | log level to use, can be one of: trace, debug, info, warn, error, critical | -| HBOX_LOG_FORMAT | text | log format to use, can be one of: text, json | -| HBOX_MAILER_HOST | | email host to use, if not set no email provider will be used | -| HBOX_MAILER_PORT | 587 | email port to use | -| HBOX_MAILER_USERNAME | | email user to use | -| HBOX_MAILER_PASSWORD | | email password to use | -| HBOX_MAILER_FROM | | email from address to use | -| 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 | +| Variable | Default | Description | +| ------------------------------------ | ---------------------- | ---------------------------------------------------------------------------------- | +| 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_HOST | | host to run the web server on, if you're using docker do not change this | +| HBOX_OPTIONS_ALLOW_REGISTRATION | true | allow users to register themselves | +| HBOX_OPTIONS_AUTO_INCREMENT_ASSET_ID | true | auto increments the asset_id field for new items | +| HBOX_WEB_MAX_UPLOAD_SIZE | 10 | maximum file upload size supported in MB | +| HBOX_STORAGE_DATA | /data/ | path to the data directory, do not change this if you're using docker | +| HBOX_STORAGE_SQLITE_URL | /data/homebox.db?_fk=1 | sqlite database url, in you're using docker do not change this | +| HBOX_LOG_LEVEL | info | log level to use, can be one of: trace, debug, info, warn, error, critical | +| HBOX_LOG_FORMAT | text | log format to use, can be one of: text, json | +| HBOX_MAILER_HOST | | email host to use, if not set no email provider will be used | +| HBOX_MAILER_PORT | 587 | email port to use | +| HBOX_MAILER_USERNAME | | email user to use | +| HBOX_MAILER_PASSWORD | | email password to use | +| HBOX_MAILER_FROM | | email from address to use | +| 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" 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] OPTIONS - --mode/$HBOX_MODE (default: development) - --web-port/$HBOX_WEB_PORT (default: 7745) - --web-host/$HBOX_WEB_HOST - --web-max-upload-size/$HBOX_WEB_MAX_UPLOAD_SIZE (default: 10) - --storage-data/$HBOX_STORAGE_DATA (default: ./.data) - --storage-sqlite-url/$HBOX_STORAGE_SQLITE_URL (default: ./.data/homebox.db?_fk=1) - --log-level/$HBOX_LOG_LEVEL (default: info) - --log-format/$HBOX_LOG_FORMAT (default: text) - --mailer-host/$HBOX_MAILER_HOST - --mailer-port/$HBOX_MAILER_PORT - --mailer-username/$HBOX_MAILER_USERNAME - --mailer-password/$HBOX_MAILER_PASSWORD - --mailer-from/$HBOX_MAILER_FROM - --swagger-host/$HBOX_SWAGGER_HOST (default: localhost:7745) - --swagger-scheme/$HBOX_SWAGGER_SCHEME (default: http) - --demo/$HBOX_DEMO - --allow-registration/$HBOX_ALLOW_REGISTRATION (default: true) + --mode/$HBOX_MODE (default: development) + --web-port/$HBOX_WEB_PORT (default: 7745) + --web-host/$HBOX_WEB_HOST + --web-max-upload-size/$HBOX_WEB_MAX_UPLOAD_SIZE (default: 10) + --storage-data/$HBOX_STORAGE_DATA (default: ./.data) + --storage-sqlite-url/$HBOX_STORAGE_SQLITE_URL (default: ./.data/homebox.db?_fk=1) + --log-level/$HBOX_LOG_LEVEL (default: info) + --log-format/$HBOX_LOG_FORMAT (default: text) + --mailer-host/$HBOX_MAILER_HOST + --mailer-port/$HBOX_MAILER_PORT + --mailer-username/$HBOX_MAILER_USERNAME + --mailer-password/$HBOX_MAILER_PASSWORD + --mailer-from/$HBOX_MAILER_FROM + --swagger-host/$HBOX_SWAGGER_HOST (default: localhost:7745) + --swagger-scheme/$HBOX_SWAGGER_SCHEME (default: http) + --demo/$HBOX_DEMO + --debug-enabled/$HBOX_DEBUG_ENABLED (default: false) + --debug-port/$HBOX_DEBUG_PORT (default: 4000) + --options-allow-registration/$HBOX_OPTIONS_ALLOW_REGISTRATION (default: true) + --options-auto-increment-asset-id/$HBOX_OPTIONS_AUTO_INCREMENT_ASSET_ID (default: true) --help/-h display this help message ``` diff --git a/docs/docs/tips-tricks.md b/docs/docs/tips-tricks.md index 30f9f1c..75501be 100644 --- a/docs/docs/tips-tricks.md +++ b/docs/docs/tips-tricks.md @@ -14,3 +14,21 @@ Custom fields are appended to the main details section of your item. !!! 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)` +## 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 diff --git a/frontend/lib/api/classes/actions.ts b/frontend/lib/api/classes/actions.ts new file mode 100644 index 0000000..be892b3 --- /dev/null +++ b/frontend/lib/api/classes/actions.ts @@ -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({ + url: route("/actions/ensure-asset-ids"), + }); + } +} diff --git a/frontend/lib/api/types/data-contracts.ts b/frontend/lib/api/types/data-contracts.ts index 7fd055c..9829f14 100644 --- a/frontend/lib/api/types/data-contracts.ts +++ b/frontend/lib/api/types/data-contracts.ts @@ -71,6 +71,9 @@ export interface ItemField { export interface ItemOut { archived: boolean; + + /** @example 0 */ + assetId: string; attachments: ItemAttachment[]; children: ItemSummary[]; createdAt: Date; @@ -131,6 +134,7 @@ export interface ItemSummary { export interface ItemUpdate { archived: boolean; + assetId: string; description: string; fields: ItemField[]; id: string; @@ -300,6 +304,10 @@ export interface ChangePassword { new: string; } +export interface EnsureAssetIDResult { + completed: number; +} + export interface GroupInvitation { expiresAt: Date; token: string; diff --git a/frontend/lib/api/user.ts b/frontend/lib/api/user.ts index d714bef..5c4940f 100644 --- a/frontend/lib/api/user.ts +++ b/frontend/lib/api/user.ts @@ -4,6 +4,7 @@ import { LabelsApi } from "./classes/labels"; import { LocationsApi } from "./classes/locations"; import { GroupApi } from "./classes/group"; import { UserApi } from "./classes/users"; +import { ActionsAPI } from "./classes/actions"; import { Requests } from "~~/lib/requests"; export class UserClient extends BaseAPI { @@ -12,6 +13,7 @@ export class UserClient extends BaseAPI { items: ItemsApi; group: GroupApi; user: UserApi; + actions: ActionsAPI; constructor(requests: Requests) { super(requests); @@ -21,6 +23,7 @@ export class UserClient extends BaseAPI { this.items = new ItemsApi(requests); this.group = new GroupApi(requests); this.user = new UserApi(requests); + this.actions = new ActionsAPI(requests); Object.freeze(this); } diff --git a/frontend/pages/item/[id]/edit.vue b/frontend/pages/item/[id]/edit.vue index a43abc6..0ed53d3 100644 --- a/frontend/pages/item/[id]/edit.vue +++ b/frontend/pages/item/[id]/edit.vue @@ -120,6 +120,11 @@ label: "Archived", ref: "archived", }, + { + type: "text", + label: "Asset ID", + ref: "assetId", + }, ]; const purchaseFields: FormField[] = [ diff --git a/frontend/pages/item/[id]/index.vue b/frontend/pages/item/[id]/index.vue index 03ddfc5..cf67854 100644 --- a/frontend/pages/item/[id]/index.vue +++ b/frontend/pages/item/[id]/index.vue @@ -70,6 +70,19 @@ ); }); + const assetID = computed
(() => { + if (item.value?.assetId === "000-000") { + return []; + } + + return [ + { + name: "Asset ID", + text: item.value?.assetId, + }, + ]; + }); + const itemDetails = computed
(() => { return [ { @@ -100,6 +113,7 @@ name: "Notes", text: item.value?.notes, }, + ...assetID.value, ...item.value.fields.map(field => { /** * Support Special URL Syntax diff --git a/frontend/pages/profile.vue b/frontend/pages/profile.vue index 5abb0f0..f2f32fe 100644 --- a/frontend/pages/profile.vue +++ b/frontend/pages/profile.vue @@ -163,6 +163,25 @@ passwordChange.current = ""; 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.`); + }