Skip to content

Add priorities to quickstarts within bundles #227

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 10 commits into
base: main
Choose a base branch
from
2 changes: 1 addition & 1 deletion cmd/migrate/migrate.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
6 changes: 6 additions & 0 deletions pkg/database/db.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()))
Expand Down
115 changes: 103 additions & 12 deletions pkg/database/db_seed.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,9 @@ import (
)

type TagTemplate struct {
Kind string
Value string
Kind string
Value string
Priority *int
}

type MetadataTemplate struct {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
}

Expand Down Expand Up @@ -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)
}
Expand All @@ -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)
Expand Down
6 changes: 6 additions & 0 deletions pkg/models/tag.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
}
63 changes: 63 additions & 0 deletions pkg/routes/quickstarts.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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)
Expand Down
39 changes: 27 additions & 12 deletions pkg/routes/quickstarts_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"}`)
Expand All @@ -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"}`)
Expand All @@ -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) {
Expand All @@ -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) {
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -203,18 +218,18 @@ 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)

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) {
Expand Down