From ca36e3b080e2f2cf1076b56e87cb000ee748fc5c Mon Sep 17 00:00:00 2001 From: Hayden <64056131+hay-kot@users.noreply.github.com> Date: Mon, 12 Sep 2022 20:54:30 -0800 Subject: [PATCH] fix: csv-importer (#10) * update item fields to support import_ref * add additional rows to CSV importer * add CSV import documentation * update readme * update readme * fix failed test --- README.md | 11 +- backend/ent/item.go | 13 +- backend/ent/item/item.go | 5 + backend/ent/item/where.go | 120 ++++++++++++++++++ backend/ent/item_create.go | 27 ++++ backend/ent/item_update.go | 12 ++ backend/ent/migrate/schema.go | 13 +- backend/ent/mutation.go | 75 ++++++++++- backend/ent/runtime.go | 26 ++-- backend/ent/schema/item.go | 9 +- backend/ent/schema/label.go | 6 +- backend/ent/schema/location.go | 6 +- backend/internal/repo/repo_items_test.go | 8 +- backend/internal/services/service_items.go | 57 ++++++--- .../internal/services/service_items_csv.go | 90 ++++++------- .../services/service_items_csv_test.go | 29 ++--- .../internal/services/service_items_test.go | 22 ++-- backend/internal/types/item_types.go | 2 + docs/docs/assets/stylesheets/extras.css | 2 +- docs/docs/import-csv.md | 35 +++-- docs/mkdocs.yml | 14 ++ 21 files changed, 447 insertions(+), 135 deletions(-) diff --git a/README.md b/README.md index 009bc5f..d35dfea 100644 --- a/README.md +++ b/README.md @@ -30,21 +30,22 @@ - [x] Create - [x] Update - [x] Delete -- [ ] Items CRUD +- [x] Items CRUD - [x] Create - - [ ] Update + - [x] Update - [x] Delete - [ ] Asset Attachments for Items - [x] Fields To Add - [x] Quantity - [x] Insured (bool) -- [ ] Bulk Import via CSV +- [x] Bulk Import via CSV - [x] Initial - - [ ] Add Warranty Columns + - [x] Add Warranty Columns + - [x] All Fields + - [x] Documentations - [ ] Documentation - [ ] Docker Compose - [ ] Config File - - [ ] Import CSV Format - [ ] TLDR; Getting Started - [x] Release Flow - [x] CI/CD Docker Builds w/ Multi-arch diff --git a/backend/ent/item.go b/backend/ent/item.go index b9f47b7..d2d6a6a 100644 --- a/backend/ent/item.go +++ b/backend/ent/item.go @@ -27,6 +27,8 @@ type Item struct { Name string `json:"name,omitempty"` // Description holds the value of the "description" field. Description string `json:"description,omitempty"` + // ImportRef holds the value of the "import_ref" field. + ImportRef string `json:"import_ref,omitempty"` // Notes holds the value of the "notes" field. Notes string `json:"notes,omitempty"` // Quantity holds the value of the "quantity" field. @@ -147,7 +149,7 @@ func (*Item) scanValues(columns []string) ([]interface{}, error) { values[i] = new(sql.NullFloat64) case item.FieldQuantity: values[i] = new(sql.NullInt64) - case item.FieldName, item.FieldDescription, 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) case item.FieldCreatedAt, item.FieldUpdatedAt, item.FieldWarrantyExpires, item.FieldPurchaseTime, item.FieldSoldTime: values[i] = new(sql.NullTime) @@ -202,6 +204,12 @@ func (i *Item) assignValues(columns []string, values []interface{}) error { } else if value.Valid { i.Description = value.String } + case item.FieldImportRef: + if value, ok := values[j].(*sql.NullString); !ok { + return fmt.Errorf("unexpected type %T for field import_ref", values[j]) + } else if value.Valid { + i.ImportRef = value.String + } case item.FieldNotes: if value, ok := values[j].(*sql.NullString); !ok { return fmt.Errorf("unexpected type %T for field notes", values[j]) @@ -377,6 +385,9 @@ func (i *Item) String() string { builder.WriteString("description=") builder.WriteString(i.Description) builder.WriteString(", ") + builder.WriteString("import_ref=") + builder.WriteString(i.ImportRef) + builder.WriteString(", ") builder.WriteString("notes=") builder.WriteString(i.Notes) builder.WriteString(", ") diff --git a/backend/ent/item/item.go b/backend/ent/item/item.go index bdc7593..ca05a2e 100644 --- a/backend/ent/item/item.go +++ b/backend/ent/item/item.go @@ -21,6 +21,8 @@ const ( FieldName = "name" // FieldDescription holds the string denoting the description field in the database. FieldDescription = "description" + // FieldImportRef holds the string denoting the import_ref field in the database. + FieldImportRef = "import_ref" // FieldNotes holds the string denoting the notes field in the database. FieldNotes = "notes" // FieldQuantity holds the string denoting the quantity field in the database. @@ -107,6 +109,7 @@ var Columns = []string{ FieldUpdatedAt, FieldName, FieldDescription, + FieldImportRef, FieldNotes, FieldQuantity, FieldInsured, @@ -164,6 +167,8 @@ var ( NameValidator func(string) error // DescriptionValidator is a validator for the "description" field. It is called by the builders before save. DescriptionValidator func(string) error + // ImportRefValidator is a validator for the "import_ref" field. It is called by the builders before save. + ImportRefValidator func(string) error // NotesValidator is a validator for the "notes" field. It is called by the builders before save. NotesValidator func(string) error // DefaultQuantity holds the default value on creation for the "quantity" field. diff --git a/backend/ent/item/where.go b/backend/ent/item/where.go index 7f31665..258af9b 100644 --- a/backend/ent/item/where.go +++ b/backend/ent/item/where.go @@ -110,6 +110,13 @@ func Description(v string) predicate.Item { }) } +// ImportRef applies equality check predicate on the "import_ref" field. It's identical to ImportRefEQ. +func ImportRef(v string) predicate.Item { + return predicate.Item(func(s *sql.Selector) { + s.Where(sql.EQ(s.C(FieldImportRef), v)) + }) +} + // Notes applies equality check predicate on the "notes" field. It's identical to NotesEQ. func Notes(v string) predicate.Item { return predicate.Item(func(s *sql.Selector) { @@ -562,6 +569,119 @@ func DescriptionContainsFold(v string) predicate.Item { }) } +// ImportRefEQ applies the EQ predicate on the "import_ref" field. +func ImportRefEQ(v string) predicate.Item { + return predicate.Item(func(s *sql.Selector) { + s.Where(sql.EQ(s.C(FieldImportRef), v)) + }) +} + +// ImportRefNEQ applies the NEQ predicate on the "import_ref" field. +func ImportRefNEQ(v string) predicate.Item { + return predicate.Item(func(s *sql.Selector) { + s.Where(sql.NEQ(s.C(FieldImportRef), v)) + }) +} + +// ImportRefIn applies the In predicate on the "import_ref" field. +func ImportRefIn(vs ...string) predicate.Item { + v := make([]interface{}, len(vs)) + for i := range v { + v[i] = vs[i] + } + return predicate.Item(func(s *sql.Selector) { + s.Where(sql.In(s.C(FieldImportRef), v...)) + }) +} + +// ImportRefNotIn applies the NotIn predicate on the "import_ref" field. +func ImportRefNotIn(vs ...string) predicate.Item { + v := make([]interface{}, len(vs)) + for i := range v { + v[i] = vs[i] + } + return predicate.Item(func(s *sql.Selector) { + s.Where(sql.NotIn(s.C(FieldImportRef), v...)) + }) +} + +// ImportRefGT applies the GT predicate on the "import_ref" field. +func ImportRefGT(v string) predicate.Item { + return predicate.Item(func(s *sql.Selector) { + s.Where(sql.GT(s.C(FieldImportRef), v)) + }) +} + +// ImportRefGTE applies the GTE predicate on the "import_ref" field. +func ImportRefGTE(v string) predicate.Item { + return predicate.Item(func(s *sql.Selector) { + s.Where(sql.GTE(s.C(FieldImportRef), v)) + }) +} + +// ImportRefLT applies the LT predicate on the "import_ref" field. +func ImportRefLT(v string) predicate.Item { + return predicate.Item(func(s *sql.Selector) { + s.Where(sql.LT(s.C(FieldImportRef), v)) + }) +} + +// ImportRefLTE applies the LTE predicate on the "import_ref" field. +func ImportRefLTE(v string) predicate.Item { + return predicate.Item(func(s *sql.Selector) { + s.Where(sql.LTE(s.C(FieldImportRef), v)) + }) +} + +// ImportRefContains applies the Contains predicate on the "import_ref" field. +func ImportRefContains(v string) predicate.Item { + return predicate.Item(func(s *sql.Selector) { + s.Where(sql.Contains(s.C(FieldImportRef), v)) + }) +} + +// ImportRefHasPrefix applies the HasPrefix predicate on the "import_ref" field. +func ImportRefHasPrefix(v string) predicate.Item { + return predicate.Item(func(s *sql.Selector) { + s.Where(sql.HasPrefix(s.C(FieldImportRef), v)) + }) +} + +// ImportRefHasSuffix applies the HasSuffix predicate on the "import_ref" field. +func ImportRefHasSuffix(v string) predicate.Item { + return predicate.Item(func(s *sql.Selector) { + s.Where(sql.HasSuffix(s.C(FieldImportRef), v)) + }) +} + +// ImportRefIsNil applies the IsNil predicate on the "import_ref" field. +func ImportRefIsNil() predicate.Item { + return predicate.Item(func(s *sql.Selector) { + s.Where(sql.IsNull(s.C(FieldImportRef))) + }) +} + +// ImportRefNotNil applies the NotNil predicate on the "import_ref" field. +func ImportRefNotNil() predicate.Item { + return predicate.Item(func(s *sql.Selector) { + s.Where(sql.NotNull(s.C(FieldImportRef))) + }) +} + +// ImportRefEqualFold applies the EqualFold predicate on the "import_ref" field. +func ImportRefEqualFold(v string) predicate.Item { + return predicate.Item(func(s *sql.Selector) { + s.Where(sql.EqualFold(s.C(FieldImportRef), v)) + }) +} + +// ImportRefContainsFold applies the ContainsFold predicate on the "import_ref" field. +func ImportRefContainsFold(v string) predicate.Item { + return predicate.Item(func(s *sql.Selector) { + s.Where(sql.ContainsFold(s.C(FieldImportRef), v)) + }) +} + // NotesEQ applies the EQ predicate on the "notes" field. func NotesEQ(v string) predicate.Item { return predicate.Item(func(s *sql.Selector) { diff --git a/backend/ent/item_create.go b/backend/ent/item_create.go index cfe3975..e229099 100644 --- a/backend/ent/item_create.go +++ b/backend/ent/item_create.go @@ -74,6 +74,20 @@ func (ic *ItemCreate) SetNillableDescription(s *string) *ItemCreate { return ic } +// SetImportRef sets the "import_ref" field. +func (ic *ItemCreate) SetImportRef(s string) *ItemCreate { + ic.mutation.SetImportRef(s) + return ic +} + +// SetNillableImportRef sets the "import_ref" field if the given value is not nil. +func (ic *ItemCreate) SetNillableImportRef(s *string) *ItemCreate { + if s != nil { + ic.SetImportRef(*s) + } + return ic +} + // SetNotes sets the "notes" field. func (ic *ItemCreate) SetNotes(s string) *ItemCreate { ic.mutation.SetNotes(s) @@ -519,6 +533,11 @@ func (ic *ItemCreate) check() error { return &ValidationError{Name: "description", err: fmt.Errorf(`ent: validator failed for field "Item.description": %w`, err)} } } + if v, ok := ic.mutation.ImportRef(); ok { + if err := item.ImportRefValidator(v); err != nil { + return &ValidationError{Name: "import_ref", err: fmt.Errorf(`ent: validator failed for field "Item.import_ref": %w`, err)} + } + } if v, ok := ic.mutation.Notes(); ok { if err := item.NotesValidator(v); err != nil { return &ValidationError{Name: "notes", err: fmt.Errorf(`ent: validator failed for field "Item.notes": %w`, err)} @@ -635,6 +654,14 @@ func (ic *ItemCreate) createSpec() (*Item, *sqlgraph.CreateSpec) { }) _node.Description = value } + if value, ok := ic.mutation.ImportRef(); ok { + _spec.Fields = append(_spec.Fields, &sqlgraph.FieldSpec{ + Type: field.TypeString, + Value: value, + Column: item.FieldImportRef, + }) + _node.ImportRef = value + } if value, ok := ic.mutation.Notes(); ok { _spec.Fields = append(_spec.Fields, &sqlgraph.FieldSpec{ Type: field.TypeString, diff --git a/backend/ent/item_update.go b/backend/ent/item_update.go index 0bfe356..d2a691d 100644 --- a/backend/ent/item_update.go +++ b/backend/ent/item_update.go @@ -694,6 +694,12 @@ func (iu *ItemUpdate) sqlSave(ctx context.Context) (n int, err error) { Column: item.FieldDescription, }) } + if iu.mutation.ImportRefCleared() { + _spec.Fields.Clear = append(_spec.Fields.Clear, &sqlgraph.FieldSpec{ + Type: field.TypeString, + Column: item.FieldImportRef, + }) + } if value, ok := iu.mutation.Notes(); ok { _spec.Fields.Set = append(_spec.Fields.Set, &sqlgraph.FieldSpec{ Type: field.TypeString, @@ -1834,6 +1840,12 @@ func (iuo *ItemUpdateOne) sqlSave(ctx context.Context) (_node *Item, err error) Column: item.FieldDescription, }) } + if iuo.mutation.ImportRefCleared() { + _spec.Fields.Clear = append(_spec.Fields.Clear, &sqlgraph.FieldSpec{ + Type: field.TypeString, + Column: item.FieldImportRef, + }) + } if value, ok := iuo.mutation.Notes(); ok { _spec.Fields.Set = append(_spec.Fields.Set, &sqlgraph.FieldSpec{ Type: field.TypeString, diff --git a/backend/ent/migrate/schema.go b/backend/ent/migrate/schema.go index ee923cd..7b00b01 100644 --- a/backend/ent/migrate/schema.go +++ b/backend/ent/migrate/schema.go @@ -142,6 +142,7 @@ var ( {Name: "updated_at", Type: field.TypeTime}, {Name: "name", Type: field.TypeString, Size: 255}, {Name: "description", Type: field.TypeString, Nullable: true, Size: 1000}, + {Name: "import_ref", Type: field.TypeString, Nullable: true, Size: 100}, {Name: "notes", Type: field.TypeString, Nullable: true, Size: 1000}, {Name: "quantity", Type: field.TypeInt, Default: 1}, {Name: "insured", Type: field.TypeBool, Default: false}, @@ -169,15 +170,15 @@ var ( ForeignKeys: []*schema.ForeignKey{ { Symbol: "items_groups_items", - Columns: []*schema.Column{ItemsColumns[21]}, + Columns: []*schema.Column{ItemsColumns[22]}, RefColumns: []*schema.Column{GroupsColumns[0]}, OnDelete: schema.Cascade, }, { Symbol: "items_locations_items", - Columns: []*schema.Column{ItemsColumns[22]}, + Columns: []*schema.Column{ItemsColumns[23]}, RefColumns: []*schema.Column{LocationsColumns[0]}, - OnDelete: schema.SetNull, + OnDelete: schema.Cascade, }, }, Indexes: []*schema.Index{ @@ -189,17 +190,17 @@ var ( { Name: "item_manufacturer", Unique: false, - Columns: []*schema.Column{ItemsColumns[10]}, + Columns: []*schema.Column{ItemsColumns[11]}, }, { Name: "item_model_number", Unique: false, - Columns: []*schema.Column{ItemsColumns[9]}, + Columns: []*schema.Column{ItemsColumns[10]}, }, { Name: "item_serial_number", Unique: false, - Columns: []*schema.Column{ItemsColumns[8]}, + Columns: []*schema.Column{ItemsColumns[9]}, }, }, } diff --git a/backend/ent/mutation.go b/backend/ent/mutation.go index ae3c606..ed69d15 100644 --- a/backend/ent/mutation.go +++ b/backend/ent/mutation.go @@ -3413,6 +3413,7 @@ type ItemMutation struct { updated_at *time.Time name *string description *string + import_ref *string notes *string quantity *int addquantity *int @@ -3712,6 +3713,55 @@ func (m *ItemMutation) ResetDescription() { delete(m.clearedFields, item.FieldDescription) } +// SetImportRef sets the "import_ref" field. +func (m *ItemMutation) SetImportRef(s string) { + m.import_ref = &s +} + +// ImportRef returns the value of the "import_ref" field in the mutation. +func (m *ItemMutation) ImportRef() (r string, exists bool) { + v := m.import_ref + if v == nil { + return + } + return *v, true +} + +// OldImportRef returns the old "import_ref" 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) OldImportRef(ctx context.Context) (v string, err error) { + if !m.op.Is(OpUpdateOne) { + return v, errors.New("OldImportRef is only allowed on UpdateOne operations") + } + if m.id == nil || m.oldValue == nil { + return v, errors.New("OldImportRef requires an ID field in the mutation") + } + oldValue, err := m.oldValue(ctx) + if err != nil { + return v, fmt.Errorf("querying old value for OldImportRef: %w", err) + } + return oldValue.ImportRef, nil +} + +// ClearImportRef clears the value of the "import_ref" field. +func (m *ItemMutation) ClearImportRef() { + m.import_ref = nil + m.clearedFields[item.FieldImportRef] = struct{}{} +} + +// ImportRefCleared returns if the "import_ref" field was cleared in this mutation. +func (m *ItemMutation) ImportRefCleared() bool { + _, ok := m.clearedFields[item.FieldImportRef] + return ok +} + +// ResetImportRef resets all changes to the "import_ref" field. +func (m *ItemMutation) ResetImportRef() { + m.import_ref = nil + delete(m.clearedFields, item.FieldImportRef) +} + // SetNotes sets the "notes" field. func (m *ItemMutation) SetNotes(s string) { m.notes = &s @@ -4750,7 +4800,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, 20) + fields := make([]string, 0, 21) if m.created_at != nil { fields = append(fields, item.FieldCreatedAt) } @@ -4763,6 +4813,9 @@ func (m *ItemMutation) Fields() []string { if m.description != nil { fields = append(fields, item.FieldDescription) } + if m.import_ref != nil { + fields = append(fields, item.FieldImportRef) + } if m.notes != nil { fields = append(fields, item.FieldNotes) } @@ -4827,6 +4880,8 @@ func (m *ItemMutation) Field(name string) (ent.Value, bool) { return m.Name() case item.FieldDescription: return m.Description() + case item.FieldImportRef: + return m.ImportRef() case item.FieldNotes: return m.Notes() case item.FieldQuantity: @@ -4876,6 +4931,8 @@ func (m *ItemMutation) OldField(ctx context.Context, name string) (ent.Value, er return m.OldName(ctx) case item.FieldDescription: return m.OldDescription(ctx) + case item.FieldImportRef: + return m.OldImportRef(ctx) case item.FieldNotes: return m.OldNotes(ctx) case item.FieldQuantity: @@ -4945,6 +5002,13 @@ func (m *ItemMutation) SetField(name string, value ent.Value) error { } m.SetDescription(v) return nil + case item.FieldImportRef: + v, ok := value.(string) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.SetImportRef(v) + return nil case item.FieldNotes: v, ok := value.(string) if !ok { @@ -5129,6 +5193,9 @@ func (m *ItemMutation) ClearedFields() []string { if m.FieldCleared(item.FieldDescription) { fields = append(fields, item.FieldDescription) } + if m.FieldCleared(item.FieldImportRef) { + fields = append(fields, item.FieldImportRef) + } if m.FieldCleared(item.FieldNotes) { fields = append(fields, item.FieldNotes) } @@ -5179,6 +5246,9 @@ func (m *ItemMutation) ClearField(name string) error { case item.FieldDescription: m.ClearDescription() return nil + case item.FieldImportRef: + m.ClearImportRef() + return nil case item.FieldNotes: m.ClearNotes() return nil @@ -5232,6 +5302,9 @@ func (m *ItemMutation) ResetField(name string) error { case item.FieldDescription: m.ResetDescription() return nil + case item.FieldImportRef: + m.ResetImportRef() + return nil case item.FieldNotes: m.ResetNotes() return nil diff --git a/backend/ent/runtime.go b/backend/ent/runtime.go index 58fd270..a82fb10 100644 --- a/backend/ent/runtime.go +++ b/backend/ent/runtime.go @@ -227,48 +227,52 @@ func init() { itemDescDescription := itemMixinFields1[1].Descriptor() // item.DescriptionValidator is a validator for the "description" field. It is called by the builders before save. item.DescriptionValidator = itemDescDescription.Validators[0].(func(string) error) + // itemDescImportRef is the schema descriptor for import_ref field. + itemDescImportRef := itemFields[0].Descriptor() + // item.ImportRefValidator is a validator for the "import_ref" field. It is called by the builders before save. + item.ImportRefValidator = itemDescImportRef.Validators[0].(func(string) error) // itemDescNotes is the schema descriptor for notes field. - itemDescNotes := itemFields[0].Descriptor() + itemDescNotes := itemFields[1].Descriptor() // item.NotesValidator is a validator for the "notes" field. It is called by the builders before save. item.NotesValidator = itemDescNotes.Validators[0].(func(string) error) // itemDescQuantity is the schema descriptor for quantity field. - itemDescQuantity := itemFields[1].Descriptor() + itemDescQuantity := itemFields[2].Descriptor() // item.DefaultQuantity holds the default value on creation for the quantity field. item.DefaultQuantity = itemDescQuantity.Default.(int) // itemDescInsured is the schema descriptor for insured field. - itemDescInsured := itemFields[2].Descriptor() + itemDescInsured := itemFields[3].Descriptor() // item.DefaultInsured holds the default value on creation for the insured field. item.DefaultInsured = itemDescInsured.Default.(bool) // itemDescSerialNumber is the schema descriptor for serial_number field. - itemDescSerialNumber := itemFields[3].Descriptor() + itemDescSerialNumber := itemFields[4].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[4].Descriptor() + itemDescModelNumber := itemFields[5].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[5].Descriptor() + itemDescManufacturer := itemFields[6].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[6].Descriptor() + itemDescLifetimeWarranty := itemFields[7].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[8].Descriptor() + itemDescWarrantyDetails := itemFields[9].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[11].Descriptor() + itemDescPurchasePrice := itemFields[12].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[14].Descriptor() + itemDescSoldPrice := itemFields[15].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[15].Descriptor() + itemDescSoldNotes := itemFields[16].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/ent/schema/item.go b/backend/ent/schema/item.go index 7d2190d..127f8d8 100644 --- a/backend/ent/schema/item.go +++ b/backend/ent/schema/item.go @@ -34,6 +34,10 @@ func (Item) Indexes() []ent.Index { // Fields of the Item. func (Item) Fields() []ent.Field { return []ent.Field{ + field.String("import_ref"). + Optional(). + MaxLen(100). + Immutable(), field.String("notes"). MaxLen(1000). Optional(), @@ -102,7 +106,10 @@ func (Item) Edges() []ent.Edge { OnDelete: entsql.Cascade, }), edge.From("label", Label.Type). - Ref("items"), + Ref("items"). + Annotations(entsql.Annotation{ + OnDelete: entsql.Cascade, + }), edge.To("attachments", Attachment.Type). Annotations(entsql.Annotation{ OnDelete: entsql.Cascade, diff --git a/backend/ent/schema/label.go b/backend/ent/schema/label.go index 492ebb7..7f60552 100644 --- a/backend/ent/schema/label.go +++ b/backend/ent/schema/label.go @@ -2,6 +2,7 @@ package schema import ( "entgo.io/ent" + "entgo.io/ent/dialect/entsql" "entgo.io/ent/schema/edge" "entgo.io/ent/schema/field" "github.com/hay-kot/content/backend/ent/schema/mixins" @@ -35,6 +36,9 @@ func (Label) Edges() []ent.Edge { Ref("labels"). Required(). Unique(), - edge.To("items", Item.Type), + edge.To("items", Item.Type). + Annotations(entsql.Annotation{ + OnDelete: entsql.Cascade, + }), } } diff --git a/backend/ent/schema/location.go b/backend/ent/schema/location.go index 0241008..4206983 100644 --- a/backend/ent/schema/location.go +++ b/backend/ent/schema/location.go @@ -2,6 +2,7 @@ package schema import ( "entgo.io/ent" + "entgo.io/ent/dialect/entsql" "entgo.io/ent/schema/edge" "github.com/hay-kot/content/backend/ent/schema/mixins" ) @@ -30,6 +31,9 @@ func (Location) Edges() []ent.Edge { Ref("locations"). Unique(). Required(), - edge.To("items", Item.Type), + edge.To("items", Item.Type). + Annotations(entsql.Annotation{ + OnDelete: entsql.Cascade, + }), } } diff --git a/backend/internal/repo/repo_items_test.go b/backend/internal/repo/repo_items_test.go index 893bcb3..631f7cb 100644 --- a/backend/internal/repo/repo_items_test.go +++ b/backend/internal/repo/repo_items_test.go @@ -84,12 +84,10 @@ func TestItemsRepository_Create(t *testing.T) { assert.NoError(t, err) assert.NotEmpty(t, result.ID) - // Cleanup + // Cleanup - Also deletes item err = tRepos.Locations.Delete(context.Background(), location.ID) assert.NoError(t, err) - err = tRepos.Items.Delete(context.Background(), result.ID) - assert.NoError(t, err) } func TestItemsRepository_Create_Location(t *testing.T) { @@ -111,11 +109,9 @@ func TestItemsRepository_Create_Location(t *testing.T) { assert.Equal(t, result.ID, foundItem.ID) assert.Equal(t, location.ID, foundItem.Edges.Location.ID) - // Cleanup + // Cleanup - Also deletes item err = tRepos.Locations.Delete(context.Background(), location.ID) assert.NoError(t, err) - err = tRepos.Items.Delete(context.Background(), result.ID) - assert.NoError(t, err) } func TestItemsRepository_Delete(t *testing.T) { diff --git a/backend/internal/services/service_items.go b/backend/internal/services/service_items.go index bb83845..147cb5f 100644 --- a/backend/internal/services/service_items.go +++ b/backend/internal/services/service_items.go @@ -156,7 +156,8 @@ func (svc *ItemService) CsvImport(ctx context.Context, gid uuid.UUID, data [][]s if len(row) == 0 { continue } - if len(row) != 14 { + + if len(row) != NumOfCols { return ErrInvalidCsv } @@ -227,15 +228,16 @@ func (svc *ItemService) CsvImport(ctx context.Context, gid uuid.UUID, data [][]s } log.Info(). - Str("name", row.Name). + Str("name", row.Item.Name). Str("location", row.Location). Strs("labels", row.getLabels()). Str("locationId", locationID.String()). - Msgf("Creating Item: %s", row.Name) + Msgf("Creating Item: %s", row.Item.Name) result, err := svc.repo.Items.Create(ctx, gid, types.ItemCreate{ - Name: row.Name, - Description: row.Description, + ImportRef: row.Item.ImportRef, + Name: row.Item.Name, + Description: row.Item.Description, LabelIDs: labelIDs, LocationID: locationID, }) @@ -246,21 +248,36 @@ func (svc *ItemService) CsvImport(ctx context.Context, gid uuid.UUID, data [][]s // Update the item with the rest of the data _, err = svc.repo.Items.Update(ctx, types.ItemUpdate{ - ID: result.ID, - Name: result.Name, - LocationID: locationID, - LabelIDs: labelIDs, - Description: result.Description, - SerialNumber: row.SerialNumber, - ModelNumber: row.ModelNumber, - Manufacturer: row.Manufacturer, - Notes: row.Notes, - PurchaseFrom: row.PurchaseFrom, - PurchasePrice: row.parsedPurchasedPrice(), - PurchaseTime: row.parsedPurchasedAt(), - SoldTo: row.SoldTo, - SoldPrice: row.parsedSoldPrice(), - SoldTime: row.parsedSoldAt(), + // Edges + LocationID: locationID, + LabelIDs: labelIDs, + + // General Fields + ID: result.ID, + Name: result.Name, + Description: result.Description, + Insured: row.Item.Insured, + Notes: row.Item.Notes, + + // Identifies the item as imported + SerialNumber: row.Item.SerialNumber, + ModelNumber: row.Item.ModelNumber, + Manufacturer: row.Item.Manufacturer, + + // Purchase + PurchaseFrom: row.Item.PurchaseFrom, + PurchasePrice: row.Item.PurchasePrice, + PurchaseTime: row.Item.PurchaseTime, + + // Warranty + LifetimeWarranty: row.Item.LifetimeWarranty, + WarrantyExpires: row.Item.WarrantyExpires, + WarrantyDetails: row.Item.WarrantyDetails, + + SoldTo: row.Item.SoldTo, + SoldPrice: row.Item.SoldPrice, + SoldTime: row.Item.SoldTime, + SoldNotes: row.Item.SoldNotes, }) if err != nil { diff --git a/backend/internal/services/service_items_csv.go b/backend/internal/services/service_items_csv.go index 5e25127..ed8e5a4 100644 --- a/backend/internal/services/service_items_csv.go +++ b/backend/internal/services/service_items_csv.go @@ -5,10 +5,14 @@ import ( "strconv" "strings" "time" + + "github.com/hay-kot/content/backend/internal/types" ) var ErrInvalidCsv = errors.New("invalid csv") +const NumOfCols = 21 + func parseFloat(s string) float64 { if s == "" { return 0 @@ -26,60 +30,56 @@ func parseDate(s string) time.Time { return p } +func parseBool(s string) bool { + switch strings.ToLower(s) { + case "true", "yes", "1": + return true + default: + return false + } +} + +func parseInt(s string) int { + i, _ := strconv.Atoi(s) + return i +} + type csvRow struct { - Location string - Labels string - Name string - Description string - SerialNumber string - ModelNumber string - Manufacturer string - Notes string - PurchaseFrom string - PurchasedPrice string - PurchasedAt string - SoldTo string - SoldPrice string - SoldAt string + Item types.ItemSummary + Location string + LabelStr string } func newCsvRow(row []string) csvRow { return csvRow{ - Location: row[0], - Labels: row[1], - Name: row[2], - Description: row[3], - SerialNumber: row[4], - ModelNumber: row[5], - Manufacturer: row[6], - Notes: row[7], - PurchaseFrom: row[8], - PurchasedPrice: row[9], - PurchasedAt: row[10], - SoldTo: row[11], - SoldPrice: row[12], - SoldAt: row[13], + Location: row[1], + LabelStr: row[2], + Item: types.ItemSummary{ + ImportRef: row[0], + Quantity: parseInt(row[3]), + Name: row[4], + Description: row[5], + Insured: parseBool(row[6]), + SerialNumber: row[7], + ModelNumber: row[8], + Manufacturer: row[9], + Notes: row[10], + PurchaseFrom: row[11], + PurchasePrice: parseFloat(row[12]), + PurchaseTime: parseDate(row[13]), + LifetimeWarranty: parseBool(row[14]), + WarrantyExpires: parseDate(row[15]), + WarrantyDetails: row[16], + SoldTo: row[17], + SoldPrice: parseFloat(row[18]), + SoldTime: parseDate(row[19]), + SoldNotes: row[20], + }, } } -func (c csvRow) parsedSoldPrice() float64 { - return parseFloat(c.SoldPrice) -} - -func (c csvRow) parsedPurchasedPrice() float64 { - return parseFloat(c.PurchasedPrice) -} - -func (c csvRow) parsedPurchasedAt() time.Time { - return parseDate(c.PurchasedAt) -} - -func (c csvRow) parsedSoldAt() time.Time { - return parseDate(c.SoldAt) -} - func (c csvRow) getLabels() []string { - split := strings.Split(c.Labels, ";") + split := strings.Split(c.LabelStr, ";") // Trim each for i, s := range split { diff --git a/backend/internal/services/service_items_csv_test.go b/backend/internal/services/service_items_csv_test.go index 34f35d0..fed6e31 100644 --- a/backend/internal/services/service_items_csv_test.go +++ b/backend/internal/services/service_items_csv_test.go @@ -8,14 +8,13 @@ import ( ) const CSV_DATA = ` -Location,Labels,Name,Description,Serial Number,Mode Number,Manufacturer,Notes,Purchase From,Purchased Price,Purchased At,Sold To,Sold Price,Sold At -Garage,IOT;Home Assistant; Z-Wave,Zooz Universal Relay ZEN17,"Zooz 700 Series Z-Wave Universal Relay ZEN17 for Awnings, Garage Doors, Sprinklers, and More | 2 NO-C-NC Relays (20A, 10A) | Signal Repeater | Hub Required (Compatible with SmartThings and Hubitat)",,ZEN17,Zooz,,Amazon,39.95,10/13/2021,,, -Living Room,IOT;Home Assistant; Z-Wave,Zooz Motion Sensor,"Zooz Z-Wave Plus S2 Motion Sensor ZSE18 with Magnetic Mount, Works with Vera and SmartThings",,ZSE18,Zooz,,Amazon,29.95,10/15/2021,,, -Office,IOT;Home Assistant; Z-Wave,Zooz 110v Power Switch,"Zooz Z-Wave Plus Power Switch ZEN15 for 110V AC Units, Sump Pumps, Humidifiers, and More",,ZEN15,Zooz,,Amazon,39.95,10/13/2021,,, -Downstairs,IOT;Home Assistant; Z-Wave,Ecolink Z-Wave PIR Motion Sensor,"Ecolink Z-Wave PIR Motion Detector Pet Immune, White (PIRZWAVE2.5-ECO)",,PIRZWAVE2.5-ECO,Ecolink,,Amazon,35.58,10/21/2020,,, -Entry,IOT;Home Assistant; Z-Wave,Yale Security Touchscreen Deadbolt,"Yale Security YRD226-ZW2-619 YRD226ZW2619 Touchscreen Deadbolt, Satin Nickel",,YRD226ZW2619,Yale,,Amazon,120.39,10/14/2020,,, -Kitchen,IOT;Home Assistant; Z-Wave,Smart Rocker Light Dimmer,"UltraPro Z-Wave Smart Rocker Light Dimmer with QuickFit and SimpleWire, 3-Way Ready, Compatible with Alexa, Google Assistant, ZWave Hub Required, Repeater/Range Extender, White Paddle Only, 39351",,39351,Honeywell,,Amazon,65.98,09/30/0202,,, -` +Import Ref,Location,Labels,Quantity,Name,Description,Insured,Serial Number,Mode Number,Manufacturer,Notes,Purchase From,Purchased Price,Purchased Time,Lifetime Warranty,Warranty Expires,Warranty Details,Sold To,Sold Price,Sold Time,Sold Notes +,Garage,IOT;Home Assistant; Z-Wave,1,Zooz Universal Relay ZEN17,"Zooz 700 Series Z-Wave Universal Relay ZEN17 for Awnings, Garage Doors, Sprinklers, and More | 2 NO-C-NC Relays (20A, 10A) | Signal Repeater | Hub Required (Compatible with SmartThings and Hubitat)",,,ZEN17,Zooz,,Amazon,39.95,10/13/2021,,,,,,, +,Living Room,IOT;Home Assistant; Z-Wave,1,Zooz Motion Sensor,"Zooz Z-Wave Plus S2 Motion Sensor ZSE18 with Magnetic Mount, Works with Vera and SmartThings",,,ZSE18,Zooz,,Amazon,29.95,10/15/2021,,,,,,, +,Office,IOT;Home Assistant; Z-Wave,1,Zooz 110v Power Switch,"Zooz Z-Wave Plus Power Switch ZEN15 for 110V AC Units, Sump Pumps, Humidifiers, and More",,,ZEN15,Zooz,,Amazon,39.95,10/13/2021,,,,,,, +,Downstairs,IOT;Home Assistant; Z-Wave,1,Ecolink Z-Wave PIR Motion Sensor,"Ecolink Z-Wave PIR Motion Detector Pet Immune, White (PIRZWAVE2.5-ECO)",,,PIRZWAVE2.5-ECO,Ecolink,,Amazon,35.58,10/21/2020,,,,,,, +,Entry,IOT;Home Assistant; Z-Wave,1,Yale Security Touchscreen Deadbolt,"Yale Security YRD226-ZW2-619 YRD226ZW2619 Touchscreen Deadbolt, Satin Nickel",,,YRD226ZW2619,Yale,,Amazon,120.39,10/14/2020,,,,,,, +,Kitchen,IOT;Home Assistant; Z-Wave,1,Smart Rocker Light Dimmer,"UltraPro Z-Wave Smart Rocker Light Dimmer with QuickFit and SimpleWire, 3-Way Ready, Compatible with Alexa, Google Assistant, ZWave Hub Required, Repeater/Range Extender, White Paddle Only, 39351",,,39351,Honeywell,,Amazon,65.98,09/30/0202,,,,,,,` func loadcsv() [][]string { reader := csv.NewReader(bytes.NewBuffer([]byte(CSV_DATA))) @@ -30,7 +29,7 @@ func loadcsv() [][]string { func Test_csvRow_getLabels(t *testing.T) { type fields struct { - Labels string + LabelStr string } tests := []struct { name string @@ -40,28 +39,28 @@ func Test_csvRow_getLabels(t *testing.T) { { name: "basic test", fields: fields{ - Labels: "IOT;Home Assistant;Z-Wave", + LabelStr: "IOT;Home Assistant;Z-Wave", }, want: []string{"IOT", "Home Assistant", "Z-Wave"}, }, { name: "no labels", fields: fields{ - Labels: "", + LabelStr: "", }, want: []string{}, }, { name: "single label", fields: fields{ - Labels: "IOT", + LabelStr: "IOT", }, want: []string{"IOT"}, }, { name: "trailing semicolon", fields: fields{ - Labels: "IOT;", + LabelStr: "IOT;", }, want: []string{"IOT"}, }, @@ -69,7 +68,7 @@ func Test_csvRow_getLabels(t *testing.T) { { name: "whitespace", fields: fields{ - Labels: " IOT; Home Assistant; Z-Wave ", + LabelStr: " IOT; Home Assistant; Z-Wave ", }, want: []string{"IOT", "Home Assistant", "Z-Wave"}, }, @@ -77,7 +76,7 @@ func Test_csvRow_getLabels(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { c := csvRow{ - Labels: tt.fields.Labels, + LabelStr: tt.fields.LabelStr, } if got := c.getLabels(); !reflect.DeepEqual(got, tt.want) { t.Errorf("csvRow.getLabels() = %v, want %v", got, tt.want) diff --git a/backend/internal/services/service_items_test.go b/backend/internal/services/service_items_test.go index ceeb764..b71a74e 100644 --- a/backend/internal/services/service_items_test.go +++ b/backend/internal/services/service_items_test.go @@ -73,21 +73,21 @@ func TestItemService_CsvImport(t *testing.T) { } for _, csvRow := range dataCsv { - if csvRow.Name == item.Name { - assert.Equal(t, csvRow.Description, item.Description) - assert.Equal(t, csvRow.SerialNumber, item.SerialNumber) - assert.Equal(t, csvRow.Manufacturer, item.Manufacturer) - assert.Equal(t, csvRow.Notes, item.Notes) + if csvRow.Item.Name == item.Name { + assert.Equal(t, csvRow.Item.Description, item.Description) + assert.Equal(t, csvRow.Item.SerialNumber, item.SerialNumber) + assert.Equal(t, csvRow.Item.Manufacturer, item.Manufacturer) + assert.Equal(t, csvRow.Item.Notes, item.Notes) // Purchase Fields - assert.Equal(t, csvRow.parsedPurchasedAt(), item.PurchaseTime) - assert.Equal(t, csvRow.PurchaseFrom, item.PurchaseFrom) - assert.Equal(t, csvRow.parsedPurchasedPrice(), item.PurchasePrice) + assert.Equal(t, csvRow.Item.PurchaseTime, item.PurchaseTime) + assert.Equal(t, csvRow.Item.PurchaseFrom, item.PurchaseFrom) + assert.Equal(t, csvRow.Item.PurchasePrice, item.PurchasePrice) // Sold Fields - assert.Equal(t, csvRow.parsedSoldAt(), item.SoldTime) - assert.Equal(t, csvRow.SoldTo, item.SoldTo) - assert.Equal(t, csvRow.parsedSoldPrice(), item.SoldPrice) + assert.Equal(t, csvRow.Item.SoldTime, item.SoldTime) + assert.Equal(t, csvRow.Item.SoldTo, item.SoldTo) + assert.Equal(t, csvRow.Item.SoldPrice, item.SoldPrice) } } } diff --git a/backend/internal/types/item_types.go b/backend/internal/types/item_types.go index 36bc8ff..ae682ae 100644 --- a/backend/internal/types/item_types.go +++ b/backend/internal/types/item_types.go @@ -7,6 +7,7 @@ import ( ) type ItemCreate struct { + ImportRef string `json:"-"` Name string `json:"name"` Description string `json:"description"` @@ -53,6 +54,7 @@ type ItemUpdate struct { } type ItemSummary struct { + ImportRef string `json:"-"` ID uuid.UUID `json:"id"` Name string `json:"name"` Description string `json:"description"` diff --git a/docs/docs/assets/stylesheets/extras.css b/docs/docs/assets/stylesheets/extras.css index 7738282..72ae471 100644 --- a/docs/docs/assets/stylesheets/extras.css +++ b/docs/docs/assets/stylesheets/extras.css @@ -1,5 +1,5 @@ [data-md-color-scheme="homebox"] { - --md-primary-fg-color: #5d5656; + --md-primary-fg-color: #5b7f67; --md-primary-fg-color--light: #5b7f67; --md-primary-fg-color--dark: #90030c; } diff --git a/docs/docs/import-csv.md b/docs/docs/import-csv.md index 95b92a0..32cf208 100644 --- a/docs/docs/import-csv.md +++ b/docs/docs/import-csv.md @@ -1,28 +1,41 @@ # CSV Imports -This document outlines the CSV import feature of Homebox and how to use it. - ## Quick Start Using the CSV import is the recommended way for adding items to the database. It is always going to be the fastest way to import any large amount of items and provides the most flexibility when it comes to adding items. **Limitations** - - Currently only supports importing items + + - Currently only supports importing items, locations, and labels - Does not support attachments. Attachments must be uploaded after import +**Template** + +You can use this snippet as the headers for your CSV. Copy and paste it into your spreadsheet editor of choice and fill in the value. + +```csv +Import RefLocation Labels Quantity Name Description Insured Serial Number Model Number Manufacturer Notes Purchase From Purchased Price Purchased Time Lifetime Warranty Warranty Expires Warranty Details Sold To Sold Price Sold Time Sold Notes +``` + +!!! tip "Column Order" + Column headers are just there for reference, the important thing is that the order is correct. You can change the headers to anything you like. + + ## CSV Reference -| Column Heading | Type | Description | +| Column | Type | Description | | ----------------- | -------------------- | ----------------------------------------------------------------------------------------------------------------------------- | +| ImportRef | String (100) | Future | | Location | String | This is the location of the item that will be created. These are de-duplicated and won't create another instance when reused. | -| Labels | `;` Separated String | List of labels to apply to the item seperated by a `;`, can be existing or new | +| Labels | `;` Separated String | List of labels to apply to the item separated by a `;`, can be existing or new | | Quantity | Integer | The quantity of items to create | | Name | String | Name of the item | | Description | String | Description of the item | +| Insured | Boolean | Whether or not the item is insured | | Serial Number | String | Serial number of the item | | Model Number | String | Model of the item | | Manufacturer | String | Manufacturer of the item | -| Notes | String | General notes about the product | +| Notes | String (1000) | General notes about the product | | Purchase From | String | Name of the place the item was purchased from | | Purchase Price | Float64 | | | Purchase At | Date | Date the item was purchased | @@ -32,10 +45,12 @@ Using the CSV import is the recommended way for adding items to the database. It | Sold To | String | Name of the person the item was sold to | | Sold At | Date | Date the item was sold | | Sold Price | Float64 | | +| Sold Notes | String (1000) | | **Type Key** -| Type | Format | -| ------ | ------------------ | -| String | Max 255 Characters | -| Date | YYYY-MM-DD | +| Type | Format | +| ------- | --------------------------------------------------- | +| String | Max 255 Characters unless otherwise specified | +| Date | YYYY-MM-DD | +| Boolean | true or false, yes or no, 1 or 0 - case insensitive | diff --git a/docs/mkdocs.yml b/docs/mkdocs.yml index d26e480..091f925 100644 --- a/docs/mkdocs.yml +++ b/docs/mkdocs.yml @@ -27,3 +27,17 @@ theme: extra_css: - assets/stylesheets/extras.css + +markdown_extensions: + - pymdownx.emoji: + emoji_index: !!python/name:materialx.emoji.twemoji + emoji_generator: !!python/name:materialx.emoji.to_svg + - def_list + - pymdownx.highlight + - pymdownx.superfences + - pymdownx.tasklist: + custom_checkbox: true + - admonition + - attr_list + - pymdownx.tabbed + - pymdownx.superfences