From 7d820f7fea88aa8e1fa36e76054933e02ab8bcd6 Mon Sep 17 00:00:00 2001 From: Samir Faci Date: Fri, 8 Mar 2024 11:05:53 -0500 Subject: [PATCH 1/4] Adding support for Org Properties ChangeLog: - Adding CLI tooling to set some Org Properties - Adding Org Properties to CLI listing. --- cli/backup/organizations.go | 12 ++- cli/tools/org_preferences.go | 116 ++++++++++++++++++++ cli/tools/organizations.go | 5 + internal/service/mocks/GrafanaService.go | 117 ++++++++++++++++++-- internal/service/mocks/OrganizationsApi.go | 119 +++++++++++++++++++-- internal/service/org_preferences.go | 94 ++++++++++++++++ internal/service/organizations.go | 102 +++++++++++++----- internal/types/models.go | 5 + test/data/organizations/dumbdumb.json | 7 +- test/data/organizations/main-org.json | 9 +- test/data/organizations/moo.json | 9 +- test/organizations_integration_test.go | 50 ++++++--- 12 files changed, 582 insertions(+), 63 deletions(-) create mode 100644 cli/tools/org_preferences.go create mode 100644 internal/service/org_preferences.go diff --git a/cli/backup/organizations.go b/cli/backup/organizations.go index 56e19ccf..493493a2 100644 --- a/cli/backup/organizations.go +++ b/cli/backup/organizations.go @@ -57,16 +57,22 @@ func newOrganizationsListCmd() simplecobra.Commander { RunFunc: func(ctx context.Context, cd *simplecobra.Commandeer, rootCmd *support.RootCommand, args []string) error { filter := service.NewOrganizationFilter(parseOrganizationGlobalFlags(cd.CobraCommand)...) slog.Info("Listing organizations for context", "context", config.Config().GetGDGConfig().GetContext()) - rootCmd.TableObj.AppendHeader(table.Row{"id", "organization Name", "org slug ID"}) + rootCmd.TableObj.AppendHeader(table.Row{"id", "organization Name", "org slug ID", "HomeDashboardUID", "Theme", "WeekStart"}) listOrganizations := rootCmd.GrafanaSvc().ListOrganizations(filter) sort.Slice(listOrganizations, func(a, b int) bool { - return listOrganizations[a].ID < listOrganizations[b].ID + return listOrganizations[a].Organization.ID < listOrganizations[b].Organization.ID }) if len(listOrganizations) == 0 { slog.Info("No organizations found") } else { for _, org := range listOrganizations { - rootCmd.TableObj.AppendRow(table.Row{org.ID, org.Name, slug.Make(org.Name)}) + rootCmd.TableObj.AppendRow(table.Row{org.Organization.ID, + org.Organization.Name, + slug.Make(org.Organization.Name), + org.Preferences.HomeDashboardUID, + org.Preferences.Theme, + org.Preferences.WeekStart, + }) } rootCmd.TableObj.Render() } diff --git a/cli/tools/org_preferences.go b/cli/tools/org_preferences.go new file mode 100644 index 00000000..6fbf07a2 --- /dev/null +++ b/cli/tools/org_preferences.go @@ -0,0 +1,116 @@ +package tools + +import ( + "context" + "github.com/bep/simplecobra" + "github.com/esnet/gdg/cli/support" + "github.com/jedib0t/go-pretty/v6/table" + "github.com/spf13/cobra" + "log" + "log/slog" +) + +func newOrgPreferenceCommand() simplecobra.Commander { + return &support.SimpleCommand{ + NameP: "preferences", + Short: "Update organization preferences", + Long: "Update organization preferences", + RunFunc: func(ctx context.Context, cd *simplecobra.Commandeer, rootCmd *support.RootCommand, args []string) error { + return cd.CobraCommand.Help() + + }, + WithCFunc: func(cmd *cobra.Command, r *support.RootCommand) { + cmd.Aliases = []string{"preference", "pref", "p", "prefs"} + }, + CommandsList: []simplecobra.Commander{ + //Preferences + newGetOrgPreferenceCmd(), + newUpdateOrgPreferenceCmd(), + }, + } + +} + +func newUpdateOrgPreferenceCmd() simplecobra.Commander { + return &support.SimpleCommand{ + NameP: "set", + Short: "Set --orgName [--homeDashUid, --theme, --weekstart] to set Org preferences", + Long: "Set --orgName [--homeDashUid, --theme, --weekstart] to set Org preferences", + WithCFunc: func(cmd *cobra.Command, r *support.RootCommand) { + cmd.PersistentFlags().StringP("orgName", "", "", "Organization Name") + cmd.PersistentFlags().StringP("homeDashUid", "", "", "UID for the home dashboard") + cmd.PersistentFlags().StringP("theme", "", "", "light, dark") + cmd.PersistentFlags().StringP("weekstart", "", "", "day of the week (sunday, monday, etc)") + + }, + RunFunc: func(ctx context.Context, cd *simplecobra.Commandeer, rootCmd *support.RootCommand, args []string) error { + slog.Info("update the org preferences") + org, _ := cd.CobraCommand.Flags().GetString("orgName") + home, _ := cd.CobraCommand.Flags().GetString("homeDashUid") + theme, _ := cd.CobraCommand.Flags().GetString("theme") + weekstart, _ := cd.CobraCommand.Flags().GetString("weekstart") + if org == "" { + log.Fatal("--orgName is a required parameter") + } + if home != "" && theme != "" && weekstart == "" { + log.Fatal("At least one of [--homeDashUid, --theme, --weekstart] needs to be set") + } + + rootCmd.GrafanaSvc().InitOrganizations() + prefere, err := rootCmd.GrafanaSvc().GetOrgPreferences(org) + if err != nil { + log.Fatal(err.Error()) + } + if home != "" { + prefere.HomeDashboardUID = home + } + if theme != "" { + prefere.Theme = theme + } + if weekstart != "" { + prefere.WeekStart = weekstart + } + + err = rootCmd.GrafanaSvc().UploadOrgPreferences(org, prefere) + if err != nil { + log.Fatal("Failed to update org preferences") + } + slog.Info("Preferences update for organization", slog.Any("organization", org)) + + return nil + + }, + } + +} + +func newGetOrgPreferenceCmd() simplecobra.Commander { + return &support.SimpleCommand{ + NameP: "get", + Short: "get returns org preferences", + Long: "get returns org preferences", + WithCFunc: func(cmd *cobra.Command, r *support.RootCommand) { + cmd.PersistentFlags().StringP("orgName", "", "", "Organization Name") + }, + RunFunc: func(ctx context.Context, cd *simplecobra.Commandeer, rootCmd *support.RootCommand, args []string) error { + orgName, _ := cd.CobraCommand.Flags().GetString("orgName") + + rootCmd.GrafanaSvc().InitOrganizations() + pref, err := rootCmd.GrafanaSvc().GetOrgPreferences(orgName) + if err != nil { + log.Fatal(err.Error()) + } + + rootCmd.TableObj.AppendHeader(table.Row{"field", "value"}) + rootCmd.TableObj.AppendRow(table.Row{"HomeDashboardUID", pref.HomeDashboardUID}) + rootCmd.TableObj.AppendRow(table.Row{"Theme", pref.Theme}) + rootCmd.TableObj.AppendRow(table.Row{"WeekStart", pref.WeekStart}) + + rootCmd.TableObj.Render() + + return nil + + }, + } + +} diff --git a/cli/tools/organizations.go b/cli/tools/organizations.go index 529ab718..6392c667 100644 --- a/cli/tools/organizations.go +++ b/cli/tools/organizations.go @@ -35,6 +35,8 @@ func newOrgCommand() simplecobra.Commander { newUpdateUserRoleCmd(), newAddUserRoleCmd(), newDeleteUserRoleCmd(), + //Preferences + newOrgPreferenceCommand(), }, } @@ -53,6 +55,9 @@ func newSetOrgCmd() simplecobra.Commander { RunFunc: func(ctx context.Context, cd *simplecobra.Commandeer, rootCmd *support.RootCommand, args []string) error { orgName, _ := cd.CobraCommand.Flags().GetString("orgName") slugName, _ := cd.CobraCommand.Flags().GetString("orgSlugName") + if orgName == "" && slugName == "" { + return errors.New("must set either --orgName or --orgSlugName flag") + } if orgName != "" || slugName != "" { var useSlug = false if slugName != "" { diff --git a/internal/service/mocks/GrafanaService.go b/internal/service/mocks/GrafanaService.go index f4a02890..77aac9e6 100644 --- a/internal/service/mocks/GrafanaService.go +++ b/internal/service/mocks/GrafanaService.go @@ -1219,6 +1219,64 @@ func (_c *GrafanaService_DownloadUsers_Call) RunAndReturn(run func(filters.Filte return _c } +// GetOrgPreferences provides a mock function with given fields: orgName +func (_m *GrafanaService) GetOrgPreferences(orgName string) (*models.Preferences, error) { + ret := _m.Called(orgName) + + if len(ret) == 0 { + panic("no return value specified for GetOrgPreferences") + } + + var r0 *models.Preferences + var r1 error + if rf, ok := ret.Get(0).(func(string) (*models.Preferences, error)); ok { + return rf(orgName) + } + if rf, ok := ret.Get(0).(func(string) *models.Preferences); ok { + r0 = rf(orgName) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*models.Preferences) + } + } + + if rf, ok := ret.Get(1).(func(string) error); ok { + r1 = rf(orgName) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// GrafanaService_GetOrgPreferences_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetOrgPreferences' +type GrafanaService_GetOrgPreferences_Call struct { + *mock.Call +} + +// GetOrgPreferences is a helper method to define mock.On call +// - orgName string +func (_e *GrafanaService_Expecter) GetOrgPreferences(orgName interface{}) *GrafanaService_GetOrgPreferences_Call { + return &GrafanaService_GetOrgPreferences_Call{Call: _e.mock.On("GetOrgPreferences", orgName)} +} + +func (_c *GrafanaService_GetOrgPreferences_Call) Run(run func(orgName string)) *GrafanaService_GetOrgPreferences_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(string)) + }) + return _c +} + +func (_c *GrafanaService_GetOrgPreferences_Call) Return(_a0 *models.Preferences, _a1 error) *GrafanaService_GetOrgPreferences_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *GrafanaService_GetOrgPreferences_Call) RunAndReturn(run func(string) (*models.Preferences, error)) *GrafanaService_GetOrgPreferences_Call { + _c.Call.Return(run) + return _c +} + // GetServerInfo provides a mock function with given fields: func (_m *GrafanaService) GetServerInfo() map[string]interface{} { ret := _m.Called() @@ -1882,19 +1940,19 @@ func (_c *GrafanaService_ListOrgUsers_Call) RunAndReturn(run func(int64) []*mode } // ListOrganizations provides a mock function with given fields: filter -func (_m *GrafanaService) ListOrganizations(filter filters.Filter) []*models.OrgDTO { +func (_m *GrafanaService) ListOrganizations(filter filters.Filter) []*types.OrgsDTOWithPreferences { ret := _m.Called(filter) if len(ret) == 0 { panic("no return value specified for ListOrganizations") } - var r0 []*models.OrgDTO - if rf, ok := ret.Get(0).(func(filters.Filter) []*models.OrgDTO); ok { + var r0 []*types.OrgsDTOWithPreferences + if rf, ok := ret.Get(0).(func(filters.Filter) []*types.OrgsDTOWithPreferences); ok { r0 = rf(filter) } else { if ret.Get(0) != nil { - r0 = ret.Get(0).([]*models.OrgDTO) + r0 = ret.Get(0).([]*types.OrgsDTOWithPreferences) } } @@ -1919,12 +1977,12 @@ func (_c *GrafanaService_ListOrganizations_Call) Run(run func(filter filters.Fil return _c } -func (_c *GrafanaService_ListOrganizations_Call) Return(_a0 []*models.OrgDTO) *GrafanaService_ListOrganizations_Call { +func (_c *GrafanaService_ListOrganizations_Call) Return(_a0 []*types.OrgsDTOWithPreferences) *GrafanaService_ListOrganizations_Call { _c.Call.Return(_a0) return _c } -func (_c *GrafanaService_ListOrganizations_Call) RunAndReturn(run func(filters.Filter) []*models.OrgDTO) *GrafanaService_ListOrganizations_Call { +func (_c *GrafanaService_ListOrganizations_Call) RunAndReturn(run func(filters.Filter) []*types.OrgsDTOWithPreferences) *GrafanaService_ListOrganizations_Call { _c.Call.Return(run) return _c } @@ -2689,6 +2747,53 @@ func (_c *GrafanaService_UploadLibraryElements_Call) RunAndReturn(run func(filte return _c } +// UploadOrgPreferences provides a mock function with given fields: orgName, pref +func (_m *GrafanaService) UploadOrgPreferences(orgName string, pref *models.Preferences) error { + ret := _m.Called(orgName, pref) + + if len(ret) == 0 { + panic("no return value specified for UploadOrgPreferences") + } + + var r0 error + if rf, ok := ret.Get(0).(func(string, *models.Preferences) error); ok { + r0 = rf(orgName, pref) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// GrafanaService_UploadOrgPreferences_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'UploadOrgPreferences' +type GrafanaService_UploadOrgPreferences_Call struct { + *mock.Call +} + +// UploadOrgPreferences is a helper method to define mock.On call +// - orgName string +// - pref *models.Preferences +func (_e *GrafanaService_Expecter) UploadOrgPreferences(orgName interface{}, pref interface{}) *GrafanaService_UploadOrgPreferences_Call { + return &GrafanaService_UploadOrgPreferences_Call{Call: _e.mock.On("UploadOrgPreferences", orgName, pref)} +} + +func (_c *GrafanaService_UploadOrgPreferences_Call) Run(run func(orgName string, pref *models.Preferences)) *GrafanaService_UploadOrgPreferences_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(string), args[1].(*models.Preferences)) + }) + return _c +} + +func (_c *GrafanaService_UploadOrgPreferences_Call) Return(_a0 error) *GrafanaService_UploadOrgPreferences_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *GrafanaService_UploadOrgPreferences_Call) RunAndReturn(run func(string, *models.Preferences) error) *GrafanaService_UploadOrgPreferences_Call { + _c.Call.Return(run) + return _c +} + // UploadOrganizations provides a mock function with given fields: filter func (_m *GrafanaService) UploadOrganizations(filter filters.Filter) []string { ret := _m.Called(filter) diff --git a/internal/service/mocks/OrganizationsApi.go b/internal/service/mocks/OrganizationsApi.go index 8300042e..4de8618c 100644 --- a/internal/service/mocks/OrganizationsApi.go +++ b/internal/service/mocks/OrganizationsApi.go @@ -7,6 +7,8 @@ import ( mock "github.com/stretchr/testify/mock" models "github.com/grafana/grafana-openapi-client-go/models" + + types "github.com/esnet/gdg/internal/types" ) // OrganizationsApi is an autogenerated mock type for the OrganizationsApi type @@ -165,6 +167,64 @@ func (_c *OrganizationsApi_DownloadOrganizations_Call) RunAndReturn(run func(fil return _c } +// GetOrgPreferences provides a mock function with given fields: orgName +func (_m *OrganizationsApi) GetOrgPreferences(orgName string) (*models.Preferences, error) { + ret := _m.Called(orgName) + + if len(ret) == 0 { + panic("no return value specified for GetOrgPreferences") + } + + var r0 *models.Preferences + var r1 error + if rf, ok := ret.Get(0).(func(string) (*models.Preferences, error)); ok { + return rf(orgName) + } + if rf, ok := ret.Get(0).(func(string) *models.Preferences); ok { + r0 = rf(orgName) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*models.Preferences) + } + } + + if rf, ok := ret.Get(1).(func(string) error); ok { + r1 = rf(orgName) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// OrganizationsApi_GetOrgPreferences_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetOrgPreferences' +type OrganizationsApi_GetOrgPreferences_Call struct { + *mock.Call +} + +// GetOrgPreferences is a helper method to define mock.On call +// - orgName string +func (_e *OrganizationsApi_Expecter) GetOrgPreferences(orgName interface{}) *OrganizationsApi_GetOrgPreferences_Call { + return &OrganizationsApi_GetOrgPreferences_Call{Call: _e.mock.On("GetOrgPreferences", orgName)} +} + +func (_c *OrganizationsApi_GetOrgPreferences_Call) Run(run func(orgName string)) *OrganizationsApi_GetOrgPreferences_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(string)) + }) + return _c +} + +func (_c *OrganizationsApi_GetOrgPreferences_Call) Return(_a0 *models.Preferences, _a1 error) *OrganizationsApi_GetOrgPreferences_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *OrganizationsApi_GetOrgPreferences_Call) RunAndReturn(run func(string) (*models.Preferences, error)) *OrganizationsApi_GetOrgPreferences_Call { + _c.Call.Return(run) + return _c +} + // GetTokenOrganization provides a mock function with given fields: func (_m *OrganizationsApi) GetTokenOrganization() *models.OrgDetailsDTO { ret := _m.Called() @@ -340,19 +400,19 @@ func (_c *OrganizationsApi_ListOrgUsers_Call) RunAndReturn(run func(int64) []*mo } // ListOrganizations provides a mock function with given fields: filter -func (_m *OrganizationsApi) ListOrganizations(filter filters.Filter) []*models.OrgDTO { +func (_m *OrganizationsApi) ListOrganizations(filter filters.Filter) []*types.OrgsDTOWithPreferences { ret := _m.Called(filter) if len(ret) == 0 { panic("no return value specified for ListOrganizations") } - var r0 []*models.OrgDTO - if rf, ok := ret.Get(0).(func(filters.Filter) []*models.OrgDTO); ok { + var r0 []*types.OrgsDTOWithPreferences + if rf, ok := ret.Get(0).(func(filters.Filter) []*types.OrgsDTOWithPreferences); ok { r0 = rf(filter) } else { if ret.Get(0) != nil { - r0 = ret.Get(0).([]*models.OrgDTO) + r0 = ret.Get(0).([]*types.OrgsDTOWithPreferences) } } @@ -377,12 +437,12 @@ func (_c *OrganizationsApi_ListOrganizations_Call) Run(run func(filter filters.F return _c } -func (_c *OrganizationsApi_ListOrganizations_Call) Return(_a0 []*models.OrgDTO) *OrganizationsApi_ListOrganizations_Call { +func (_c *OrganizationsApi_ListOrganizations_Call) Return(_a0 []*types.OrgsDTOWithPreferences) *OrganizationsApi_ListOrganizations_Call { _c.Call.Return(_a0) return _c } -func (_c *OrganizationsApi_ListOrganizations_Call) RunAndReturn(run func(filters.Filter) []*models.OrgDTO) *OrganizationsApi_ListOrganizations_Call { +func (_c *OrganizationsApi_ListOrganizations_Call) RunAndReturn(run func(filters.Filter) []*types.OrgsDTOWithPreferences) *OrganizationsApi_ListOrganizations_Call { _c.Call.Return(run) return _c } @@ -585,6 +645,53 @@ func (_c *OrganizationsApi_UpdateUserInOrg_Call) RunAndReturn(run func(string, s return _c } +// UploadOrgPreferences provides a mock function with given fields: orgName, pref +func (_m *OrganizationsApi) UploadOrgPreferences(orgName string, pref *models.Preferences) error { + ret := _m.Called(orgName, pref) + + if len(ret) == 0 { + panic("no return value specified for UploadOrgPreferences") + } + + var r0 error + if rf, ok := ret.Get(0).(func(string, *models.Preferences) error); ok { + r0 = rf(orgName, pref) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// OrganizationsApi_UploadOrgPreferences_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'UploadOrgPreferences' +type OrganizationsApi_UploadOrgPreferences_Call struct { + *mock.Call +} + +// UploadOrgPreferences is a helper method to define mock.On call +// - orgName string +// - pref *models.Preferences +func (_e *OrganizationsApi_Expecter) UploadOrgPreferences(orgName interface{}, pref interface{}) *OrganizationsApi_UploadOrgPreferences_Call { + return &OrganizationsApi_UploadOrgPreferences_Call{Call: _e.mock.On("UploadOrgPreferences", orgName, pref)} +} + +func (_c *OrganizationsApi_UploadOrgPreferences_Call) Run(run func(orgName string, pref *models.Preferences)) *OrganizationsApi_UploadOrgPreferences_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(string), args[1].(*models.Preferences)) + }) + return _c +} + +func (_c *OrganizationsApi_UploadOrgPreferences_Call) Return(_a0 error) *OrganizationsApi_UploadOrgPreferences_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *OrganizationsApi_UploadOrgPreferences_Call) RunAndReturn(run func(string, *models.Preferences) error) *OrganizationsApi_UploadOrgPreferences_Call { + _c.Call.Return(run) + return _c +} + // UploadOrganizations provides a mock function with given fields: filter func (_m *OrganizationsApi) UploadOrganizations(filter filters.Filter) []string { ret := _m.Called(filter) diff --git a/internal/service/org_preferences.go b/internal/service/org_preferences.go new file mode 100644 index 00000000..4886d1df --- /dev/null +++ b/internal/service/org_preferences.go @@ -0,0 +1,94 @@ +package service + +import ( + "errors" + "fmt" + "github.com/gosimple/slug" + "github.com/grafana/grafana-openapi-client-go/models" + "log/slog" +) + +// OrgPreferencesApi Contract definition +type OrgPreferencesApi interface { + GetOrgPreferences(orgName string) (*models.Preferences, error) + UploadOrgPreferences(orgName string, pref *models.Preferences) error +} + +// GetOrgPreferences returns the preferences for a given Org +// orgName: The name of the organization whose preferences we should retrieve +func (s *DashNGoImpl) GetOrgPreferences(orgName string) (*models.Preferences, error) { + if !s.grafanaConf.IsAdminEnabled() { + return nil, errors.New("no valid Grafana Admin configured, cannot retrieve Organizations Preferences") + } + f := func() (interface{}, error) { + orgPreferences, err := s.GetClient().OrgPreferences.GetOrgPreferences() + if err != nil { + return nil, err + } + return orgPreferences.GetPayload(), nil + } + result, err := s.scopeIntoOrg(orgName, f) + if err != nil { + return nil, err + } + return result.(*models.Preferences), nil +} + +// scopeIntoOrg changes the organization, performs an operation, and reverts the Org to the previous value. +func (s *DashNGoImpl) scopeIntoOrg(orgName string, runTask func() (interface{}, error)) (interface{}, error) { + currentOrg := s.getAssociatedActiveOrg(s.GetClient()) + orgNameBackup := s.grafanaConf.OrganizationName + s.grafanaConf.OrganizationName = orgName + orgEntity, err := s.getOrgIdFromSlug(slug.Make(orgName)) + if err != nil { + return nil, err + } + defer func() { + s.grafanaConf.OrganizationName = orgNameBackup + //restore scoped Org + err = s.SetUserOrganizations(currentOrg.ID) + if err != nil { + slog.Warn("unable to restore previous Org") + } + }() + + err = s.SetUserOrganizations(orgEntity.OrgID) + if err != nil { + return nil, fmt.Errorf("unable to scope into requested org. %w", err) + } + + res, err := runTask() + if err != nil { + return nil, err + } + + return res, nil +} + +// UploadOrgPreferences Updates the preferences for a given organization. Returns error if org is not found. +func (s *DashNGoImpl) UploadOrgPreferences(orgName string, pref *models.Preferences) error { + f := func() (interface{}, error) { + if pref == nil { + return nil, fmt.Errorf("preferences are nil, cannot update") + } + + update := &models.UpdatePrefsCmd{} + update.HomeDashboardUID = pref.HomeDashboardUID + update.Language = pref.Language + update.Timezone = pref.Timezone + update.Theme = pref.Theme + update.WeekStart = pref.WeekStart + + status, err := s.GetClient().OrgPreferences.UpdateOrgPreferences(update) + if err != nil { + return nil, err + } + return status, nil + } + _, err := s.scopeIntoOrg(orgName, f) + if err != nil { + return err + } + slog.Info("Organization Preferences were update") + return nil +} diff --git a/internal/service/organizations.go b/internal/service/organizations.go index eadca01d..39ade25b 100644 --- a/internal/service/organizations.go +++ b/internal/service/organizations.go @@ -6,6 +6,7 @@ import ( "fmt" "github.com/esnet/gdg/internal/config" "github.com/esnet/gdg/internal/service/filters" + "github.com/esnet/gdg/internal/types" "github.com/gosimple/slug" "github.com/grafana/grafana-openapi-client-go/client" "github.com/grafana/grafana-openapi-client-go/client/orgs" @@ -17,25 +18,38 @@ import ( "strings" ) -// OrganizationsApi Contract definition -type OrganizationsApi interface { - ListOrganizations(filter filters.Filter) []*models.OrgDTO +type organizationCrudApi interface { + ListOrganizations(filter filters.Filter) []*types.OrgsDTOWithPreferences DownloadOrganizations(filter filters.Filter) []string UploadOrganizations(filter filters.Filter) []string - SetOrganizationByName(name string, useSlug bool) error +} + +type organizationToolsApi interface { //Manage Active Organization + SetOrganizationByName(name string, useSlug bool) error GetUserOrganization() *models.OrgDetailsDTO GetTokenOrganization() *models.OrgDetailsDTO SetUserOrganizations(id int64) error ListUserOrganizations() ([]*models.UserOrgDTO, error) - InitOrganizations() - //Org Users +} + +// organizationUserCrudApi Manages user memberships to an org +type organizationUserCrudApi interface { ListOrgUsers(orgId int64) []*models.OrgUserDTO AddUserToOrg(role, orgSlug string, userId int64) error DeleteUserFromOrg(orgId string, userId int64) error UpdateUserInOrg(role, orgSlug string, userId int64) error } +// OrganizationsApi Contract definition +type OrganizationsApi interface { + organizationCrudApi + organizationToolsApi + organizationUserCrudApi + OrgPreferencesApi + InitOrganizations() +} + func NewOrganizationFilter(args ...string) filters.Filter { filterObj := filters.NewBaseFilter() if len(args) == 0 || args[0] == "" { @@ -129,7 +143,7 @@ func (s *DashNGoImpl) SetOrganizationByName(name string, useSlug bool) error { } // ListOrganizations List all dashboards -func (s *DashNGoImpl) ListOrganizations(filter filters.Filter) []*models.OrgDTO { +func (s *DashNGoImpl) ListOrganizations(filter filters.Filter) []*types.OrgsDTOWithPreferences { if !s.grafanaConf.IsAdminEnabled() { slog.Error("No valid Grafana Admin configured, cannot retrieve Organizations List") return nil @@ -148,13 +162,21 @@ func (s *DashNGoImpl) ListOrganizations(filter filters.Filter) []*models.OrgDTO log.Fatalf("%s, err: %v", msg, err) } } - var result []*models.OrgDTO - for _, org := range orgList.Payload { + + var resultsData []*types.OrgsDTOWithPreferences + for _, org := range orgList.GetPayload() { if filter.GetFilter(filters.OrgFilter) == "" || filter.GetFilter(filters.OrgFilter) == org.Name { - result = append(result, org) + preferences, err := s.GetOrgPreferences(org.Name) + if err != nil { + slog.Warn("unable to retrieve org preferences for org", slog.String("organization", org.Name)) + preferences = &models.Preferences{} + } + resultsData = append(resultsData, &types.OrgsDTOWithPreferences{Organization: org, Preferences: preferences}) } + } - return result + + return resultsData } // DownloadOrganizations Download organizations @@ -172,12 +194,12 @@ func (s *DashNGoImpl) DownloadOrganizations(filter filters.Filter) []string { orgsListing := s.ListOrganizations(filter) for _, organisation := range orgsListing { if dsPacked, err = json.MarshalIndent(organisation, "", " "); err != nil { - slog.Error("Unable to serialize organization object", "err", err, "organization", organisation.Name) + slog.Error("Unable to serialize organization object", "err", err, "organization", organisation.Organization.Name) continue } - dsPath := buildResourcePath(slug.Make(organisation.Name), config.OrganizationResource) + dsPath := buildResourcePath(slug.Make(organisation.Organization.Name), config.OrganizationResource) if err = s.storage.WriteFile(dsPath, dsPacked); err != nil { - slog.Error("Unable to write file", "err", err.Error(), "organization", slug.Make(organisation.Name)) + slog.Error("Unable to write file", "err", err.Error(), "organization", slug.Make(organisation.Organization.Name)) } else { dataFiles = append(dataFiles, dsPath) } @@ -196,6 +218,7 @@ func (s *DashNGoImpl) UploadOrganizations(filter filters.Filter) []string { result []string rawFolder []byte ) + //syncedMap := new(sync.Map) filesInDir, err := s.storage.FindAllFiles(config.Config().GetDefaultGrafanaConfig().GetPath(config.OrganizationResource), false) if err != nil { log.Fatalf("Failed to read folders imports, err: %v", err) @@ -203,7 +226,7 @@ func (s *DashNGoImpl) UploadOrganizations(filter filters.Filter) []string { orgListing := s.ListOrganizations(filter) orgMap := map[string]bool{} for _, entry := range orgListing { - orgMap[entry.Name] = true + orgMap[entry.Organization.Name] = true } for _, file := range filesInDir { @@ -214,17 +237,34 @@ func (s *DashNGoImpl) UploadOrganizations(filter filters.Filter) []string { continue } } + var jsonOrg types.OrgsDTOWithPreferences var newOrg models.CreateOrgCommand - if err = json.Unmarshal(rawFolder, &newOrg); err != nil { + if err = json.Unmarshal(rawFolder, &jsonOrg); err != nil { slog.Warn("failed to unmarshall folder", "err", err) continue } + if jsonOrg.Organization == nil { + slog.Warn("unable to retrieve Org info from file", slog.String("file", file)) + continue + } + newOrg.Name = jsonOrg.Organization.Name rawOrgName := gjson.GetBytes(rawFolder, "name").String() if filter.GetFilter(filters.OrgFilter) != "" && rawOrgName != filter.GetFilter(filters.OrgFilter) { continue } + updateProperties := func(org *types.OrgsDTOWithPreferences) error { + if org.Preferences == nil || org.Organization == nil { + slog.Warn("Properties or Organization is nil, ignore update request") + return nil + } + return s.UploadOrgPreferences(org.Organization.Name, org.Preferences) + } if _, ok := orgMap[newOrg.Name]; ok { slog.Info("Organization already exists, skipping", "organization", newOrg.Name) + err = updateProperties(&jsonOrg) + if err != nil { + slog.Warn("unable to update Org properties for org.", slog.String("organization", newOrg.Name)) + } continue } @@ -234,6 +274,10 @@ func (s *DashNGoImpl) UploadOrganizations(filter filters.Filter) []string { continue } result = append(result, newOrg.Name) + err = updateProperties(&jsonOrg) + if err != nil { + slog.Warn("unable to update Org properties for org.", slog.String("organization", newOrg.Name)) + } } return result @@ -349,53 +393,53 @@ func (s *DashNGoImpl) AddUserToOrg(role, orgSlug string, userId int64) error { Role: role, } - orgId, err := s.getOrgIdFromSlug(orgSlug) + orgEntity, err := s.getOrgIdFromSlug(orgSlug) if err != nil { return fmt.Errorf("unable to find a valid org with slug value of %s", orgSlug) } - _, err = s.GetAdminClient().Orgs.AddOrgUser(orgId, request) + _, err = s.GetAdminClient().Orgs.AddOrgUser(orgEntity.OrgID, request) return err } func (s *DashNGoImpl) DeleteUserFromOrg(orgSlugName string, userId int64) error { - p := orgs.NewRemoveOrgUserParams() - orgId, err := s.getOrgIdFromSlug(orgSlugName) + orgEntity, err := s.getOrgIdFromSlug(orgSlugName) if err != nil { return err } - p.OrgID = orgId - p.UserID = userId - _, err = s.GetAdminClient().Orgs.RemoveOrgUser(userId, orgId) + _, err = s.GetAdminClient().Orgs.RemoveOrgUser(userId, orgEntity.OrgID) return err } -func (s *DashNGoImpl) getOrgIdFromSlug(slugName string) (int64, error) { +func (s *DashNGoImpl) getOrgIdFromSlug(slugName string) (*models.UserOrgDTO, error) { //Get Org organizations, err := s.ListUserOrganizations() if err != nil { - return 0, fmt.Errorf("unable to retrieve user organizations, %w", err) + return nil, fmt.Errorf("unable to retrieve user organizations, %w", err) } var orgId int64 + var orgEntity *models.UserOrgDTO for _, org := range organizations { if slug.Make(org.Name) == slugName { orgId = org.OrgID + orgEntity = org break } } if orgId == 0 { - return 0, fmt.Errorf("unable to find org with matching slug name of %s", slugName) + return nil, fmt.Errorf("unable to find org with matching slug name of %s", slugName) } - return orgId, nil + return orgEntity, nil + } func (s *DashNGoImpl) UpdateUserInOrg(role, orgSlug string, userId int64) error { p := orgs.NewUpdateOrgUserParams() - orgId, err := s.getOrgIdFromSlug(orgSlug) + orgEntity, err := s.getOrgIdFromSlug(orgSlug) if err != nil { return err } - p.OrgID = orgId + p.OrgID = orgEntity.OrgID p.UserID = userId p.Body = &models.UpdateOrgUserCommand{ Role: role, diff --git a/internal/types/models.go b/internal/types/models.go index afceb40d..60b4c219 100644 --- a/internal/types/models.go +++ b/internal/types/models.go @@ -11,3 +11,8 @@ type UserProfileWithAuth struct { models.UserProfileDTO Password string } + +type OrgsDTOWithPreferences struct { + Organization *models.OrgDTO `json:"organization"` + Preferences *models.Preferences `json:"preferences"` // Preferences are preferences associated with a given org. theme, dashboard, timezone, etc +} diff --git a/test/data/organizations/dumbdumb.json b/test/data/organizations/dumbdumb.json index c15912ce..0a6aa216 100644 --- a/test/data/organizations/dumbdumb.json +++ b/test/data/organizations/dumbdumb.json @@ -1,4 +1,7 @@ { - "id": 2, - "name": "DumbDumb" + "organization": { + "id": 2, + "name": "DumbDumb" + }, + "preferences": {} } \ No newline at end of file diff --git a/test/data/organizations/main-org.json b/test/data/organizations/main-org.json index 46f7f6d4..29841cce 100644 --- a/test/data/organizations/main-org.json +++ b/test/data/organizations/main-org.json @@ -1,4 +1,7 @@ { - "id": 1, - "name": "Main Org." -} \ No newline at end of file + "organization": { + "id": 1, + "name": "Main Org." + }, + "preferences": {} +} diff --git a/test/data/organizations/moo.json b/test/data/organizations/moo.json index 1e768e67..e7546367 100644 --- a/test/data/organizations/moo.json +++ b/test/data/organizations/moo.json @@ -1,4 +1,9 @@ { - "id": 3, - "name": "Moo" + "organization": { + "id": 3, + "name": "Moo" + }, + "preferences": { + "homeDashboardUID": "000000004" + } } \ No newline at end of file diff --git a/test/organizations_integration_test.go b/test/organizations_integration_test.go index adb2da9e..bd118089 100644 --- a/test/organizations_integration_test.go +++ b/test/organizations_integration_test.go @@ -24,8 +24,8 @@ func TestOrganizationCrud(t *testing.T) { orgs := apiClient.ListOrganizations(service.NewOrganizationFilter()) assert.Equal(t, len(orgs), 1) mainOrg := orgs[0] - assert.Equal(t, mainOrg.ID, int64(1)) - assert.Equal(t, mainOrg.Name, "Main Org.") + assert.Equal(t, mainOrg.Organization.ID, int64(1)) + assert.Equal(t, mainOrg.Organization.Name, "Main Org.") newOrgs := apiClient.UploadOrganizations(service.NewOrganizationFilter()) assert.Equal(t, len(newOrgs), 2) assert.True(t, slices.Contains(newOrgs, "DumbDumb")) @@ -33,7 +33,7 @@ func TestOrganizationCrud(t *testing.T) { //Filter Org orgs = apiClient.ListOrganizations(service.NewOrganizationFilter("DumbDumb")) assert.Equal(t, len(orgs), 1) - assert.Equal(t, orgs[0].Name, "DumbDumb") + assert.Equal(t, orgs[0].Organization.Name, "DumbDumb") } @@ -50,7 +50,7 @@ func TestOrganizationUserMembership(t *testing.T) { apiClient.UploadOrganizations(service.NewOrganizationFilter()) orgs := apiClient.ListOrganizations(service.NewOrganizationFilter()) sort.Slice(orgs, func(a, b int) bool { - return orgs[a].ID < orgs[b].ID + return orgs[a].Organization.ID < orgs[b].Organization.ID }) newOrg := orgs[2] //Create Users in case they aren't already present. @@ -67,25 +67,51 @@ func TestOrganizationUserMembership(t *testing.T) { } assert.NotNil(t, orgUser) //Reset if any state exists. - err := apiClient.DeleteUserFromOrg(slug.Make(newOrg.Name), orgUser.ID) + err := apiClient.DeleteUserFromOrg(slug.Make(newOrg.Organization.Name), orgUser.ID) assert.Nil(t, err) //Start CRUD test - orgUsers := apiClient.ListOrgUsers(newOrg.ID) + orgUsers := apiClient.ListOrgUsers(newOrg.Organization.ID) assert.Equal(t, len(orgUsers), 1) assert.Equal(t, orgUsers[0].Login, "admin") assert.Equal(t, orgUsers[0].Role, "Admin") - err = apiClient.AddUserToOrg("Admin", slug.Make(newOrg.Name), orgUser.ID) + err = apiClient.AddUserToOrg("Admin", slug.Make(newOrg.Organization.Name), orgUser.ID) assert.Nil(t, err) - orgUsers = apiClient.ListOrgUsers(newOrg.ID) + orgUsers = apiClient.ListOrgUsers(newOrg.Organization.ID) assert.Equal(t, len(orgUsers), 2) assert.Equal(t, orgUsers[1].Role, "Admin") - err = apiClient.UpdateUserInOrg("Viewer", slug.Make(newOrg.Name), orgUser.ID) - orgUsers = apiClient.ListOrgUsers(newOrg.ID) + err = apiClient.UpdateUserInOrg("Viewer", slug.Make(newOrg.Organization.Name), orgUser.ID) + orgUsers = apiClient.ListOrgUsers(newOrg.Organization.ID) assert.Nil(t, err) assert.Equal(t, orgUsers[1].Role, "Viewer") - err = apiClient.DeleteUserFromOrg(slug.Make(newOrg.Name), orgUser.ID) - orgUsers = apiClient.ListOrgUsers(newOrg.ID) + err = apiClient.DeleteUserFromOrg(slug.Make(newOrg.Organization.Name), orgUser.ID) + orgUsers = apiClient.ListOrgUsers(newOrg.Organization.ID) assert.Equal(t, len(orgUsers), 1) assert.Nil(t, err) } + +func TestOrganizationProperties(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test") + } + if os.Getenv("TEST_TOKEN_CONFIG") == "1" { + t.Skip("Skipping Token configuration, Organization CRUD requires Basic SecureData") + } + apiClient, _, cleanup := initTest(t, nil) + defer cleanup() + apiClient.UploadDashboards(service.NewDashboardFilter("", "", "")) + defer apiClient.DeleteAllDashboards(service.NewDashboardFilter("", "", "")) + prefs, err := apiClient.GetOrgPreferences("Main Org.") + assert.Nil(t, err) + prefs.HomeDashboardUID = "000000003" + prefs.Theme = "dark" + prefs.WeekStart = "Saturday" + err = apiClient.UploadOrgPreferences("Main Org.", prefs) + assert.Nil(t, err) + prefs, err = apiClient.GetOrgPreferences("Main Org.") + assert.Nil(t, err) + assert.NotNil(t, prefs) + assert.Equal(t, prefs.Theme, "dark") + assert.Equal(t, prefs.WeekStart, "Saturday") + assert.Equal(t, prefs.HomeDashboardUID, "000000003") +} From 5de280f93bd33edc63e0342726c478c272c3fcda Mon Sep 17 00:00:00 2001 From: Samir Faci Date: Mon, 11 Mar 2024 08:55:15 -0400 Subject: [PATCH 2/4] Switching to use crypto/rand --- internal/config/types.go | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/internal/config/types.go b/internal/config/types.go index 5af539d0..2f1909d3 100644 --- a/internal/config/types.go +++ b/internal/config/types.go @@ -1,6 +1,7 @@ package config import ( + "crypto/rand" "crypto/sha256" "encoding/json" "errors" @@ -8,7 +9,7 @@ import ( "github.com/sethvargo/go-password/password" "github.com/spf13/viper" "log/slog" - "math/rand/v2" + "math/big" "os" "path/filepath" ) @@ -74,7 +75,12 @@ func (u *UserSettings) GetPassword(username string) string { return u.defaultUserPassword(username) } - passLength := rand.IntN(u.MaxLength-u.MinLength) + u.MinLength + nBig, err := rand.Int(rand.Reader, big.NewInt(int64(u.MaxLength))) + if err != nil { + slog.Warn("Failed to get random value") + return u.defaultUserPassword(username) + } + passLength := int(nBig.Int64() + int64(u.MinLength)) res, err := password.Generate(passLength, 1, 1, false, false) if err != nil { slog.Warn("unable to generate a proper random password, falling back on default password pattern", From 1516da8707df09a282c18eff05491ec24a01b71f Mon Sep 17 00:00:00 2001 From: Samir Faci Date: Mon, 11 Mar 2024 09:28:11 -0400 Subject: [PATCH 3/4] Adding Version v0.6 Release notes --- website/config/_default/menus/menus.en.toml | 2 +- website/content/en/docs/gdg/tools_guide.md | 23 +++++++++++++++++++++ website/content/en/docs/releases/gdg_0.5.md | 2 +- 3 files changed, 25 insertions(+), 2 deletions(-) diff --git a/website/config/_default/menus/menus.en.toml b/website/config/_default/menus/menus.en.toml index 4989229d..b080f840 100644 --- a/website/config/_default/menus/menus.en.toml +++ b/website/config/_default/menus/menus.en.toml @@ -35,7 +35,7 @@ [[main]] name = "Release Notes" - url = "/docs/releases/gdg_0.5/" + url = "/docs/releases/gdg_0.6/" weight = 10 diff --git a/website/content/en/docs/gdg/tools_guide.md b/website/content/en/docs/gdg/tools_guide.md index 40038124..4ee5ea72 100644 --- a/website/content/en/docs/gdg/tools_guide.md +++ b/website/content/en/docs/gdg/tools_guide.md @@ -123,6 +123,29 @@ NOTE: this only manages top level of the orgs structure. Mainly used for a lazy Additionally `addUser`, `updateUserRole`, `deleteUser`, `listUsers` are all used to manage a user's membership within a given organization. + +### Organizations Preferences + +There are a few properties that can be set to change behavior. Keep in mind that all of these entity need to be owned by the Org, you cannot reference to a dashboard outside of a given org. + +```sh +## will set the weekstart as Tuesday and a default Org theme of dark +gdg t orgs prefs set --orgName "Main Org." --theme dark --weekstart tuesday +## Retrieve the Orgs Preferences +gdg t orgs prefs get --orgName "Main Org." +``` + + +``` +┌──────────────────┬─────────┐ +│ FIELD │ VALUE │ +├──────────────────┼─────────┤ +│ HomeDashboardUID │ │ +│ Theme │ dark │ +│ WeekStart │ tuesday │ +└──────────────────┴─────────┘ +``` + ### Users CRUD is under the 'backup' command. The tools subcommand allows you to promote a given user to a grafana admin if you have the permission to do so. diff --git a/website/content/en/docs/releases/gdg_0.5.md b/website/content/en/docs/releases/gdg_0.5.md index ece4cf7f..06931069 100644 --- a/website/content/en/docs/releases/gdg_0.5.md +++ b/website/content/en/docs/releases/gdg_0.5.md @@ -4,7 +4,7 @@ description: "Release Notes for v0.5" date: 2023-09-01T00:00:00 draft: false images: [] -weight: 197 +weight: 198 toc: true --- From 0bba3344a3065880894679be6c492ca56aa69324 Mon Sep 17 00:00:00 2001 From: Samir Faci Date: Mon, 11 Mar 2024 09:33:51 -0400 Subject: [PATCH 4/4] Code Review comments --- cli/tools/org_preferences.go | 2 +- internal/service/org_preferences.go | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/cli/tools/org_preferences.go b/cli/tools/org_preferences.go index 6fbf07a2..32236833 100644 --- a/cli/tools/org_preferences.go +++ b/cli/tools/org_preferences.go @@ -73,7 +73,7 @@ func newUpdateOrgPreferenceCmd() simplecobra.Commander { err = rootCmd.GrafanaSvc().UploadOrgPreferences(org, prefere) if err != nil { - log.Fatal("Failed to update org preferences") + log.Fatalf("Failed to update org preferences, %v", err) } slog.Info("Preferences update for organization", slog.Any("organization", org)) diff --git a/internal/service/org_preferences.go b/internal/service/org_preferences.go index 4886d1df..7b984449 100644 --- a/internal/service/org_preferences.go +++ b/internal/service/org_preferences.go @@ -67,7 +67,7 @@ func (s *DashNGoImpl) scopeIntoOrg(orgName string, runTask func() (interface{}, // UploadOrgPreferences Updates the preferences for a given organization. Returns error if org is not found. func (s *DashNGoImpl) UploadOrgPreferences(orgName string, pref *models.Preferences) error { - f := func() (interface{}, error) { + runTask := func() (interface{}, error) { if pref == nil { return nil, fmt.Errorf("preferences are nil, cannot update") } @@ -85,10 +85,10 @@ func (s *DashNGoImpl) UploadOrgPreferences(orgName string, pref *models.Preferen } return status, nil } - _, err := s.scopeIntoOrg(orgName, f) + _, err := s.scopeIntoOrg(orgName, runTask) if err != nil { return err } - slog.Info("Organization Preferences were update") + slog.Info("Organization Preferences were updated") return nil }