diff --git a/api/adoptionplan.go b/api/adoptionplan.go index e6f5be83a..da32c3278 100644 --- a/api/adoptionplan.go +++ b/api/adoptionplan.go @@ -79,7 +79,7 @@ func (h AdoptionPlanHandler) Graph(ctx *gin.Context) { for i := range reviews { review := &reviews[i] vertex := Vertex{ - ID: review.ApplicationID, + ID: *review.ApplicationID, Name: review.Application.Name, Decision: review.ProposedAction, EffortEstimate: review.EffortEstimate, diff --git a/api/application.go b/api/application.go index 4a867ff6e..3b27f38a0 100644 --- a/api/application.go +++ b/api/application.go @@ -3,6 +3,7 @@ package api import ( "encoding/json" "github.com/gin-gonic/gin" + "github.com/konveyor/tackle2-hub/assessment" "github.com/konveyor/tackle2-hub/model" "gorm.io/gorm/clause" "net/http" @@ -21,6 +22,8 @@ const ( AppBucketRoot = ApplicationRoot + "/bucket" AppBucketContentRoot = AppBucketRoot + "/*" + Wildcard AppStakeholdersRoot = ApplicationRoot + "/stakeholders" + AppAssessmentsRoot = ApplicationRoot + "/assessments" + AppAssessmentRoot = AppAssessmentsRoot + "/:" + ID2 ) // @@ -77,6 +80,11 @@ func (h ApplicationHandler) AddRoutes(e *gin.Engine) { routeGroup = e.Group("/") routeGroup.Use(Required("applications.stakeholders")) routeGroup.PUT(AppStakeholdersRoot, h.StakeholdersUpdate) + // Assessments + routeGroup = e.Group("/") + routeGroup.Use(Required("applications.assessments")) + routeGroup.GET(AppAssessmentsRoot, h.AssessmentList) + routeGroup.POST(AppAssessmentsRoot, h.AssessmentCreate) } // Get godoc @@ -106,8 +114,39 @@ func (h ApplicationHandler) Get(ctx *gin.Context) { return } + questionnaire, err := assessment.NewQuestionnaireResolver(h.DB(ctx)) + if err != nil { + _ = ctx.Error(err) + return + } + membership := assessment.NewMembershipResolver(h.DB(ctx)) + tagsResolver, err := assessment.NewTagResolver(h.DB(ctx)) + if err != nil { + _ = ctx.Error(err) + return + } + resolver := assessment.NewApplicationResolver(m, tagsResolver, membership, questionnaire) + archetypes, err := resolver.Archetypes() + if err != nil { + _ = ctx.Error(err) + return + } + archetypeTags, err := resolver.ArchetypeTags() + if err != nil { + _ = ctx.Error(err) + return + } + r := Application{} r.With(m, tags) + r.WithArchetypes(archetypes) + r.WithSourcedTags(archetypeTags, "archetype") + r.WithSourcedTags(resolver.AssessmentTags(), "assessment") + r.Assessed, err = resolver.Assessed() + if err != nil { + _ = ctx.Error(err) + return + } h.Respond(ctx, http.StatusOK, r) } @@ -128,6 +167,19 @@ func (h ApplicationHandler) List(ctx *gin.Context) { _ = ctx.Error(result.Error) return } + + questionnaire, err := assessment.NewQuestionnaireResolver(h.DB(ctx)) + if err != nil { + _ = ctx.Error(err) + return + } + membership := assessment.NewMembershipResolver(h.DB(ctx)) + tagsResolver, err := assessment.NewTagResolver(h.DB(ctx)) + if err != nil { + _ = ctx.Error(err) + return + } + resources := []Application{} for i := range list { tags := []model.ApplicationTag{} @@ -137,9 +189,27 @@ func (h ApplicationHandler) List(ctx *gin.Context) { _ = ctx.Error(result.Error) return } - + resolver := assessment.NewApplicationResolver(&list[i], tagsResolver, membership, questionnaire) + archetypes, aErr := resolver.Archetypes() + if aErr != nil { + _ = ctx.Error(aErr) + return + } + archetypeTags, aErr := resolver.ArchetypeTags() + if aErr != nil { + _ = ctx.Error(aErr) + return + } r := Application{} r.With(&list[i], tags) + r.WithArchetypes(archetypes) + r.WithSourcedTags(archetypeTags, "archetype") + r.WithSourcedTags(resolver.AssessmentTags(), "assessment") + r.Assessed, err = resolver.Assessed() + if err != nil { + _ = ctx.Error(err) + return + } resources = append(resources, r) } @@ -182,7 +252,38 @@ func (h ApplicationHandler) Create(ctx *gin.Context) { } } + questionnaire, err := assessment.NewQuestionnaireResolver(h.DB(ctx)) + if err != nil { + _ = ctx.Error(err) + return + } + membership := assessment.NewMembershipResolver(h.DB(ctx)) + tagsResolver, err := assessment.NewTagResolver(h.DB(ctx)) + if err != nil { + _ = ctx.Error(err) + return + } + resolver := assessment.NewApplicationResolver(m, tagsResolver, membership, questionnaire) + archetypes, err := resolver.Archetypes() + if err != nil { + _ = ctx.Error(err) + return + } + archetypeTags, err := resolver.ArchetypeTags() + if err != nil { + _ = ctx.Error(err) + return + } + r.With(m, tags) + r.WithArchetypes(archetypes) + r.WithSourcedTags(archetypeTags, "archetype") + r.WithSourcedTags(resolver.AssessmentTags(), "assessment") + r.Assessed, err = resolver.Assessed() + if err != nil { + _ = ctx.Error(err) + return + } h.Respond(ctx, http.StatusCreated, r) } @@ -202,12 +303,6 @@ func (h ApplicationHandler) Delete(ctx *gin.Context) { _ = ctx.Error(result.Error) return } - p := Pathfinder{} - err := p.DeleteAssessment([]uint{id}, ctx) - if err != nil { - _ = ctx.Error(err) - return - } result = h.DB(ctx).Delete(m) if result.Error != nil { _ = ctx.Error(result.Error) @@ -231,12 +326,6 @@ func (h ApplicationHandler) DeleteList(ctx *gin.Context) { _ = ctx.Error(err) return } - p := Pathfinder{} - err = p.DeleteAssessment(ids, ctx) - if err != nil { - _ = ctx.Error(err) - return - } err = h.DB(ctx).Delete( &model.Application{}, "id IN ?", @@ -843,6 +932,115 @@ func (h ApplicationHandler) StakeholdersUpdate(ctx *gin.Context) { h.Status(ctx, http.StatusNoContent) } +// AssessmentList godoc +// @summary List the assessments of an Application and any it inherits from its archetypes. +// @description List the assessments of an Application and any it inherits from its archetypes. +// @tags applications +// @success 200 {object} []api.Assessment +// @router /applications/{id}/assessments [get] +// @param id path int true "Application ID" +func (h ApplicationHandler) AssessmentList(ctx *gin.Context) { + m := &model.Application{} + id := h.pk(ctx) + db := h.preLoad(h.DB(ctx), clause.Associations) + db = db.Omit("Analyses") + result := db.First(m, id) + if result.Error != nil { + _ = ctx.Error(result.Error) + return + } + + questionnaire, err := assessment.NewQuestionnaireResolver(h.DB(ctx)) + if err != nil { + _ = ctx.Error(err) + return + } + membership := assessment.NewMembershipResolver(h.DB(ctx)) + tagsResolver, err := assessment.NewTagResolver(h.DB(ctx)) + if err != nil { + _ = ctx.Error(err) + return + } + resolver := assessment.NewApplicationResolver(m, tagsResolver, membership, questionnaire) + archetypes, err := resolver.Archetypes() + if err != nil { + _ = ctx.Error(err) + return + } + + assessments := m.Assessments + for _, a := range archetypes { + assessments = append(assessments, a.Assessments...) + } + + resources := []Assessment{} + for i := range assessments { + r := Assessment{} + r.With(&assessments[i]) + resources = append(resources, r) + } + + h.Respond(ctx, http.StatusOK, resources) +} + +// AssessmentCreate godoc +// @summary Create an application assessment. +// @description Create an application assessment. +// @tags applications +// @accept json +// @produce json +// @success 201 {object} api.Assessment +// @router /applications/{id}/assessments [post] +// @param assessment body api.Assessment true "Assessment data" +func (h ApplicationHandler) AssessmentCreate(ctx *gin.Context) { + application := &model.Application{} + id := h.pk(ctx) + db := h.preLoad(h.DB(ctx), clause.Associations) + db = db.Omit("Analyses") + result := db.First(application, id) + if result.Error != nil { + _ = ctx.Error(result.Error) + return + } + + r := &Assessment{} + err := h.Bind(ctx, r) + if err != nil { + _ = ctx.Error(err) + return + } + r.Application = &Ref{ID: id} + r.Archetype = nil + q := &model.Questionnaire{} + db = h.preLoad(h.DB(ctx)) + result = db.First(q, r.Questionnaire.ID) + if result.Error != nil { + _ = ctx.Error(result.Error) + return + } + m := r.Model() + m.Sections = q.Sections + m.Thresholds = q.Thresholds + m.RiskMessages = q.RiskMessages + m.CreateUser = h.CurrentUser(ctx) + + resolver, err := assessment.NewTagResolver(h.DB(ctx)) + if err != nil { + _ = ctx.Error(err) + return + } + assessment.PrepareForApplication(resolver, application, m) + + result = h.DB(ctx).Create(m) + if result.Error != nil { + _ = ctx.Error(result.Error) + return + } + + r.With(m) + h.Respond(ctx, http.StatusCreated, r) +} + // // Application REST resource. type Application struct { @@ -860,6 +1058,9 @@ type Application struct { Owner *Ref `json:"owner"` Contributors []Ref `json:"contributors"` MigrationWave *Ref `json:"migrationWave"` + Archetypes []Ref `json:"archetypes"` + Assessments []Ref `json:"assessments"` + Assessed bool `json:"assessed"` } // @@ -901,6 +1102,32 @@ func (r *Application) With(m *model.Application, tags []model.ApplicationTag) { ref) } r.MigrationWave = r.refPtr(m.MigrationWaveID, m.MigrationWave) + r.Assessments = []Ref{} + for _, a := range m.Assessments { + ref := Ref{} + ref.With(a.ID, "") + r.Assessments = append(r.Assessments, ref) + } +} + +// +// WithArchetypes updates the resource with archetypes. +func (r *Application) WithArchetypes(archetypes []model.Archetype) { + for _, a := range archetypes { + ref := Ref{} + ref.With(a.ID, a.Name) + r.Archetypes = append(r.Archetypes, ref) + } +} + +// +// WithSourcedTags updates the resource with tags derived from assessments. +func (r *Application) WithSourcedTags(tags []model.Tag, source string) { + for _, t := range tags { + ref := TagRef{} + ref.With(t.ID, t.Name, source) + r.Tags = append(r.Tags, ref) + } } // diff --git a/api/archetype.go b/api/archetype.go new file mode 100644 index 000000000..221f4cff5 --- /dev/null +++ b/api/archetype.go @@ -0,0 +1,416 @@ +package api + +import ( + "github.com/gin-gonic/gin" + "github.com/konveyor/tackle2-hub/assessment" + "github.com/konveyor/tackle2-hub/model" + "gorm.io/gorm/clause" + "net/http" +) + +// +// Routes +const ( + ArchetypesRoot = "/archetypes" + ArchetypeRoot = ArchetypesRoot + "/:" + ID + ArchetypeAssessmentsRoot = ArchetypeRoot + "/assessments" +) + +// +// ArchetypeHandler handles Archetype resource routes. +type ArchetypeHandler struct { + BaseHandler +} + +// +// AddRoutes adds routes. +func (h ArchetypeHandler) AddRoutes(e *gin.Engine) { + routeGroup := e.Group("/") + routeGroup.Use(Required("archetypes"), Transaction) + routeGroup.GET(ArchetypesRoot, h.List) + routeGroup.POST(ArchetypesRoot, h.Create) + routeGroup.GET(ArchetypeRoot, h.Get) + routeGroup.PUT(ArchetypeRoot, h.Update) + routeGroup.DELETE(ArchetypeRoot, h.Delete) + // Assessments + routeGroup = e.Group("/") + routeGroup.Use(Required("archetypes.assessments")) + routeGroup.GET(ArchetypeAssessmentsRoot, h.AssessmentList) + routeGroup.POST(ArchetypeAssessmentsRoot, h.AssessmentCreate) +} + +// Get godoc +// @summary Get an archetype by ID. +// @description Get an archetype by ID. +// @tags archetypes +// @produce json +// @success 200 {object} api.Archetypes +// @router /archetypes/{id} [get] +// @param id path string true "Archetype ID" +func (h ArchetypeHandler) Get(ctx *gin.Context) { + m := &model.Archetype{} + id := h.pk(ctx) + db := h.preLoad(h.DB(ctx), clause.Associations) + result := db.First(m, id) + if result.Error != nil { + _ = ctx.Error(result.Error) + return + } + + archetypes := []model.Archetype{} + db = h.preLoad(h.DB(ctx), "Tags", "CriteriaTags") + result = db.Find(&archetypes) + if result.Error != nil { + _ = ctx.Error(result.Error) + return + } + + membership := assessment.NewMembershipResolver(h.DB(ctx)) + applications, err := membership.Applications(m) + if err != nil { + _ = ctx.Error(err) + return + } + + r := Archetype{} + r.With(m) + r.WithApplications(applications) + h.Respond(ctx, http.StatusOK, r) +} + +// List godoc +// @summary List all archetypes. +// @description List all archetypes. +// @tags archetypes +// @produce json +// @success 200 {object} []api.Archetype +// @router /archetypes [get] +func (h ArchetypeHandler) List(ctx *gin.Context) { + var list []model.Archetype + db := h.preLoad(h.DB(ctx), clause.Associations) + result := db.Find(&list) + if result.Error != nil { + _ = ctx.Error(result.Error) + return + } + + membership := assessment.NewMembershipResolver(h.DB(ctx)) + resources := []Archetype{} + for i := range list { + r := Archetype{} + applications, err := membership.Applications(&list[i]) + if err != nil { + _ = ctx.Error(err) + return + } + r.With(&list[i]) + r.WithApplications(applications) + resources = append(resources, r) + } + + h.Respond(ctx, http.StatusOK, resources) +} + +// Create godoc +// @summary Create an archetype. +// @description Create an archetype. +// @tags archetypes +// @accept json +// @produce json +// @success 200 {object} api.Archetype +// @router /archetypes [post] +// @param archetype body api.Archetype true "Archetype data" +func (h ArchetypeHandler) Create(ctx *gin.Context) { + r := &Archetype{} + err := h.Bind(ctx, r) + if err != nil { + _ = ctx.Error(err) + return + } + m := r.Model() + m.CreateUser = h.CurrentUser(ctx) + result := h.DB(ctx).Create(m) + if result.Error != nil { + _ = ctx.Error(result.Error) + return + } + + archetypes := []model.Archetype{} + db := h.preLoad(h.DB(ctx), "Tags", "CriteriaTags") + result = db.Find(&archetypes) + if result.Error != nil { + _ = ctx.Error(result.Error) + } + + membership := assessment.NewMembershipResolver(h.DB(ctx)) + applications, err := membership.Applications(m) + if err != nil { + _ = ctx.Error(err) + return + } + + r.With(m) + r.WithApplications(applications) + h.Respond(ctx, http.StatusCreated, r) +} + +// Delete godoc +// @summary Delete an archetype. +// @description Delete an archetype. +// @tags archetypes +// @success 204 +// @router /archetypes/{id} [delete] +// @param id path string true "Archetype ID" +func (h ArchetypeHandler) Delete(ctx *gin.Context) { + id := h.pk(ctx) + m := &model.Archetype{} + result := h.DB(ctx).First(m, id) + if result.Error != nil { + _ = ctx.Error(result.Error) + return + } + result = h.DB(ctx).Delete(m) + if result.Error != nil { + _ = ctx.Error(result.Error) + return + } + + h.Status(ctx, http.StatusNoContent) +} + +// Update godoc +// @summary Update an archetype. +// @description Update an archetype. +// @tags archetypes +// @accept json +// @success 204 +// @router /archetypes/{id} [put] +// @param id path string true "Archetype ID" +// @param archetype body api.Archetype true "Archetype data" +func (h ArchetypeHandler) Update(ctx *gin.Context) { + id := h.pk(ctx) + r := &Archetype{} + err := h.Bind(ctx, r) + if err != nil { + _ = ctx.Error(err) + return + } + m := r.Model() + m.ID = id + m.UpdateUser = h.CurrentUser(ctx) + db := h.DB(ctx).Model(m) + db = db.Omit(clause.Associations) + result := db.Updates(h.fields(m)) + if result.Error != nil { + _ = ctx.Error(result.Error) + return + } + + err = h.DB(ctx).Model(m).Association("Stakeholders").Replace("Stakeholders", m.Stakeholders) + if err != nil { + _ = ctx.Error(err) + return + } + err = h.DB(ctx).Model(m).Association("StakeholderGroups").Replace("StakeholderGroups", m.StakeholderGroups) + if err != nil { + _ = ctx.Error(err) + return + } + err = h.DB(ctx).Model(m).Association("CriteriaTags").Replace("CriteriaTags", m.CriteriaTags) + if err != nil { + _ = ctx.Error(err) + return + } + err = h.DB(ctx).Model(m).Association("Tags").Replace("Tags", m.Tags) + if err != nil { + _ = ctx.Error(err) + return + } + + h.Status(ctx, http.StatusNoContent) +} + +// AssessmentList godoc +// @summary List the assessments of an archetype. +// @description List the assessments of an archetype. +// @tags archetypes +// @success 200 {object} []api.Assessment +// @router /archetypes/{id}/assessments [get] +// @param id path int true "Archetype ID" +func (h ArchetypeHandler) AssessmentList(ctx *gin.Context) { + m := &model.Archetype{} + id := h.pk(ctx) + db := h.preLoad(h.DB(ctx), clause.Associations) + result := db.First(m, id) + if result.Error != nil { + _ = ctx.Error(result.Error) + return + } + + resources := []Assessment{} + for i := range m.Assessments { + r := Assessment{} + r.With(&m.Assessments[i]) + resources = append(resources, r) + } + + h.Respond(ctx, http.StatusOK, resources) +} + +// AssessmentCreate godoc +// @summary Create an archetype assessment. +// @description Create an archetype assessment. +// @tags archetypes +// @accept json +// @produce json +// @success 201 {object} api.Assessment +// @router /archetypes/{id}/assessments [post] +// @param assessment body api.Assessment true "Assessment data" +func (h ArchetypeHandler) AssessmentCreate(ctx *gin.Context) { + archetype := &model.Archetype{} + id := h.pk(ctx) + db := h.preLoad(h.DB(ctx), clause.Associations) + result := db.First(archetype, id) + if result.Error != nil { + _ = ctx.Error(result.Error) + return + } + + r := &Assessment{} + err := h.Bind(ctx, r) + if err != nil { + _ = ctx.Error(err) + return + } + r.Archetype = &Ref{ID: id} + r.Application = nil + q := &model.Questionnaire{} + result = h.DB(ctx).First(q, r.Questionnaire.ID) + if result.Error != nil { + _ = ctx.Error(result.Error) + return + } + m := r.Model() + m.Sections = q.Sections + m.Thresholds = q.Thresholds + m.RiskMessages = q.RiskMessages + m.CreateUser = h.CurrentUser(ctx) + + resolver, err := assessment.NewTagResolver(h.DB(ctx)) + if err != nil { + _ = ctx.Error(err) + return + } + assessment.PrepareForArchetype(resolver, archetype, m) + result = h.DB(ctx).Create(m) + if result.Error != nil { + _ = ctx.Error(result.Error) + return + } + + r.With(m) + h.Respond(ctx, http.StatusCreated, r) +} + +// +// Archetype REST resource. +type Archetype struct { + Resource + Name string `json:"name" yaml:"name"` + Description string `json:"description" yaml:"description"` + Comments string `json:"comments" yaml:"comments"` + Tags []Ref `json:"tags" yaml:"tags"` + CriteriaTags []Ref `json:"criteriaTags" yaml:"criteriaTags"` + Stakeholders []Ref `json:"stakeholders" yaml:"stakeholders"` + StakeholderGroups []Ref `json:"stakeholderGroups" yaml:"stakeholderGroups"` + Applications []Ref `json:"applications" yaml:"applications"` + Assessments []Ref `json:"assessments" yaml:"assessments"` +} + +// +// With updates the resource with the model. +func (r *Archetype) With(m *model.Archetype) { + r.Resource.With(&m.Model) + r.Name = m.Name + r.Description = m.Description + r.Comments = m.Comments + r.Tags = []Ref{} + for _, t := range m.Tags { + r.Tags = append(r.Tags, r.ref(t.ID, &t)) + } + r.CriteriaTags = []Ref{} + for _, t := range m.CriteriaTags { + r.CriteriaTags = append(r.CriteriaTags, r.ref(t.ID, &t)) + } + r.Stakeholders = []Ref{} + for _, s := range m.Stakeholders { + r.Stakeholders = append(r.Stakeholders, r.ref(s.ID, &s)) + } + r.StakeholderGroups = []Ref{} + for _, g := range m.StakeholderGroups { + r.StakeholderGroups = append(r.StakeholderGroups, r.ref(g.ID, &g)) + } + r.Assessments = []Ref{} + for _, a := range m.Assessments { + r.Assessments = append(r.Assessments, r.ref(a.ID, &a)) + } +} + +// +// WithApplications updates the Archetype resource with the applications. +func (r *Archetype) WithApplications(apps []model.Application) { + for i := range apps { + ref := Ref{} + ref.With(apps[i].ID, apps[i].Name) + r.Applications = append(r.Applications, ref) + } +} + +// +// Model builds a model from the resource. +func (r *Archetype) Model() (m *model.Archetype) { + m = &model.Archetype{ + Name: r.Name, + Description: r.Description, + Comments: r.Comments, + } + m.ID = r.ID + for _, ref := range r.Tags { + m.Tags = append( + m.Tags, + model.Tag{ + Model: model.Model{ + ID: ref.ID, + }, + }) + } + for _, ref := range r.CriteriaTags { + m.CriteriaTags = append( + m.CriteriaTags, + model.Tag{ + Model: model.Model{ + ID: ref.ID, + }, + }) + } + for _, ref := range r.Stakeholders { + m.Stakeholders = append( + m.Stakeholders, + model.Stakeholder{ + Model: model.Model{ + ID: ref.ID, + }, + }) + } + for _, ref := range r.StakeholderGroups { + m.StakeholderGroups = append( + m.StakeholderGroups, + model.StakeholderGroup{ + Model: model.Model{ + ID: ref.ID, + }, + }) + } + + return +} diff --git a/api/assessment.go b/api/assessment.go new file mode 100644 index 000000000..5eb56f92f --- /dev/null +++ b/api/assessment.go @@ -0,0 +1,292 @@ +package api + +import ( + "encoding/json" + "github.com/gin-gonic/gin" + "github.com/konveyor/tackle2-hub/assessment" + "github.com/konveyor/tackle2-hub/model" + "gorm.io/gorm/clause" + "net/http" +) + +// +// Routes +const ( + AssessmentsRoot = "/assessments" + AssessmentRoot = AssessmentsRoot + "/:" + ID +) + +// +// Assessment status +const ( + AssessmentEmpty = "empty" + AssessmentStarted = "started" + AssessmentComplete = "complete" +) + +// +// Assessment risks +const ( + RiskRed = "red" + RiskYellow = "yellow" + RiskGreen = "green" + RiskUnknown = "unknown" +) + +// +// AssessmentHandler handles Assessment resource routes. +type AssessmentHandler struct { + BaseHandler +} + +// +// AddRoutes adds routes. +func (h AssessmentHandler) AddRoutes(e *gin.Engine) { + routeGroup := e.Group("/") + routeGroup.Use(Required("assessments"), Transaction) + routeGroup.GET(AssessmentsRoot, h.List) + routeGroup.GET(AssessmentsRoot+"/", h.List) + routeGroup.GET(AssessmentRoot, h.Get) + routeGroup.PUT(AssessmentRoot, h.Update) + routeGroup.DELETE(AssessmentRoot, h.Delete) +} + +// Get godoc +// @summary Get an assessment by ID. +// @description Get an assessment by ID. +// @tags questionnaires +// @produce json +// @success 200 {object} api.Assessment +// @router /assessments/{id} [get] +// @param id path string true "Assessment ID" +func (h AssessmentHandler) Get(ctx *gin.Context) { + m := &model.Assessment{} + id := h.pk(ctx) + db := h.preLoad(h.DB(ctx), clause.Associations) + result := db.First(m, id) + if result.Error != nil { + _ = ctx.Error(result.Error) + return + } + r := Assessment{} + r.With(m) + + h.Respond(ctx, http.StatusOK, r) +} + +// List godoc +// @summary List all assessments. +// @description List all assessments. +// @tags assessments +// @produce json +// @success 200 {object} []api.Assessment +// @router /assessments [get] +func (h AssessmentHandler) List(ctx *gin.Context) { + var list []model.Assessment + db := h.preLoad(h.DB(ctx), clause.Associations) + result := db.Find(&list) + if result.Error != nil { + _ = ctx.Error(result.Error) + return + } + resources := []Assessment{} + for i := range list { + r := Assessment{} + r.With(&list[i]) + resources = append(resources, r) + } + + h.Respond(ctx, http.StatusOK, resources) +} + +// Delete godoc +// @summary Delete an assessment. +// @description Delete an assessment. +// @tags assessments +// @success 204 +// @router /assessments/{id} [delete] +// @param id path string true "Assessment ID" +func (h AssessmentHandler) Delete(ctx *gin.Context) { + id := h.pk(ctx) + m := &model.Assessment{} + result := h.DB(ctx).First(m, id) + if result.Error != nil { + _ = ctx.Error(result.Error) + return + } + result = h.DB(ctx).Delete(m) + if result.Error != nil { + _ = ctx.Error(result.Error) + return + } + + h.Status(ctx, http.StatusNoContent) +} + +// Update godoc +// @summary Update an assessment. +// @description Update an assessment. +// @tags assessments +// @accept json +// @success 204 +// @router /assessments/{id} [put] +// @param id path string true "Assessment ID" +// @param assessment body api.Assessment true "Assessment data" +func (h AssessmentHandler) Update(ctx *gin.Context) { + id := h.pk(ctx) + r := &Assessment{} + err := h.Bind(ctx, r) + if err != nil { + _ = ctx.Error(err) + return + } + m := r.Model() + m.ID = id + m.UpdateUser = h.CurrentUser(ctx) + db := h.DB(ctx).Model(m) + db = db.Omit(clause.Associations) + result := db.Updates(h.fields(m)) + if result.Error != nil { + _ = ctx.Error(result.Error) + return + } + err = h.DB(ctx).Model(m).Association("Stakeholders").Replace("Stakeholders", m.Stakeholders) + if err != nil { + _ = ctx.Error(err) + return + } + err = h.DB(ctx).Model(m).Association("StakeholderGroups").Replace("StakeholderGroups", m.StakeholderGroups) + if err != nil { + _ = ctx.Error(err) + return + } + + h.Status(ctx, http.StatusNoContent) +} + +// +// Assessment REST resource. +type Assessment struct { + Resource + Application *Ref `json:"application,omitempty" yaml:",omitempty" binding:"excluded_with=Archetype"` + Archetype *Ref `json:"archetype,omitempty" yaml:",omitempty" binding:"excluded_with=Application"` + Questionnaire Ref `json:"questionnaire" binding:"required"` + Sections []assessment.Section `json:"sections"` + Stakeholders []Ref `json:"stakeholders"` + StakeholderGroups []Ref `json:"stakeholderGroups"` + // read only + Risk string `json:"risk"` + Confidence int `json:"confidence"` + Status string `json:"status"` + Thresholds assessment.Thresholds `json:"thresholds"` + RiskMessages assessment.RiskMessages `json:"riskMessages"` +} + +// +// With updates the resource with the model. +func (r *Assessment) With(m *model.Assessment) { + r.Resource.With(&m.Model) + r.Questionnaire = r.ref(m.QuestionnaireID, &m.Questionnaire) + r.Archetype = r.refPtr(m.ArchetypeID, m.Archetype) + r.Application = r.refPtr(m.ApplicationID, m.Application) + _ = json.Unmarshal(m.Sections, &r.Sections) + _ = json.Unmarshal(m.Thresholds, &r.Thresholds) + _ = json.Unmarshal(m.RiskMessages, &r.RiskMessages) + r.Stakeholders = []Ref{} + for _, s := range m.Stakeholders { + ref := Ref{} + ref.With(s.ID, s.Name) + r.Stakeholders = append(r.Stakeholders, ref) + } + r.StakeholderGroups = []Ref{} + for _, sg := range m.StakeholderGroups { + ref := Ref{} + ref.With(sg.ID, sg.Name) + r.StakeholderGroups = append(r.StakeholderGroups, ref) + } + if r.Complete() { + r.Status = AssessmentComplete + } else if r.Started() { + r.Status = AssessmentStarted + } else { + r.Status = AssessmentEmpty + } + r.Risk = r.RiskLevel() + r.Confidence = assessment.Confidence(r.Sections) +} + +// +// Model builds a model. +func (r *Assessment) Model() (m *model.Assessment) { + m = &model.Assessment{} + m.ID = r.ID + m.Sections, _ = json.Marshal(r.Sections) + m.QuestionnaireID = r.Questionnaire.ID + if r.Archetype != nil { + m.ArchetypeID = &r.Archetype.ID + } + if r.Application != nil { + m.ApplicationID = &r.Application.ID + } + for _, ref := range r.Stakeholders { + m.Stakeholders = append( + m.Stakeholders, + model.Stakeholder{ + Model: model.Model{ID: ref.ID}, + }) + } + for _, ref := range r.StakeholderGroups { + m.StakeholderGroups = append( + m.StakeholderGroups, + model.StakeholderGroup{ + Model: model.Model{ID: ref.ID}, + }) + } + return +} + +func (r *Assessment) RiskLevel() string { + var total uint + colors := make(map[string]uint) + for _, s := range r.Sections { + for _, risk := range s.Risks() { + colors[risk]++ + total++ + } + } + if total == 0 { + return RiskGreen + } + if (float64(colors[RiskRed]) / float64(total)) >= (float64(r.Thresholds.Red) / float64(100)) { + return RiskRed + } + if (float64(colors[RiskYellow]) / float64(total)) >= (float64(r.Thresholds.Yellow) / float64(100)) { + return RiskYellow + } + if (float64(colors[RiskUnknown]) / float64(total)) >= (float64(r.Thresholds.Unknown) / float64(100)) { + return RiskUnknown + } + return RiskGreen +} + +// +// Complete returns whether all sections have been completed. +func (r *Assessment) Complete() bool { + for _, s := range r.Sections { + if !s.Complete() { + return false + } + } + return true +} + +// +// Started returns whether any sections have been started. +func (r *Assessment) Started() bool { + for _, s := range r.Sections { + if s.Started() { + return true + } + } + return false +} diff --git a/api/base.go b/api/base.go index 31a63eb9b..1d2930b27 100644 --- a/api/base.go +++ b/api/base.go @@ -4,9 +4,11 @@ import ( "bytes" "database/sql" "encoding/json" + "errors" "fmt" "github.com/gin-gonic/gin" "github.com/gin-gonic/gin/binding" + liberr "github.com/jortel/go-utils/error" "github.com/jortel/go-utils/logr" "github.com/konveyor/tackle2-hub/api/reflect" "github.com/konveyor/tackle2-hub/api/sort" @@ -166,9 +168,9 @@ func (h *BaseHandler) Bind(ctx *gin.Context, r interface{}) (err error) { case "", binding.MIMEPOSTForm, binding.MIMEJSON: - err = ctx.BindJSON(r) + err = h.BindJSON(ctx, r) case binding.MIMEYAML: - err = ctx.BindYAML(r) + err = h.BindYAML(ctx, r) default: err = &BadRequestError{"Bind: MIME not supported."} } @@ -178,6 +180,57 @@ func (h *BaseHandler) Bind(ctx *gin.Context, r interface{}) (err error) { return } +// +// BindJSON attempts to bind a request body to a struct, assuming that the body is JSON. +// Binding is strict: unknown fields in the input will cause binding to fail. +func (h *BaseHandler) BindJSON(ctx *gin.Context, r interface{}) (err error) { + if ctx.Request == nil || ctx.Request.Body == nil { + err = errors.New("invalid request") + return + } + decoder := json.NewDecoder(ctx.Request.Body) + decoder.DisallowUnknownFields() + err = decoder.Decode(r) + if err != nil { + err = liberr.Wrap(err) + return + } + err = h.Validate(r) + return +} + +// +// BindYAML attempts to bind a request body to a struct, assuming that the body is YAML. +// Binding is strict: unknown fields in the input will cause binding to fail. +func (h *BaseHandler) BindYAML(ctx *gin.Context, r interface{}) (err error) { + if ctx.Request == nil || ctx.Request.Body == nil { + err = errors.New("invalid request") + return + } + decoder := yaml.NewDecoder(ctx.Request.Body) + decoder.SetStrict(true) + err = decoder.Decode(r) + if err != nil { + err = liberr.Wrap(err) + return + } + err = h.Validate(r) + return +} + +// +// Validate that the struct field values obey the binding field tags. +func (h *BaseHandler) Validate(r interface{}) (err error) { + if binding.Validator == nil { + return + } + err = binding.Validator.ValidateStruct(r) + if err != nil { + err = liberr.Wrap(err) + } + return +} + // // Decoder returns a decoder based on encoding. // Opinionated towards json. diff --git a/api/pathfinder.go b/api/pathfinder.go deleted file mode 100644 index d87246c16..000000000 --- a/api/pathfinder.go +++ /dev/null @@ -1,129 +0,0 @@ -package api - -import ( - "bytes" - "encoding/json" - "github.com/gin-gonic/gin" - "github.com/gin-gonic/gin/binding" - liberr "github.com/jortel/go-utils/error" - "github.com/konveyor/tackle2-hub/metrics" - "io" - "net/http" - "net/http/httputil" - "net/url" - "os" - "path" - "strconv" -) - -// -// Routes -const ( - PathfinderRoot = "/pathfinder" - AssessmentsRoot = "assessments" - AssessmentsRootX = AssessmentsRoot + "/*" + Wildcard -) - -// -// PathfinderHandler handles assessment routes. -type PathfinderHandler struct { - BaseHandler -} - -// -// AddRoutes adds routes. -func (h PathfinderHandler) AddRoutes(e *gin.Engine) { - routeGroup := e.Group(PathfinderRoot) - routeGroup.Use(Required(AssessmentsRoot)) - routeGroup.Any(AssessmentsRoot, h.ReverseProxy) - routeGroup.Any(AssessmentsRootX, h.ReverseProxy) -} - -// Get godoc -// @summary ReverseProxy - forward to pathfinder. -// @description ReverseProxy forwards API calls to pathfinder API. -func (h PathfinderHandler) ReverseProxy(ctx *gin.Context) { - pathfinder := os.Getenv("PATHFINDER_URL") - target, _ := url.Parse(pathfinder) - proxy := httputil.ReverseProxy{ - Director: func(req *http.Request) { - req.URL.Scheme = target.Scheme - req.URL.Host = target.Host - }, - } - - if ctx.Request.URL.Path == path.Join(PathfinderRoot, AssessmentsRoot) && ctx.Request.Method == http.MethodPost { - metrics.AssessmentsInitiated.Inc() - } - - proxy.ServeHTTP(ctx.Writer, ctx.Request) -} - -// -// Pathfinder client. -type Pathfinder struct { -} - -// -// DeleteAssessment deletes associated assessments by application Ids. -func (r *Pathfinder) DeleteAssessment(ids []uint, ctx *gin.Context) (err error) { - if Settings.Disconnected { - return - } - client := r.client() - body := map[string][]uint{"applicationIds": ids} - b, _ := json.Marshal(body) - header := http.Header{ - Authorization: ctx.Request.Header[Authorization], - ContentLength: []string{strconv.Itoa(len(b))}, - ContentType: []string{binding.MIMEJSON}, - } - request := r.request( - http.MethodDelete, - "bulkDelete", - header) - reader := bytes.NewReader(b) - request.Body = io.NopCloser(reader) - result, err := client.Do(request) - if err != nil { - return - } - status := result.StatusCode - switch status { - case http.StatusNoContent: - Log.Info( - "Assessment(s) deleted for applications.", - "Ids", - ids) - default: - err = liberr.New(http.StatusText(status)) - } - - return -} - -// -// Build the client. -func (r *Pathfinder) client() (client *http.Client) { - client = &http.Client{ - Transport: &http.Transport{DisableKeepAlives: true}, - } - return -} - -// -// Build the request -func (r *Pathfinder) request(method, endpoint string, header http.Header) (request *http.Request) { - u, _ := url.Parse(os.Getenv("PATHFINDER_URL")) - u.Path = path.Join( - u.Path, - PathfinderRoot, - AssessmentsRoot, - endpoint) - request = &http.Request{ - Method: method, - Header: header, - URL: u, - } - return -} diff --git a/api/pkg.go b/api/pkg.go index b5b3e7b0f..691c5da1f 100644 --- a/api/pkg.go +++ b/api/pkg.go @@ -79,7 +79,6 @@ func All() []Handler { &TagCategoryHandler{}, &TaskHandler{}, &TaskGroupHandler{}, - &PathfinderHandler{}, &TicketHandler{}, &TrackerHandler{}, &BucketHandler{}, @@ -87,6 +86,9 @@ func All() []Handler { &MigrationWaveHandler{}, &BatchHandler{}, &TargetHandler{}, + &QuestionnaireHandler{}, + &AssessmentHandler{}, + &ArchetypeHandler{}, } } diff --git a/api/questionnaire.go b/api/questionnaire.go new file mode 100644 index 000000000..890ffb2d2 --- /dev/null +++ b/api/questionnaire.go @@ -0,0 +1,200 @@ +package api + +import ( + "encoding/json" + "github.com/gin-gonic/gin" + "github.com/konveyor/tackle2-hub/assessment" + "github.com/konveyor/tackle2-hub/model" + "gorm.io/gorm/clause" + "net/http" +) + +// Routes +const ( + QuestionnairesRoot = "/questionnaires" + QuestionnaireRoot = QuestionnairesRoot + "/:" + ID +) + +// QuestionnaireHandler handles Questionnaire resource routes. +type QuestionnaireHandler struct { + BaseHandler +} + +// AddRoutes adds routes. +func (h QuestionnaireHandler) AddRoutes(e *gin.Engine) { + routeGroup := e.Group("/") + routeGroup.Use(Required("questionnaires"), Transaction) + routeGroup.GET(QuestionnairesRoot, h.List) + routeGroup.GET(QuestionnairesRoot+"/", h.List) + routeGroup.POST(QuestionnairesRoot, h.Create) + routeGroup.GET(QuestionnaireRoot, h.Get) + routeGroup.PUT(QuestionnaireRoot, h.Update) + routeGroup.DELETE(QuestionnaireRoot, h.Delete) +} + +// Get godoc +// @summary Get a questionnaire by ID. +// @description Get a questionnaire by ID. +// @tags questionnaires +// @produce json +// @success 200 {object} api.Questionnaire +// @router /questionnaires/{id} [get] +// @param id path string true "Questionnaire ID" +func (h QuestionnaireHandler) Get(ctx *gin.Context) { + m := &model.Questionnaire{} + id := h.pk(ctx) + db := h.preLoad(h.DB(ctx), clause.Associations) + result := db.First(m, id) + if result.Error != nil { + _ = ctx.Error(result.Error) + return + } + r := Questionnaire{} + r.With(m) + + h.Respond(ctx, http.StatusOK, r) +} + +// List godoc +// @summary List all questionnaires. +// @description List all questionnaires. +// @tags questionnaires +// @produce json +// @success 200 {object} []api.Questionnaire +// @router /questionnaires [get] +func (h QuestionnaireHandler) List(ctx *gin.Context) { + var list []model.Questionnaire + db := h.preLoad(h.DB(ctx), clause.Associations) + result := db.Find(&list) + if result.Error != nil { + _ = ctx.Error(result.Error) + return + } + resources := []Questionnaire{} + for i := range list { + r := Questionnaire{} + r.With(&list[i]) + resources = append(resources, r) + } + + h.Respond(ctx, http.StatusOK, resources) +} + +// Create godoc +// @summary Create a questionnaire. +// @description Create a questionnaire. +// @tags questionnaires +// @accept json +// @produce json +// @success 200 {object} api.Questionnaire +// @router /questionnaires [post] +// @param questionnaire body api.Questionnaire true "Questionnaire data" +func (h QuestionnaireHandler) Create(ctx *gin.Context) { + r := &Questionnaire{} + err := h.Bind(ctx, r) + if err != nil { + _ = ctx.Error(err) + return + } + m := r.Model() + m.CreateUser = h.CurrentUser(ctx) + result := h.DB(ctx).Create(m) + if result.Error != nil { + _ = ctx.Error(result.Error) + return + } + r.With(m) + + h.Respond(ctx, http.StatusCreated, r) +} + +// Delete godoc +// @summary Delete a questionnaire. +// @description Delete a questionnaire. +// @tags questionnaires +// @success 204 +// @router /questionnaires/{id} [delete] +// @param id path string true "Questionnaire ID" +func (h QuestionnaireHandler) Delete(ctx *gin.Context) { + id := h.pk(ctx) + m := &model.Questionnaire{} + result := h.DB(ctx).First(m, id) + if result.Error != nil { + _ = ctx.Error(result.Error) + return + } + result = h.DB(ctx).Delete(m) + if result.Error != nil { + _ = ctx.Error(result.Error) + return + } + + h.Status(ctx, http.StatusNoContent) +} + +// Update godoc +// @summary Update a questionnaire. +// @description Update a questionnaire. +// @tags questionnaires +// @accept json +// @success 204 +// @router /questionnaires/{id} [put] +// @param id path string true "Questionnaire ID" +// @param questionnaire body api.Questionnaire true "Questionnaire data" +func (h QuestionnaireHandler) Update(ctx *gin.Context) { + id := h.pk(ctx) + r := &Questionnaire{} + err := h.Bind(ctx, r) + if err != nil { + _ = ctx.Error(err) + return + } + m := r.Model() + m.ID = id + m.UpdateUser = h.CurrentUser(ctx) + db := h.DB(ctx).Model(m) + db = db.Omit(clause.Associations) + result := db.Updates(h.fields(m)) + if result.Error != nil { + _ = ctx.Error(result.Error) + return + } + + h.Status(ctx, http.StatusNoContent) +} + +type Questionnaire struct { + Resource + Name string `json:"name" yaml:"name" binding:"required"` + Description string `json:"description" yaml:"description"` + Required bool `json:"required" yaml:"required"` + Sections []assessment.Section `json:"sections" yaml:"sections" binding:"required"` + Thresholds assessment.Thresholds `json:"thresholds" yaml:"thresholds" binding:"required"` + RiskMessages assessment.RiskMessages `json:"riskMessages" yaml:"riskMessages" binding:"required"` +} + +// With updates the resource with the model. +func (r *Questionnaire) With(m *model.Questionnaire) { + r.Resource.With(&m.Model) + r.Name = m.Name + r.Description = m.Description + r.Required = m.Required + _ = json.Unmarshal(m.Sections, &r.Sections) + _ = json.Unmarshal(m.Thresholds, &r.Thresholds) + _ = json.Unmarshal(m.RiskMessages, &r.RiskMessages) +} + +// Model builds a model. +func (r *Questionnaire) Model() (m *model.Questionnaire) { + m = &model.Questionnaire{ + Name: r.Name, + Description: r.Description, + Required: r.Required, + } + m.ID = r.ID + m.Sections, _ = json.Marshal(r.Sections) + m.Thresholds, _ = json.Marshal(r.Thresholds) + m.RiskMessages, _ = json.Marshal(r.RiskMessages) + + return +} diff --git a/api/review.go b/api/review.go index 4681aae7c..d94e307f4 100644 --- a/api/review.go +++ b/api/review.go @@ -96,6 +96,7 @@ func (h ReviewHandler) Create(ctx *gin.Context) { review := Review{} err := h.Bind(ctx, &review) if err != nil { + _ = ctx.Error(err) return } m := review.Model() @@ -193,7 +194,7 @@ func (h ReviewHandler) CopyReview(ctx *gin.Context) { ProposedAction: m.ProposedAction, WorkPriority: m.WorkPriority, Comments: m.Comments, - ApplicationID: id, + ApplicationID: &id, } existing := []model.Review{} result = h.DB(ctx).Find(&existing, "applicationid = ?", id) @@ -229,7 +230,8 @@ type Review struct { ProposedAction string `json:"proposedAction"` WorkPriority uint `json:"workPriority"` Comments string `json:"comments"` - Application Ref `json:"application" binding:"required"` + Application *Ref `json:"application,omitempty" binding:"required_without=Archetype,excluded_with=Archetype"` + Archetype *Ref `json:"archetype,omitempty" binding:"required_without=Application,excluded_with=Application"` } // With updates the resource with the model. @@ -240,7 +242,8 @@ func (r *Review) With(m *model.Review) { r.ProposedAction = m.ProposedAction r.WorkPriority = m.WorkPriority r.Comments = m.Comments - r.Application = r.ref(m.ApplicationID, m.Application) + r.Application = r.refPtr(m.ApplicationID, m.Application) + r.Archetype = r.refPtr(m.ArchetypeID, m.Archetype) } // @@ -252,9 +255,13 @@ func (r *Review) Model() (m *model.Review) { ProposedAction: r.ProposedAction, WorkPriority: r.WorkPriority, Comments: r.Comments, - ApplicationID: r.Application.ID, } m.ID = r.ID + if r.Application != nil { + m.ApplicationID = &r.Application.ID + } else if r.Archetype != nil { + m.ArchetypeID = &r.Archetype.ID + } return } diff --git a/assessment/application.go b/assessment/application.go new file mode 100644 index 000000000..30406ab49 --- /dev/null +++ b/assessment/application.go @@ -0,0 +1,114 @@ +package assessment + +import ( + "github.com/konveyor/tackle2-hub/model" +) + +// +// NewApplicationResolver creates a new ApplicationResolver from an application and other shared resolvers. +func NewApplicationResolver(app *model.Application, tags *TagResolver, membership *MembershipResolver, questionnaire *QuestionnaireResolver) (a *ApplicationResolver) { + a = &ApplicationResolver{ + application: app, + tagResolver: tags, + membershipResolver: membership, + questionnaireResolver: questionnaire, + } + return +} + +// +// ApplicationResolver wraps an Application model +// with archetype and assessment resolution behavior. +type ApplicationResolver struct { + application *model.Application + archetypes []model.Archetype + tagResolver *TagResolver + membershipResolver *MembershipResolver + questionnaireResolver *QuestionnaireResolver +} + +// +// Archetypes returns the list of archetypes the application is a member of. +func (r *ApplicationResolver) Archetypes() (archetypes []model.Archetype, err error) { + if len(r.archetypes) > 0 { + archetypes = r.archetypes + return + } + + archetypes, err = r.membershipResolver.Archetypes(r.application) + return +} + +// +// ArchetypeTags returns the list of tags that the application should inherit from the archetypes it is a member of, +// including any tags that would be inherited due to answers given to the archetypes' assessments. +func (r *ApplicationResolver) ArchetypeTags() (tags []model.Tag, err error) { + archetypes, err := r.Archetypes() + if err != nil { + return + } + + seenTags := make(map[uint]bool) + for _, a := range archetypes { + for _, t := range a.Tags { + if _, found := seenTags[t.ID]; !found { + seenTags[t.ID] = true + tags = append(tags, t) + } + } + // if an application has any of its own assessments then it should not + // inherit assessment tags from any of its archetypes. + if len(r.application.Assessments) == 0 { + for _, assessment := range a.Assessments { + aTags := r.tagResolver.Assessment(&assessment) + for _, t := range aTags { + if _, found := seenTags[t.ID]; !found { + seenTags[t.ID] = true + tags = append(tags, t) + } + } + } + } + } + return +} + +// +// AssessmentTags returns the list of tags that the application should inherit from the answers given +// to its assessments. +func (r *ApplicationResolver) AssessmentTags() (tags []model.Tag) { + seenTags := make(map[uint]bool) + for _, assessment := range r.application.Assessments { + aTags := r.tagResolver.Assessment(&assessment) + for _, t := range aTags { + if _, found := seenTags[t.ID]; !found { + seenTags[t.ID] = true + tags = append(tags, t) + } + } + } + return +} + +// +// Assessed returns whether the application has been fully assessed. +func (r *ApplicationResolver) Assessed() (assessed bool, err error) { + // if the application has any of its own assessments, only consider them for + // determining whether it has been assessed. + if len(r.application.Assessments) > 0 { + assessed = r.questionnaireResolver.Assessed(r.application.Assessments) + return + } + // otherwise the application is assessed if all of its archetypes are fully assessed. + archetypes, err := r.Archetypes() + if err != nil { + return + } + for _, a := range archetypes { + if !r.questionnaireResolver.Assessed(a.Assessments) { + return + } + } + assessed = true + return +} diff --git a/assessment/assessment_test.go b/assessment/assessment_test.go new file mode 100644 index 000000000..7ae3e825b --- /dev/null +++ b/assessment/assessment_test.go @@ -0,0 +1,92 @@ +package assessment + +import ( + "github.com/konveyor/tackle2-hub/model" + "github.com/onsi/gomega" + "testing" +) + +func TestPrepareSections(t *testing.T) { + g := gomega.NewGomegaWithT(t) + + sections := []Section{ + { + Questions: []Question{ + { + Text: "Default", + Answers: []Answer{ + { + Text: "Answer1", + }, + { + Text: "Answer2", + }, + }, + }, + { + Text: "Should Include", + IncludeFor: []CategorizedTag{ + {Category: "Category", Tag: "Tag"}, + }, + Answers: []Answer{ + { + Text: "Answer1", + }, + { + Text: "Answer2", + }, + }, + }, + { + Text: "Should Exclude", + ExcludeFor: []CategorizedTag{ + {Category: "Category", Tag: "Tag"}, + }, + Answers: []Answer{ + { + Text: "Answer1", + }, + { + Text: "Answer2", + }, + }, + }, + { + Text: "AutoAnswer", + Answers: []Answer{ + { + Text: "Answer1", + AutoAnswerFor: []CategorizedTag{ + {Category: "Category", Tag: "Tag"}, + }, + }, + { + Text: "Answer2", + }, + }, + }, + }, + }, + } + tagResolver := TagResolver{ + cache: map[string]map[string]*model.Tag{ + "Category": {"Tag": {Model: model.Model{ID: 1}}}, + }, + } + tags := NewSet() + tags.Add(1) + + preparedSections := prepareSections(&tagResolver, tags, sections) + questions := preparedSections[0].Questions + + g.Expect(len(questions)).To(gomega.Equal(3)) + g.Expect(questions[0].Text).To(gomega.Equal("Default")) + g.Expect(questions[0].Answered()).To(gomega.BeFalse()) + g.Expect(questions[1].Text).To(gomega.Equal("Should Include")) + g.Expect(questions[1].Answered()).To(gomega.BeFalse()) + g.Expect(questions[2].Text).To(gomega.Equal("AutoAnswer")) + g.Expect(questions[2].Answered()).To(gomega.BeTrue()) + g.Expect(questions[2].Answers[0].Text).To(gomega.Equal("Answer1")) + g.Expect(questions[2].Answers[0].AutoAnswered).To(gomega.BeTrue()) + g.Expect(questions[2].Answers[0].Selected).To(gomega.BeTrue()) +} \ No newline at end of file diff --git a/assessment/membership.go b/assessment/membership.go new file mode 100644 index 000000000..1ec0dde74 --- /dev/null +++ b/assessment/membership.go @@ -0,0 +1,126 @@ +package assessment + +import ( + liberr "github.com/jortel/go-utils/error" + "github.com/konveyor/tackle2-hub/model" + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +// +// NewMembershipResolver builds a MembershipResolver. +func NewMembershipResolver(db *gorm.DB) (m *MembershipResolver) { + m = &MembershipResolver{db: db} + m.tagSets = make(map[uint]Set) + m.archetypeMembers = make(map[uint][]model.Application) + return +} + +// +// MembershipResolver resolves archetype membership. +type MembershipResolver struct { + db *gorm.DB + archetypes []model.Archetype + tagSets map[uint]Set + archetypeMembers map[uint][]model.Application + membersCached bool +} + +// +// Applications returns the list of applications that are members of the given archetype. +func (r *MembershipResolver) Applications(m *model.Archetype) (applications []model.Application, err error) { + err = r.cacheArchetypeMembers() + if err != nil { + return + } + + applications = r.archetypeMembers[m.ID] + + return +} + +// +// Archetypes returns the list of archetypes that the application is a member of. +func (r *MembershipResolver) Archetypes(m *model.Application) (archetypes []model.Archetype, err error) { + err = r.cacheArchetypes() + if err != nil { + return + } + + appTags := NewSet() + for _, t := range m.Tags { + appTags.Add(t.ID) + } + + matches := []model.Archetype{} + for _, a := range r.archetypes { + if appTags.Superset(r.tagSets[a.ID]) { + matches = append(matches, a) + } + } + + // throw away any archetypes that are a subset of + // another archetype-- only keep the most specific matches. + loop: + for _, a1 := range matches { + for _, a2 := range matches{ + if a1.ID == a2.ID { + continue + } + a1tags := r.tagSets[a1.ID] + a2tags := r.tagSets[a2.ID] + if a1tags.Subset(a2tags) { + continue loop + } + } + archetypes = append(archetypes, a1) + r.archetypeMembers[a1.ID] = append(r.archetypeMembers[a1.ID], *m) + } + + return +} + +func (r *MembershipResolver) cacheArchetypes() (err error) { + if len(r.archetypes) > 0 { + return + } + + db := r.db.Preload(clause.Associations) + result := db.Find(&r.archetypes) + if result.Error != nil { + err = liberr.Wrap(err) + return + } + + for _, a := range r.archetypes { + set := NewSet() + for _, t := range a.CriteriaTags { + set.Add(t.ID) + } + r.tagSets[a.ID] = set + } + + return +} + +func (r *MembershipResolver) cacheArchetypeMembers() (err error) { + if r.membersCached { + return + } + allApplications := []model.Application{} + result := r.db.Preload("Tags").Find(&allApplications) + if result.Error != nil { + err = liberr.Wrap(err) + return + } + for _, a := range allApplications { + _, aErr := r.Archetypes(&a) + if aErr != nil { + err = aErr + return + } + } + r.membersCached = true + + return +} diff --git a/assessment/pkg.go b/assessment/pkg.go new file mode 100644 index 000000000..dadb144bc --- /dev/null +++ b/assessment/pkg.go @@ -0,0 +1,165 @@ +package assessment + +import ( + "encoding/json" + "github.com/konveyor/tackle2-hub/model" + "math" +) + +// +// Assessment risk +const ( + RiskUnknown = "unknown" + RiskRed = "red" + RiskYellow = "yellow" + RiskGreen = "green" +) + +// +// Confidence adjustment +const ( + AdjusterRed = 0.5 + AdjusterYellow = 0.98 +) + +// +// Confidence multiplier. +const ( + MultiplierRed = 0.6 + MultiplierYellow = 0.95 +) + +// +// Risk weights +const ( + WeightRed = 1 + WeightYellow = 80 + WeightGreen = 100 + WeightUnknown = 70 +) + +// +// Confidence calculates a confidence score based on the answers to an assessment's questions. +// The algorithm is a reimplementation of the calculation done by Pathfinder. +func Confidence(sections []Section) (score int) { + totalQuestions := 0 + riskCounts := make(map[string]int) + for _, s := range sections { + for _, r := range s.Risks() { + riskCounts[r]++ + totalQuestions++ + } + } + adjuster := 1.0 + if riskCounts[RiskRed] > 0 { + adjuster = adjuster * math.Pow(AdjusterRed, float64(riskCounts[RiskRed])) + } + if riskCounts[RiskYellow] > 0 { + adjuster = adjuster * math.Pow(AdjusterYellow, float64(riskCounts[RiskYellow])) + } + confidence := 0.0 + for i := 0; i < riskCounts[RiskRed]; i++ { + confidence *= MultiplierRed + confidence += WeightRed * adjuster + } + for i := 0; i < riskCounts[RiskYellow]; i++ { + confidence *= MultiplierYellow + confidence += WeightYellow * adjuster + } + confidence += float64(riskCounts[RiskGreen]) * WeightGreen * adjuster + confidence += float64(riskCounts[RiskUnknown]) * WeightUnknown * adjuster + + maxConfidence := WeightGreen * totalQuestions + score = int(confidence / float64(maxConfidence) * 100) + + return +} + +// +// PrepareForApplication prepares the sections of an assessment by including, excluding, +// or auto-answering questions based on a set of tags. +func PrepareForApplication(tagResolver *TagResolver, application *model.Application, assessment *model.Assessment) { + sections := []Section{} + _ = json.Unmarshal(assessment.Sections, §ions) + + tagSet := NewSet() + for _, t := range application.Tags { + tagSet.Add(t.ID) + } + + assessment.Sections, _ = json.Marshal(prepareSections(tagResolver, tagSet, sections)) + + return +} + +// +// PrepareForArchetype prepares the sections of an assessment by including, excluding, +// or auto-answering questions based on a set of tags. +func PrepareForArchetype(tagResolver *TagResolver, archetype *model.Archetype, assessment *model.Assessment) { + sections := []Section{} + _ = json.Unmarshal(assessment.Sections, §ions) + + tagSet := NewSet() + for _, t := range archetype.CriteriaTags { + tagSet.Add(t.ID) + } + + assessment.Sections, _ = json.Marshal(prepareSections(tagResolver, tagSet, sections)) + + return +} + +func prepareSections(tagResolver *TagResolver, tags Set, sections []Section) (preparedSections []Section) { + for i := range sections { + s := §ions[i] + includedQuestions := []Question{} + for _, q := range s.Questions { + for j := range q.Answers { + a := &q.Answers[j] + autoAnswerTags := NewSet() + for _, t := range a.AutoAnswerFor { + tag, found := tagResolver.Resolve(t.Category, t.Tag) + if found { + autoAnswerTags.Add(tag.ID) + } + } + if tags.Intersects(autoAnswerTags) { + a.AutoAnswered = true + a.Selected = true + break + } + } + + if len(q.IncludeFor) > 0 { + includeForTags := NewSet() + for _, t := range q.IncludeFor { + tag, found := tagResolver.Resolve(t.Category, t.Tag) + if found { + includeForTags.Add(tag.ID) + } + } + if tags.Intersects(includeForTags) { + includedQuestions = append(includedQuestions, q) + } + continue + } + + if len(q.ExcludeFor) > 0 { + excludeForTags := NewSet() + for _, t := range q.ExcludeFor { + tag, found := tagResolver.Resolve(t.Category, t.Tag) + if found { + excludeForTags.Add(tag.ID) + } + } + if tags.Intersects(excludeForTags) { + continue + } + } + includedQuestions = append(includedQuestions, q) + } + s.Questions = includedQuestions + } + preparedSections = sections + return +} diff --git a/assessment/questionnaire.go b/assessment/questionnaire.go new file mode 100644 index 000000000..eecbb6f7a --- /dev/null +++ b/assessment/questionnaire.go @@ -0,0 +1,66 @@ +package assessment + +import ( + "encoding/json" + liberr "github.com/jortel/go-utils/error" + "github.com/konveyor/tackle2-hub/model" + "gorm.io/gorm" +) + +// +// NewQuestionnaireResolver builds a QuestionnaireResolver. +func NewQuestionnaireResolver(db *gorm.DB) (a *QuestionnaireResolver, err error) { + a = &QuestionnaireResolver{db: db} + a.requiredQuestionnaires = NewSet() + err = a.cacheQuestionnaires() + return +} + +// +// QuestionnaireResolver resolves questionnaire logic. +type QuestionnaireResolver struct { + db *gorm.DB + requiredQuestionnaires Set +} + +func (r *QuestionnaireResolver) cacheQuestionnaires() (err error) { + if r.requiredQuestionnaires.Size() > 0 { + return + } + + questionnaires := []model.Questionnaire{} + result := r.db.Find(&questionnaires, "required = ?", true) + if result.Error != nil { + err = liberr.Wrap(err) + return + } + + for _, q := range questionnaires { + r.requiredQuestionnaires.Add(q.ID) + } + + return +} + +// +// Assessed returns whether a slice contains a completed assessment for each of the required +// questionnaires. +func (r *QuestionnaireResolver) Assessed(assessments []model.Assessment) (assessed bool) { + answered := NewSet() + loop: + for _, a := range assessments { + if r.requiredQuestionnaires.Contains(a.QuestionnaireID) { + sections := []Section{} + _ = json.Unmarshal(a.Sections, §ions) + for _, s := range sections { + if !s.Complete() { + continue loop + } + } + answered.Add(a.QuestionnaireID) + } + } + assessed = answered.Superset(r.requiredQuestionnaires) + + return +} \ No newline at end of file diff --git a/assessment/section.go b/assessment/section.go new file mode 100644 index 000000000..18bd22c5e --- /dev/null +++ b/assessment/section.go @@ -0,0 +1,135 @@ +package assessment + +// +// Section represents a group of questions in a questionnaire. +type Section struct { + Order uint `json:"order" yaml:"order"` + Name string `json:"name" yaml:"name"` + Questions []Question `json:"questions" yaml:"questions"` +} + +// +// Complete returns whether all questions in the section have been answered. +func (r *Section) Complete() bool { + for _, q := range r.Questions { + if !q.Answered() { + return false + } + } + return true +} + +// +// Started returns whether any questions in the section have been answered. +func (r *Section) Started() bool { + for _, q := range r.Questions { + if q.Answered() { + return true + } + } + return false +} + +// +// Risks returns a slice of the risks of each of its questions. +func (r *Section) Risks() []string { + risks := []string{} + for _, q := range r.Questions { + risks = append(risks, q.Risk()) + } + return risks +} + +// +// Tags returns all the tags that should be applied based on how +// the questions in the section have been answered. +func (r *Section) Tags() (tags []CategorizedTag) { + for _, q := range r.Questions { + tags = append(tags, q.Tags()...) + } + return +} + +// +// Question represents a question in a questionnaire. +type Question struct { + Order uint `json:"order" yaml:"order"` + Text string `json:"text" yaml:"text"` + Explanation string `json:"explanation" yaml:"explanation"` + IncludeFor []CategorizedTag `json:"includeFor,omitempty" yaml:"includeFor,omitempty" binding:"excluded_with=ExcludeFor"` + ExcludeFor []CategorizedTag `json:"excludeFor,omitempty" yaml:"excludeFor,omitempty" binding:"excluded_with=IncludeFor"` + Answers []Answer `json:"answers" yaml:"answers"` +} + +// +// Risk returns the risk level for the question based on how it has been answered. +func (r *Question) Risk() string { + for _, a := range r.Answers { + if a.Selected { + return a.Risk + } + } + return RiskUnknown +} + +// +// Answered returns whether the question has had an answer selected. +func (r *Question) Answered() bool { + for _, a := range r.Answers { + if a.Selected { + return true + } + } + return false +} + +// +// Tags returns any tags to be applied based on how the question is answered. +func (r *Question) Tags() (tags []CategorizedTag) { + for _, answer := range r.Answers { + if answer.Selected { + tags = answer.ApplyTags + return + } + } + return +} + +// +// Answer represents an answer to a question in a questionnaire. +type Answer struct { + Order uint `json:"order" yaml:"order"` + Text string `json:"text" yaml:"text"` + Risk string `json:"risk" yaml:"risk" binding:"oneof=red,yellow,green,unknown"` + Rationale string `json:"rationale" yaml:"rationale"` + Mitigation string `json:"mitigation" yaml:"mitigation"` + ApplyTags []CategorizedTag `json:"applyTags,omitempty" yaml:"applyTags,omitempty"` + AutoAnswerFor []CategorizedTag `json:"autoAnswerFor,omitempty" yaml:"autoAnswerFor,omitempty"` + Selected bool `json:"selected,omitempty" yaml:"selected,omitempty"` + AutoAnswered bool `json:"autoAnswered,omitempty" yaml:"autoAnswered,omitempty"` +} + +// +// CategorizedTag represents a human-readable pair of category and tag. +type CategorizedTag struct { + Category string `json:"category" yaml:"category"` + Tag string `json:"tag" yaml:"tag"` +} + +// +// RiskMessages contains messages to display for each risk level. +type RiskMessages struct { + Red string `json:"red" yaml:"red"` + Yellow string `json:"yellow" yaml:"yellow"` + Green string `json:"green" yaml:"green"` + Unknown string `json:"unknown" yaml:"unknown"` +} + +// +// Thresholds contains the threshold values for determining risk for the questionnaire. +type Thresholds struct { + Red uint `json:"red" yaml:"red"` + Yellow uint `json:"yellow" yaml:"yellow"` + Unknown uint `json:"unknown" yaml:"unknown"` +} + diff --git a/assessment/set.go b/assessment/set.go new file mode 100644 index 000000000..c71006248 --- /dev/null +++ b/assessment/set.go @@ -0,0 +1,72 @@ +package assessment + +// +// NewSet builds a new Set. +func NewSet() (s Set) { + s = Set{} + s.members = make(map[uint]bool) + return +} + +// +// Set is an unordered collection of uints +// with no duplicate elements. +type Set struct { + members map[uint]bool +} + +// +// Size returns the number of members in the set. +func (r Set) Size() int { + return len(r.members) +} + +// +// Add a member to the set. +func (r Set) Add(member uint) { + r.members[member] = true +} + +// +// Contains returns whether an element is a member of the set. +func (r Set) Contains(element uint) bool { + return r.members[element] +} + +// +// Superset tests whether every element of other is in the set. +func (r Set) Superset(other Set) bool { + for m := range other.members { + if !r.Contains(m) { + return false + } + } + return true +} + +// +// Subset tests whether every element of this set is in the other. +func (r Set) Subset(other Set) bool { + return other.Superset(r) +} + +// +// Intersects tests whether this set and the other have at least one element in common. +func (r Set) Intersects(other Set) bool { + for m := range r.members { + if other.Contains(m) { + return true + } + } + return false +} + +// +// Members returns the members of the set as a slice. +func (r Set) Members() []uint { + members := []uint{} + for k := range r.members { + members = append(members, k) + } + return members +} diff --git a/assessment/tag.go b/assessment/tag.go new file mode 100644 index 000000000..aed421f88 --- /dev/null +++ b/assessment/tag.go @@ -0,0 +1,69 @@ +package assessment + +import ( + "encoding/json" + liberr "github.com/jortel/go-utils/error" + "github.com/konveyor/tackle2-hub/model" + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +// +// NewTagResolver builds a TagResolver. +func NewTagResolver(db *gorm.DB) (t *TagResolver, err error) { + t = &TagResolver{ + db: db, + } + err = t.cacheTags() + return +} + +// +// TagResolver resolves CategorizedTags to Tag models. +type TagResolver struct { + cache map[string]map[string]*model.Tag + db *gorm.DB +} + +// +// Resolve a category and tag name to a Tag model. +func (r *TagResolver) Resolve(category string, tag string) (t *model.Tag, found bool) { + t, found = r.cache[category][tag] + return +} + +// +// Assessment returns all the Tag models that should be applied from the assessment. +func (r *TagResolver) Assessment(assessment *model.Assessment) (tags []model.Tag) { + sections := []Section{} + _ = json.Unmarshal(assessment.Sections, §ions) + for _, s := range sections { + for _, t := range s.Tags() { + tag, found := r.Resolve(t.Category, t.Tag) + if found { + tags = append(tags, *tag) + } + } + } + return +} + +func (r *TagResolver) cacheTags() (err error) { + r.cache = make(map[string]map[string]*model.Tag) + + categories := []model.TagCategory{} + result := r.db.Preload(clause.Associations).Find(&categories) + if result.Error != nil { + err = liberr.Wrap(result.Error) + return + } + + for _, c := range categories { + r.cache[c.Name] = make(map[string]*model.Tag) + for _, t := range c.Tags { + r.cache[c.Name][t.Name] = &t + } + } + + return +} \ No newline at end of file diff --git a/auth/roles.yaml b/auth/roles.yaml index 6fd0f95d3..774979b51 100644 --- a/auth/roles.yaml +++ b/auth/roles.yaml @@ -42,11 +42,14 @@ - name: applications.stakeholders verbs: - put + - name: applications.assessments + verbs: + - get + - post - name: assessments verbs: - delete - get - - patch - post - put - name: businessservices @@ -185,6 +188,22 @@ - get - post - put + - name: archetypes + verbs: + - delete + - get + - post + - put + - name: archetypes.assessments + verbs: + - get + - post + - name: questionnaires + verbs: + - delete + - get + - post + - put - role: tackle-architect resources: - name: addons @@ -229,11 +248,14 @@ - name: applications.stakeholders verbs: - put + - name: applications.assessments + verbs: + - get + - post - name: assessments verbs: - delete - get - - patch - post - put - name: businessservices @@ -359,6 +381,16 @@ - get - post - put + - name: archetypes + verbs: + - get + - name: archetypes.assessments + verbs: + - get + - post + - name: questionnaires + verbs: + - get - role: tackle-migrator resources: - name: addons @@ -382,10 +414,12 @@ - name: applications.analyses verbs: - get + - name: applications.assessments + verbs: + - get - name: assessments verbs: - get - - post - name: businessservices verbs: - get @@ -466,6 +500,15 @@ - name: analyses verbs: - get + - name: archetypes + verbs: + - get + - name: archetypes.assessments + verbs: + - get + - name: questionnaires + verbs: + - get - role: tackle-project-manager resources: - name: addons @@ -492,6 +535,9 @@ - name: applications.stakeholders verbs: - put + - name: applications.assessments + verbs: + - get - name: assessments verbs: - get @@ -567,3 +613,12 @@ - name: analyses verbs: - get + - name: archetypes + verbs: + - get + - name: archetypes.assessments + verbs: + - get + - name: questionnaires + verbs: + - get \ No newline at end of file diff --git a/hack/add/application.sh b/hack/add/application.sh index b615a6a60..fd688b556 100755 --- a/hack/add/application.sh +++ b/hack/add/application.sh @@ -18,11 +18,6 @@ repository: identities: - id: 1 - id: 2 -facts: - - key: A - value: 1 - - key: B - value: 2 tags: - id: 1 ' @@ -37,11 +32,6 @@ name: Cat description: Cat application. identities: - id: 1 -facts: - - key: C - value: 3 - - key: D - value: 4 tags: - id: 1 - id: 2 diff --git a/hack/add/job-function.sh b/hack/add/job-function.sh index bc42d7acc..6362f7e8e 100755 --- a/hack/add/job-function.sh +++ b/hack/add/job-function.sh @@ -4,6 +4,5 @@ host="${HOST:-localhost:8080}" curl -X POST ${host}/jobfunctions -d \ '{ - "name": "tackle", - "role": "Administrator" + "name": "tackle" }' | jq -M . diff --git a/hack/add/stakeholder.sh b/hack/add/stakeholder.sh index 924f309d9..b2f879995 100755 --- a/hack/add/stakeholder.sh +++ b/hack/add/stakeholder.sh @@ -5,9 +5,7 @@ host="${HOST:-localhost:8080}" curl -X POST ${host}/stakeholders -d \ '{ "name": "tackle", - "displayName":"Elmer", "email": "tackle@konveyor.org", - "role": "Administrator", "stakeholderGroups": [{"id": 1}], "jobFunction" : {"id": 1} }' | jq -M . diff --git a/migration/pkg.go b/migration/pkg.go index c8127d672..c82490c0e 100644 --- a/migration/pkg.go +++ b/migration/pkg.go @@ -9,6 +9,7 @@ import ( v6 "github.com/konveyor/tackle2-hub/migration/v6" v7 "github.com/konveyor/tackle2-hub/migration/v7" v8 "github.com/konveyor/tackle2-hub/migration/v8" + v9 "github.com/konveyor/tackle2-hub/migration/v9" "github.com/konveyor/tackle2-hub/settings" "gorm.io/gorm" ) @@ -49,5 +50,6 @@ func All() []Migration { v6.Migration{}, v7.Migration{}, v8.Migration{}, + v9.Migration{}, } } diff --git a/migration/v9/migrate.go b/migration/v9/migrate.go new file mode 100644 index 000000000..e51b3e522 --- /dev/null +++ b/migration/v9/migrate.go @@ -0,0 +1,33 @@ +package v9 + +import ( + liberr "github.com/jortel/go-utils/error" + "github.com/jortel/go-utils/logr" + "github.com/konveyor/tackle2-hub/migration/v9/model" + "gorm.io/gorm" +) + +var log = logr.WithName("migration|v9") + +type Migration struct{} + +func (r Migration) Apply(db *gorm.DB) (err error) { + + type Review struct { + model.Review + ArchetypeID *uint + } + + err = db.AutoMigrate(&Review{}) + if err != nil { + err = liberr.Wrap(err) + return + } + + err = db.AutoMigrate(r.Models()...) + return +} + +func (r Migration) Models() []interface{} { + return model.All() +} diff --git a/migration/v9/model/application.go b/migration/v9/model/application.go new file mode 100644 index 000000000..b9673835f --- /dev/null +++ b/migration/v9/model/application.go @@ -0,0 +1,26 @@ +package model + +type Application struct { + Model + BucketOwner + Name string `gorm:"index;unique;not null"` + Description string + Review *Review `gorm:"constraint:OnDelete:CASCADE"` + Repository JSON `gorm:"type:json"` + Binary string + Facts []Fact `gorm:"constraint:OnDelete:CASCADE"` + Comments string + Tasks []Task `gorm:"constraint:OnDelete:CASCADE"` + Tags []Tag `gorm:"many2many:ApplicationTags"` + Identities []Identity `gorm:"many2many:ApplicationIdentity;constraint:OnDelete:CASCADE"` + BusinessServiceID *uint `gorm:"index"` + BusinessService *BusinessService + OwnerID *uint `gorm:"index"` + Owner *Stakeholder `gorm:"foreignKey:OwnerID"` + Contributors []Stakeholder `gorm:"many2many:ApplicationContributors;constraint:OnDelete:CASCADE"` + Analyses []Analysis `gorm:"constraint:OnDelete:CASCADE"` + MigrationWaveID *uint `gorm:"index"` + MigrationWave *MigrationWave + Ticket *Ticket `gorm:"constraint:OnDelete:CASCADE"` + Assessments []Assessment `gorm:"constraint:OnDelete:CASCADE"` +} diff --git a/migration/v9/model/applicationtag.go b/migration/v9/model/applicationtag.go new file mode 100644 index 000000000..d6cb5974d --- /dev/null +++ b/migration/v9/model/applicationtag.go @@ -0,0 +1,19 @@ +package model + +// +// ApplicationTag represents a row in the join table for the +// many-to-many relationship between Applications and Tags. +type ApplicationTag struct { + ApplicationID uint `gorm:"primaryKey"` + TagID uint `gorm:"primaryKey"` + Source string `gorm:"primaryKey;not null"` + Application Application `gorm:"constraint:OnDelete:CASCADE"` + Tag Tag `gorm:"constraint:OnDelete:CASCADE"` +} + +// +// TableName must return "ApplicationTags" to ensure compatibility +// with the autogenerated join table name. +func (ApplicationTag) TableName() string { + return "ApplicationTags" +} diff --git a/migration/v9/model/archetype.go b/migration/v9/model/archetype.go new file mode 100644 index 000000000..bb272210f --- /dev/null +++ b/migration/v9/model/archetype.go @@ -0,0 +1,14 @@ +package model + +type Archetype struct { + Model + Name string + Description string + Comments string + Review *Review `gorm:"constraint:OnDelete:CASCADE"` + Assessments []Assessment `gorm:"constraint:OnDelete:CASCADE"` + CriteriaTags []Tag `gorm:"many2many:ArchetypeCriteriaTags"` + Tags []Tag `gorm:"many2many:ArchetypeTags"` + Stakeholders []Stakeholder `gorm:"many2many:ArchetypeStakeholders;constraint:OnDelete:CASCADE"` + StakeholderGroups []StakeholderGroup `gorm:"many2many:ArchetypeStakeholderGroups;constraint:OnDelete:CASCADE"` +} diff --git a/migration/v9/model/assessment.go b/migration/v9/model/assessment.go new file mode 100644 index 000000000..45ac1e084 --- /dev/null +++ b/migration/v9/model/assessment.go @@ -0,0 +1,27 @@ +package model + +type Questionnaire struct { + Model + Name string `gorm:"unique"` + Description string + Required bool + Sections JSON `gorm:"type:json"` + Thresholds JSON `gorm:"type:json"` + RiskMessages JSON `gorm:"type:json"` + Assessments []Assessment `gorm:"constraint:OnDelete:CASCADE"` +} + +type Assessment struct { + Model + ApplicationID *uint `gorm:"uniqueIndex:AssessmentA"` + Application *Application + ArchetypeID *uint `gorm:"uniqueIndex:AssessmentB"` + Archetype *Archetype + QuestionnaireID uint `gorm:"uniqueIndex:AssessmentA;uniqueIndex:AssessmentB"` + Questionnaire Questionnaire + Sections JSON `gorm:"type:json"` + Thresholds JSON `gorm:"type:json"` + RiskMessages JSON `gorm:"type:json"` + Stakeholders []Stakeholder `gorm:"many2many:AssessmentStakeholders;constraint:OnDelete:CASCADE"` + StakeholderGroups []StakeholderGroup `gorm:"many2many:AssessmentStakeholderGroups;constraint:OnDelete:CASCADE"` +} diff --git a/migration/v9/model/migrationwave.go b/migration/v9/model/migrationwave.go new file mode 100644 index 000000000..8cec17fee --- /dev/null +++ b/migration/v9/model/migrationwave.go @@ -0,0 +1,13 @@ +package model + +import "time" + +type MigrationWave struct { + Model + Name string `gorm:"uniqueIndex:MigrationWaveA"` + StartDate time.Time `gorm:"uniqueIndex:MigrationWaveA"` + EndDate time.Time `gorm:"uniqueIndex:MigrationWaveA"` + Applications []Application `gorm:"constraint:OnDelete:SET NULL"` + Stakeholders []Stakeholder `gorm:"many2many:MigrationWaveStakeholders;constraint:OnDelete:CASCADE"` + StakeholderGroups []StakeholderGroup `gorm:"many2many:MigrationWaveStakeholderGroups;constraint:OnDelete:CASCADE"` +} diff --git a/migration/v9/model/pkg.go b/migration/v9/model/pkg.go new file mode 100644 index 000000000..fb1767a70 --- /dev/null +++ b/migration/v9/model/pkg.go @@ -0,0 +1,82 @@ +package model + +import "github.com/konveyor/tackle2-hub/migration/v8/model" + +// +// JSON field (data) type. +type JSON = []byte + +type Model = model.Model +type TechDependency = model.TechDependency +type Incident = model.Incident +type Analysis = model.Analysis +type Issue = model.Issue +type Bucket = model.Bucket +type BucketOwner = model.BucketOwner +type BusinessService = model.BusinessService +type Dependency = model.Dependency +type File = model.File +type Fact = model.Fact +type Identity = model.Identity +type Import = model.Import +type ImportSummary = model.ImportSummary +type ImportTag = model.ImportTag +type JobFunction = model.JobFunction +type Proxy = model.Proxy +type Setting = model.Setting +type RuleSet = model.RuleSet +type Rule = model.Rule +type Tag = model.Tag +type TagCategory = model.TagCategory +type Target = model.Target +type Task = model.Task +type TaskGroup = model.TaskGroup +type TaskReport = model.TaskReport +type Ticket = model.Ticket +type Tracker = model.Tracker +type TTL = model.TTL +type DependencyCyclicError = model.DependencyCyclicError + +// +// All builds all models. +// Models are enumerated such that each are listed after +// all the other models on which they may depend. +func All() []interface{} { + return []interface{}{ + Application{}, + TechDependency{}, + Incident{}, + Analysis{}, + Issue{}, + Bucket{}, + BusinessService{}, + Dependency{}, + File{}, + Fact{}, + Identity{}, + Import{}, + ImportSummary{}, + ImportTag{}, + JobFunction{}, + MigrationWave{}, + Proxy{}, + Review{}, + Setting{}, + RuleSet{}, + Rule{}, + Stakeholder{}, + StakeholderGroup{}, + Tag{}, + TagCategory{}, + Target{}, + Task{}, + TaskGroup{}, + TaskReport{}, + Ticket{}, + Tracker{}, + ApplicationTag{}, + Questionnaire{}, + Assessment{}, + Archetype{}, + } +} diff --git a/migration/v9/model/review.go b/migration/v9/model/review.go new file mode 100644 index 000000000..6c113f579 --- /dev/null +++ b/migration/v9/model/review.go @@ -0,0 +1,14 @@ +package model + +type Review struct { + Model + BusinessCriticality uint `gorm:"not null"` + EffortEstimate string `gorm:"not null"` + ProposedAction string `gorm:"not null"` + WorkPriority uint `gorm:"not null"` + Comments string + ApplicationID *uint `gorm:"uniqueIndex"` + Application *Application + ArchetypeID *uint `gorm:"uniqueIndex"` + Archetype *Archetype +} diff --git a/migration/v9/model/stakeholder.go b/migration/v9/model/stakeholder.go new file mode 100644 index 000000000..ad347ba4b --- /dev/null +++ b/migration/v9/model/stakeholder.go @@ -0,0 +1,16 @@ +package model + +type Stakeholder struct { + Model + Name string `gorm:"not null;"` + Email string `gorm:"index;unique;not null"` + Groups []StakeholderGroup `gorm:"many2many:StakeholderGroupStakeholder;constraint:OnDelete:CASCADE"` + BusinessServices []BusinessService `gorm:"constraint:OnDelete:SET NULL"` + JobFunctionID *uint `gorm:"index"` + JobFunction *JobFunction + Owns []Application `gorm:"foreignKey:OwnerID;constraint:OnDelete:SET NULL"` + Contributes []Application `gorm:"many2many:ApplicationContributors;constraint:OnDelete:CASCADE"` + MigrationWaves []MigrationWave `gorm:"many2many:MigrationWaveStakeholders;constraint:OnDelete:CASCADE"` + Assessments []Assessment `gorm:"many2many:AssessmentStakeholders;constraint:OnDelete:CASCADE"` + Archetypes []Archetype `gorm:"many2many:ArchetypeStakeholders;constraint:OnDelete:CASCADE"` +} diff --git a/migration/v9/model/stakeholdergroup.go b/migration/v9/model/stakeholdergroup.go new file mode 100644 index 000000000..085bf4aac --- /dev/null +++ b/migration/v9/model/stakeholdergroup.go @@ -0,0 +1,12 @@ +package model + +type StakeholderGroup struct { + Model + Name string `gorm:"index;unique;not null"` + Username string + Description string + Stakeholders []Stakeholder `gorm:"many2many:StakeholderGroupStakeholder;constraint:OnDelete:CASCADE"` + MigrationWaves []MigrationWave `gorm:"many2many:MigrationWaveStakeholderGroups;constraint:OnDelete:CASCADE"` + Assessments []Assessment `gorm:"many2many:AssessmentStakeholderGroups;constraint:OnDelete:CASCADE"` + Archetypes []Archetype `gorm:"many2many:ArchetypeStakeholderGroups;constraint:OnDelete:CASCADE"` +} diff --git a/model/pkg.go b/model/pkg.go index 6b534753b..60361f4fb 100644 --- a/model/pkg.go +++ b/model/pkg.go @@ -1,7 +1,7 @@ package model import ( - "github.com/konveyor/tackle2-hub/migration/v8/model" + "github.com/konveyor/tackle2-hub/migration/v9/model" "gorm.io/datatypes" ) @@ -13,6 +13,8 @@ type JSON = datatypes.JSON // Models type Model = model.Model type Application = model.Application +type Archetype = model.Archetype +type Assessment = model.Assessment type TechDependency = model.TechDependency type Incident = model.Incident type Analysis = model.Analysis @@ -30,6 +32,7 @@ type ImportTag = model.ImportTag type JobFunction = model.JobFunction type MigrationWave = model.MigrationWave type Proxy = model.Proxy +type Questionnaire = model.Questionnaire type Review = model.Review type Setting = model.Setting type RuleSet = model.RuleSet diff --git a/test/api/review/samples.go b/test/api/review/samples.go index e42441d6a..4fb33b981 100644 --- a/test/api/review/samples.go +++ b/test/api/review/samples.go @@ -11,7 +11,7 @@ var Samples = []api.Review{ ProposedAction: "run", WorkPriority: 1, Comments: "nil", - Application: api.Ref{ + Application: &api.Ref{ ID: 1, Name: "Sample Review 1", }, @@ -22,7 +22,7 @@ var Samples = []api.Review{ ProposedAction: "stop", WorkPriority: 2, Comments: "nil", - Application: api.Ref{ + Application: &api.Ref{ ID: 2, Name: "Sample Review 2", },