diff --git a/cmd/migrate/migrate.go b/cmd/migrate/migrate.go index ea3ea68f..5edb417b 100644 --- a/cmd/migrate/migrate.go +++ b/cmd/migrate/migrate.go @@ -12,7 +12,7 @@ func main() { godotenv.Load() config.Init() database.Init() - err := database.DB.AutoMigrate(&models.Quickstart{}, &models.QuickstartProgress{}, &models.Tag{}, &models.HelpTopic{}, &models.FavoriteQuickstart{}) + err := database.DB.AutoMigrate(&models.Quickstart{}, &models.QuickstartProgress{}, &models.Tag{}, &models.HelpTopic{}, &models.FavoriteQuickstart{}, &models.QuickstartTag{}) if err != nil { panic(err) } diff --git a/pkg/database/db.go b/pkg/database/db.go index a8cda813..7b596b0b 100644 --- a/pkg/database/db.go +++ b/pkg/database/db.go @@ -45,6 +45,12 @@ func Init() { if !DB.Migrator().HasTable(&models.FavoriteQuickstart{}) { DB.Migrator().CreateTable(&models.FavoriteQuickstart{}) } + if (!DB.Migrator().HasTable(&models.QuickstartTag{})) { + DB.Migrator().CreateTable(&models.QuickstartTag{}) + } + + DB.SetupJoinTable(&models.Quickstart{}, "Tags", &models.QuickstartTag{}) + DB.SetupJoinTable(&models.Tag{}, "Quickstarts", &models.QuickstartTag{}) if err != nil { panic(fmt.Sprintf("failed to connect database: %s", err.Error())) diff --git a/pkg/database/db_seed.go b/pkg/database/db_seed.go index b9728f4d..2b51c37d 100644 --- a/pkg/database/db_seed.go +++ b/pkg/database/db_seed.go @@ -16,8 +16,9 @@ import ( ) type TagTemplate struct { - Kind string - Value string + Kind string + Value string + Priority *int } type MetadataTemplate struct { @@ -78,22 +79,94 @@ func findTags() []MetadataTemplate { return MetadataTemplates } -func seedQuickstart(t MetadataTemplate, defaultTag models.Tag) (models.Quickstart, error) { - yamlfile, err := ioutil.ReadFile(t.ContentPath) +func makeQuickstartPrioritiesMap(tags []TagTemplate) (out map[string]int) { + out = make(map[string]int) + + for _, tag := range tags { + if tag.Kind == string(models.BundleTag) && tag.Priority != nil { + out[tag.Value] = *tag.Priority + } + } + + return out +} + +func quickstartMetadata(quickstartData map[string]interface{}) (map[string]interface{}, error) { + rawMetadata, ok := quickstartData["metadata"] + + if !ok { + return nil, fmt.Errorf("expected quickstart to contain metadata") + } + + metadata, ok := rawMetadata.(map[string]interface{}) + + if !ok { + return nil, fmt.Errorf("expected quickstart metadata to be an object, got %v", metadata) + } + + return metadata, nil +} + +func quickstartName(metadata map[string]interface{}) (string, error) { + rawName, ok := metadata["name"] + + if !ok { + return "", fmt.Errorf("expected quickstart metadata to contain a name") + } + + name, ok := rawName.(string) + + if !ok { + return "", fmt.Errorf("expected quickstart metadata.name to be a string got %v", name) + } + + return name, nil +} + +func seedQuickstart(t MetadataTemplate, defaultTag models.Tag, priorities map[string]int) (models.Quickstart, error) { var newQuickstart models.Quickstart var originalQuickstart models.Quickstart + + yamlfile, err := ioutil.ReadFile(t.ContentPath) + + if err != nil { + return newQuickstart, err + } + + var quickstartData map[string]interface{} + err = yaml.Unmarshal(yamlfile, &quickstartData) + + if err != nil { + return newQuickstart, err + } + + metadata, err := quickstartMetadata(quickstartData) + + if err != nil { + return newQuickstart, err + } + + name, err := quickstartName(metadata) + + if err != nil { + return newQuickstart, err + } + + if len(priorities) > 0 { + metadata["bundle_priority"] = priorities + } + + jsonContent, err := json.Marshal(quickstartData) + if err != nil { return newQuickstart, err } - jsonContent, err := yaml.YAMLToJSON(yamlfile) - var data map[string]map[string]string - json.Unmarshal(jsonContent, &data) - name := data["metadata"]["name"] r := DB.Where("name = ?", name).Find(&originalQuickstart) + if r.Error != nil { // check for DB error - return newQuickstart, err + return newQuickstart, r.Error } else if r.RowsAffected == 0 { // Create new quickstart newQuickstart.Content = jsonContent @@ -249,6 +322,15 @@ func clearOldContent() []models.FavoriteQuickstart { DB.Unscoped().Delete(&h) } + // Remove any left-over links between quickstarts and their tags. + + var staleQuickStartLinks []models.QuickstartTag + DB.Model(&models.QuickstartTag{}).Find(&staleQuickStartLinks) + + for _, link := range staleQuickStartLinks { + DB.Unscoped().Delete(&link) + } + return favorites } @@ -284,7 +366,7 @@ func SeedTags() { var quickstart models.Quickstart var quickstartErr error var tags []models.Tag - quickstart, quickstartErr = seedQuickstart(template, defaultTags["quickstart"]) + quickstart, quickstartErr = seedQuickstart(template, defaultTags["quickstart"], makeQuickstartPrioritiesMap(template.Tags)) if quickstartErr != nil { fmt.Println("Unable to seed quickstart: ", quickstartErr.Error(), template.ContentPath) } @@ -306,12 +388,21 @@ func SeedTags() { originalTag = newTag } - // Create tags quickstarts associations - err := DB.Model(&originalTag).Association("Quickstarts").Append(&quickstart) + newLink := models.QuickstartTag{QuickstartID: quickstart.ID, TagID: originalTag.ID} + + if newTag.Type == models.BundleTag { + newLink.Priority = tag.Priority + } else if tag.Priority != nil { + logrus.Warningln("Unexpected priority for non-bundle tag in file", template.ContentPath) + } + + err := DB.Create(&newLink).Error + if err != nil { fmt.Println("Failed creating tags associations", err.Error()) } + originalTag.Quickstarts = append(originalTag.Quickstarts, quickstart) quickstart.Tags = append(quickstart.Tags, originalTag) DB.Save(&quickstart) diff --git a/pkg/models/tag.go b/pkg/models/tag.go index 39cd247e..7e404e26 100644 --- a/pkg/models/tag.go +++ b/pkg/models/tag.go @@ -52,3 +52,9 @@ type Tag struct { Quickstarts []Quickstart `gorm:"many2many:quickstart_tags;"` HelpTopics []HelpTopic `gorm:"many2many:help_topic_tags;"` } + +type QuickstartTag struct { + QuickstartID uint `gorm:"primaryKey"` + TagID uint `gorm:"primaryKey"` + Priority *int `gorm:"default:null"` +} diff --git a/pkg/routes/quickstarts.go b/pkg/routes/quickstarts.go index 67b8e516..3309aa74 100644 --- a/pkg/routes/quickstarts.go +++ b/pkg/routes/quickstarts.go @@ -9,6 +9,8 @@ import ( "github.com/RedHatInsights/quickstarts/pkg/database" "github.com/RedHatInsights/quickstarts/pkg/models" "github.com/go-chi/chi/v5" + + "gorm.io/gorm/clause" ) func FindQuickstartById(id int) (models.Quickstart, error) { @@ -27,7 +29,68 @@ func findQuickstartsByName(name string, pagination Pagination) ([]models.Quickst return quickStarts, nil } +func findBundleQuickstarts(bundle string, pagination Pagination) ([]models.Quickstart, error) { + var quickstarts []models.Quickstart + var foundTags []models.Tag + var err error + + // We have to handle the case of multiple tags here, despite it seeming like there can only be one. + // There is no database constraint that actually enforces uniqueness, so of course non-unique tags + // do happen. For instance, two tests create tags with type=bundle and value=rhel, and if we only + // look for one tag, then we may not find what we are actually looking for. + + database.DB.Model(&models.Tag{}).Where("type = ? AND value = ?", models.BundleTag, bundle).Find(&foundTags) + err = database.DB.Error + + if err != nil { + return quickstarts, err + } + + if len(foundTags) == 0 { + return quickstarts, nil + } + + var tagIDs []uint + + for _, tag := range foundTags { + tagIDs = append(tagIDs, tag.ID) + } + + quickstartIdsQuery := database.DB.Model(&models.QuickstartTag{}).Select("quickstart_id").Where("tag_id IN ?", tagIDs) + + // The hard-coded 1000 here is the default priority of a quickstart within a bundle. + // This must remain in sync with the learning-resources frontend. + + database. + DB. + Limit(pagination.Limit). + Offset(pagination.Offset). + Select("id, name, content"). + Where("id IN (?)", quickstartIdsQuery). + Clauses(clause.OrderBy{ + Expression: clause.Expr{ + SQL: "COALESCE((SELECT MIN(priority) FROM quickstart_tags WHERE quickstart_tags.tag_id IN ? AND quickstart_tags.quickstart_id = quickstarts.id AND quickstart_tags.priority IS NOT NULL), 1000)", + Vars: []interface{}{tagIDs}, + }, + }). + Find(&quickstarts) + + err = database.DB.Error + + if err != nil { + return quickstarts, err + } + + return quickstarts, nil +} + func findQuickstartsByTags(tagTypes []models.TagType, tagValues []string, pagination Pagination) ([]models.Quickstart, error) { + // Special case of requesting exactly a single bundle: we will return results sorted by the quickstarts' priorities + // within that bundle. + if len(tagTypes) == 1 && tagTypes[0] == models.BundleTag && len(tagValues) == 1 { + return findBundleQuickstarts(tagValues[0], pagination) + } + var quickstarts []models.Quickstart var tagsArray []models.Tag database.DB.Where("type IN ? AND value IN ?", tagTypes, tagValues).Find(&tagsArray) diff --git a/pkg/routes/quickstarts_test.go b/pkg/routes/quickstarts_test.go index d00035b3..14a160db 100644 --- a/pkg/routes/quickstarts_test.go +++ b/pkg/routes/quickstarts_test.go @@ -16,7 +16,8 @@ import ( var quickstart models.Quickstart var taggedQuickstart models.Quickstart var settingsQuickstart models.Quickstart -var rhelQuickstart models.Quickstart +var rhelQuickstartA models.Quickstart +var rhelQuickstartB models.Quickstart var rbacQuickstart models.Quickstart var rhelBudleTag models.Tag var settingsBundleTag models.Tag @@ -77,6 +78,10 @@ func setupTags() { database.DB.Create(&unusedTag) } +func linkWithPriority(quickstart *models.Quickstart, tag *models.Tag, priority int) { + database.DB.Create(&models.QuickstartTag{QuickstartID: quickstart.ID, TagID: tag.ID, Priority: &priority}) +} + func setupTaggedQuickstarts() { taggedQuickstart.Name = "tagged-quickstart" taggedQuickstart.Content = []byte(`{"tags": "all-tags"}`) @@ -92,12 +97,17 @@ func setupTaggedQuickstarts() { database.DB.Model(&settingsQuickstart).Association("Tags").Append(&settingsBundleTag) database.DB.Save(&settingsQuickstart) - rhelQuickstart.Name = "rhel-quickstart" - rhelQuickstart.Content = []byte(`{"tags": "rhel"}`) + rhelQuickstartA.Name = "rhel-quickstart-a" + rhelQuickstartA.Content = []byte(`{"tags": "rhel"}`) + + database.DB.Create(&rhelQuickstartA) + linkWithPriority(&rhelQuickstartA, &rhelBudleTag, 1100) - database.DB.Create(&rhelQuickstart) - database.DB.Model(&rhelQuickstart).Association("Tags").Append(&rhelBudleTag) - database.DB.Save(&rhelQuickstart) + rhelQuickstartB.Name = "rhel-quickstart-b" + rhelQuickstartB.Content = []byte(`{"tags": "rhel"}`) + + database.DB.Create(&rhelQuickstartB) + linkWithPriority(&rhelQuickstartB, &rhelBudleTag, 900) rbacQuickstart.Name = "rbac-quickstart" rbacQuickstart.Content = []byte(`{"tags": "rbac"}`) @@ -121,7 +131,7 @@ func TestGetAll(t *testing.T) { var payload *responsePayload json.NewDecoder(response.Body).Decode(&payload) assert.Equal(t, 200, response.Code) - assert.Equal(t, 3, len(payload.Data)) + assert.Equal(t, 4, len(payload.Data)) }) t.Run("should get all quickstarts with 'rhel' bundle tag", func(t *testing.T) { @@ -132,7 +142,12 @@ func TestGetAll(t *testing.T) { var payload *responsePayload json.NewDecoder(response.Body).Decode(&payload) assert.Equal(t, 200, response.Code) - assert.Equal(t, 2, len(payload.Data)) + assert.Equal(t, 3, len(payload.Data)) + + // This is a request for a single bundle, so the quickstarts should be sorted in priority order. + assert.Equal(t, "rhel-quickstart-b", payload.Data[0].Name) // Priority 900 + assert.Equal(t, "tagged-quickstart", payload.Data[1].Name) // Default priority (1000) + assert.Equal(t, "rhel-quickstart-a", payload.Data[2].Name) // Priority 1100 }) t.Run("should get all quickstarts with 'settings' bundle tag", func(t *testing.T) { @@ -169,7 +184,7 @@ func TestGetAll(t *testing.T) { var payload *responsePayload json.NewDecoder(response.Body).Decode(&payload) assert.Equal(t, 200, response.Code) - assert.Equal(t, 5, len(payload.Data)) + assert.Equal(t, 6, len(payload.Data)) }) t.Run("should get quikctart by ID", func(t *testing.T) { @@ -203,10 +218,10 @@ func TestGetAll(t *testing.T) { var payload *responsePayload json.NewDecoder(response.Body).Decode(&payload) assert.Equal(t, 200, response.Code) - assert.Equal(t, 5, len(payload.Data)) + assert.Equal(t, 6, len(payload.Data)) }) - t.Run("should offset response by 2 and recover 3 records", func(t *testing.T) { + t.Run("should offset response by 2 and recover 4 records", func(t *testing.T) { request, _ := http.NewRequest(http.MethodGet, "/?offset=2", nil) response := httptest.NewRecorder() router.ServeHTTP(response, request) @@ -214,7 +229,7 @@ func TestGetAll(t *testing.T) { var payload *responsePayload json.NewDecoder(response.Body).Decode(&payload) assert.Equal(t, 200, response.Code) - assert.Equal(t, 3, len(payload.Data)) + assert.Equal(t, 4, len(payload.Data)) }) t.Run("should limit response by 2 offset response by 2 and recover 2 records", func(t *testing.T) {