diff --git a/pkg/api/api.go b/pkg/api/api.go
index c3a3728338d..db7bf1df3fb 100644
--- a/pkg/api/api.go
+++ b/pkg/api/api.go
@@ -132,6 +132,13 @@ func (hs *HttpServer) registerRoutes() {
r.Post("/:id/using/:orgId", wrap(UpdateUserActiveOrg))
}, reqGrafanaAdmin)
+ // user group (admin permission required)
+ r.Group("/user-groups", func() {
+ r.Get("/search", wrap(SearchUserGroups))
+ r.Post("/", quota("user-groups"), bind(m.CreateUserGroupCommand{}), wrap(CreateUserGroup))
+ r.Delete("/:userGroupId", wrap(DeleteUserGroupById))
+ }, reqGrafanaAdmin)
+
// org information available to all users.
r.Group("/org", func() {
r.Get("/", wrap(GetOrgCurrent))
diff --git a/pkg/api/user.go b/pkg/api/user.go
index 39e7fce1462..a0a2592cca1 100644
--- a/pkg/api/user.go
+++ b/pkg/api/user.go
@@ -218,7 +218,7 @@ func SearchUsers(c *middleware.Context) Response {
return Json(200, query.Result.Users)
}
-// GET /api/search
+// GET /api/users/search
func SearchUsersWithPaging(c *middleware.Context) Response {
query, err := searchUser(c)
if err != nil {
diff --git a/pkg/api/user_group.go b/pkg/api/user_group.go
new file mode 100644
index 00000000000..392fb52ebf1
--- /dev/null
+++ b/pkg/api/user_group.go
@@ -0,0 +1,66 @@
+package api
+
+import (
+ "github.com/grafana/grafana/pkg/bus"
+ "github.com/grafana/grafana/pkg/metrics"
+ "github.com/grafana/grafana/pkg/middleware"
+ m "github.com/grafana/grafana/pkg/models"
+ "github.com/grafana/grafana/pkg/util"
+)
+
+// POST /api/user-groups
+func CreateUserGroup(c *middleware.Context, cmd m.CreateUserGroupCommand) Response {
+ cmd.OrgId = c.OrgId
+ if err := bus.Dispatch(&cmd); err != nil {
+ if err == m.ErrUserGroupNameTaken {
+ return ApiError(409, "User Group name taken", err)
+ }
+ return ApiError(500, "Failed to create User Group", err)
+ }
+
+ metrics.M_Api_UserGroup_Create.Inc(1)
+
+ return Json(200, &util.DynMap{
+ "userGroupId": cmd.Result.Id,
+ "message": "User Group created",
+ })
+}
+
+// DELETE /api/user-groups/:userGroupId
+func DeleteUserGroupById(c *middleware.Context) Response {
+ if err := bus.Dispatch(&m.DeleteUserGroupCommand{Id: c.ParamsInt64(":userGroupId")}); err != nil {
+ if err == m.ErrUserGroupNotFound {
+ return ApiError(404, "Failed to delete User Group. ID not found", nil)
+ }
+ return ApiError(500, "Failed to update User Group", err)
+ }
+ return ApiSuccess("User Group deleted")
+}
+
+// GET /api/user-groups/search
+func SearchUserGroups(c *middleware.Context) Response {
+ perPage := c.QueryInt("perpage")
+ if perPage <= 0 {
+ perPage = 1000
+ }
+ page := c.QueryInt("page")
+ if page < 1 {
+ page = 1
+ }
+
+ query := m.SearchUserGroupsQuery{
+ Query: c.Query("query"),
+ Name: c.Query("name"),
+ Page: page,
+ Limit: perPage,
+ }
+
+ if err := bus.Dispatch(&query); err != nil {
+ return ApiError(500, "Failed to search User Groups", err)
+ }
+
+ query.Result.Page = page
+ query.Result.PerPage = perPage
+
+ return Json(200, query.Result)
+}
diff --git a/pkg/api/user_group_test.go b/pkg/api/user_group_test.go
new file mode 100644
index 00000000000..730461ac8e8
--- /dev/null
+++ b/pkg/api/user_group_test.go
@@ -0,0 +1,71 @@
+package api
+
+import (
+ "testing"
+
+ "github.com/grafana/grafana/pkg/bus"
+ "github.com/grafana/grafana/pkg/components/simplejson"
+ "github.com/grafana/grafana/pkg/models"
+
+ . "github.com/smartystreets/goconvey/convey"
+)
+
+func TestUserGroupApiEndpoint(t *testing.T) {
+ Convey("Given two user groups", t, func() {
+ mockResult := models.SearchUserGroupQueryResult{
+ UserGroups: []*models.UserGroup{
+ {Name: "userGroup1"},
+ {Name: "userGroup2"},
+ },
+ TotalCount: 2,
+ }
+
+ Convey("When searching with no parameters", func() {
+ loggedInUserScenario("When calling GET on", "/api/user-groups/search", func(sc *scenarioContext) {
+ var sentLimit int
+ var sendPage int
+ bus.AddHandler("test", func(query *models.SearchUserGroupsQuery) error {
+ query.Result = mockResult
+
+ sentLimit = query.Limit
+ sendPage = query.Page
+
+ return nil
+ })
+
+ sc.handlerFunc = SearchUserGroups
+ sc.fakeReqWithParams("GET", sc.url, map[string]string{}).exec()
+
+ So(sentLimit, ShouldEqual, 1000)
+ So(sendPage, ShouldEqual, 1)
+
+ respJSON, err := simplejson.NewJson(sc.resp.Body.Bytes())
+ So(err, ShouldBeNil)
+
+ So(respJSON.Get("totalCount").MustInt(), ShouldEqual, 2)
+ So(len(respJSON.Get("userGroups").MustArray()), ShouldEqual, 2)
+ })
+ })
+
+ Convey("When searching with page and perpage parameters", func() {
+ loggedInUserScenario("When calling GET on", "/api/user-groups/search", func(sc *scenarioContext) {
+ var sentLimit int
+ var sendPage int
+ bus.AddHandler("test", func(query *models.SearchUserGroupsQuery) error {
+ query.Result = mockResult
+
+ sentLimit = query.Limit
+ sendPage = query.Page
+
+ return nil
+ })
+
+ sc.handlerFunc = SearchUserGroups
+ sc.fakeReqWithParams("GET", sc.url, map[string]string{"perpage": "10", "page": "2"}).exec()
+
+ So(sentLimit, ShouldEqual, 10)
+ So(sendPage, ShouldEqual, 2)
+ })
+ })
+ })
+}
diff --git a/pkg/metrics/metrics.go b/pkg/metrics/metrics.go
index c23a53009a9..002cc840d97 100644
--- a/pkg/metrics/metrics.go
+++ b/pkg/metrics/metrics.go
@@ -35,6 +35,7 @@ var (
M_Api_Dashboard_Snapshot_Create Counter
M_Api_Dashboard_Snapshot_External Counter
M_Api_Dashboard_Snapshot_Get Counter
+ M_Api_UserGroup_Create Counter
M_Models_Dashboard_Insert Counter
M_Alerting_Result_State_Alerting Counter
M_Alerting_Result_State_Ok Counter
@@ -92,6 +93,8 @@ func initMetricVars(settings *MetricSettings) {
M_Api_User_SignUpCompleted = RegCounter("api.user.signup_completed")
M_Api_User_SignUpInvite = RegCounter("api.user.signup_invite")
+ M_Api_UserGroup_Create = RegCounter("api.usergroup.create")
+
M_Api_Dashboard_Save = RegTimer("api.dashboard.save")
M_Api_Dashboard_Get = RegTimer("api.dashboard.get")
M_Api_Dashboard_Search = RegTimer("api.dashboard.search")
diff --git a/pkg/models/user_group.go b/pkg/models/user_group.go
index abfdad64d4b..c9e0a54025e 100644
--- a/pkg/models/user_group.go
+++ b/pkg/models/user_group.go
@@ -13,12 +13,12 @@ var (
// UserGroup model
type UserGroup struct {
- Id int64
- OrgId int64
- Name string
+ Id int64 `json:"id"`
+ OrgId int64 `json:"orgId"`
+ Name string `json:"name"`
- Created time.Time
- Updated time.Time
+ Created time.Time `json:"created"`
+ Updated time.Time `json:"updated"`
}
// ---------------------
@@ -26,7 +26,7 @@ type UserGroup struct {
type CreateUserGroupCommand struct {
Name string `json:"name" binding:"Required"`
- OrgId int64 `json:"orgId" binding:"Required"`
+ OrgId int64 `json:"-"`
Result UserGroup `json:"-"`
}
@@ -46,5 +46,12 @@ type SearchUserGroupsQuery struct {
Limit int
Page int
- Result []*UserGroup
+ Result SearchUserGroupQueryResult
+}
+
+type SearchUserGroupQueryResult struct {
+ TotalCount int64 `json:"totalCount"`
+ UserGroups []*UserGroup `json:"userGroups"`
+ Page int `json:"page"`
+ PerPage int `json:"perPage"`
}
diff --git a/pkg/services/sqlstore/user_group.go b/pkg/services/sqlstore/user_group.go
index f4d1396e82b..996e318d694 100644
--- a/pkg/services/sqlstore/user_group.go
+++ b/pkg/services/sqlstore/user_group.go
@@ -83,17 +83,37 @@ func isUserGroupNameTaken(name string, existingId int64, sess *session) (bool, e
}
func SearchUserGroups(query *m.SearchUserGroupsQuery) error {
- query.Result = make([]*m.UserGroup, 0)
+ query.Result = m.SearchUserGroupQueryResult{
+ UserGroups: make([]*m.UserGroup, 0),
+ }
+ queryWithWildcards := "%" + query.Query + "%"
+
sess := x.Table("user_group")
if query.Query != "" {
- sess.Where("name LIKE ?", query.Query+"%")
+ sess.Where("name LIKE ?", queryWithWildcards)
}
if query.Name != "" {
sess.Where("name=?", query.Name)
}
- sess.Limit(query.Limit, query.Limit*query.Page)
+ offset := query.Limit * (query.Page - 1)
+ sess.Limit(query.Limit, offset)
sess.Cols("id", "name")
- err := sess.Find(&query.Result)
+ if err := sess.Find(&query.Result.UserGroups); err != nil {
+ return err
+ }
+
+ userGroup := m.UserGroup{}
+
+ countSess := x.Table("user_group")
+ if query.Query != "" {
+ countSess.Where("name LIKE ?", queryWithWildcards)
+ }
+ if query.Name != "" {
+ countSess.Where("name=?", query.Name)
+ }
+ count, err := countSess.Count(&userGroup)
+ query.Result.TotalCount = count
+
return err
}
diff --git a/pkg/services/sqlstore/user_group_test.go b/pkg/services/sqlstore/user_group_test.go
index acf11a74e54..63fddc89012 100644
--- a/pkg/services/sqlstore/user_group_test.go
+++ b/pkg/services/sqlstore/user_group_test.go
@@ -36,13 +36,13 @@ func TestUserGroupCommandsAndQueries(t *testing.T) {
So(err, ShouldBeNil)
Convey("Should be able to create user groups and add users", func() {
- query := &m.SearchUserGroupsQuery{Name: "group1 name"}
+ query := &m.SearchUserGroupsQuery{Name: "group1 name", Page: 1, Limit: 10}
err = SearchUserGroups(query)
So(err, ShouldBeNil)
- So(query.Page, ShouldEqual, 0)
+ So(query.Page, ShouldEqual, 1)
- userGroup1 := query.Result[0]
- So(query.Result[0].Name, ShouldEqual, "group1 name")
+ userGroup1 := query.Result.UserGroups[0]
+ So(userGroup1.Name, ShouldEqual, "group1 name")
err = AddUserGroupMember(&m.AddUserGroupMemberCommand{OrgId: 1, UserGroupId: userGroup1.Id, UserId: userIds[0]})
So(err, ShouldBeNil)
@@ -55,10 +55,16 @@ func TestUserGroupCommandsAndQueries(t *testing.T) {
})
Convey("Should be able to search for user groups", func() {
- query := &m.SearchUserGroupsQuery{Query: "group"}
+ query := &m.SearchUserGroupsQuery{Query: "group", Page: 1}
err = SearchUserGroups(query)
So(err, ShouldBeNil)
- So(len(query.Result), ShouldEqual, 2)
+ So(len(query.Result.UserGroups), ShouldEqual, 2)
+ So(query.Result.TotalCount, ShouldEqual, 2)
+
+ query2 := &m.SearchUserGroupsQuery{Query: ""}
+ err = SearchUserGroups(query2)
+ So(err, ShouldBeNil)
+ So(len(query2.Result.UserGroups), ShouldEqual, 2)
})
Convey("Should be able to remove users from a group", func() {
diff --git a/public/app/core/components/sidemenu/sidemenu.ts b/public/app/core/components/sidemenu/sidemenu.ts
index 62258a25e7d..ccecc41b2fc 100644
--- a/public/app/core/components/sidemenu/sidemenu.ts
+++ b/public/app/core/components/sidemenu/sidemenu.ts
@@ -64,6 +64,10 @@ export class SideMenuCtrl {
text: "Users",
url: this.getUrl("/org/users")
});
+ this.orgMenu.push({
+ text: "User Groups",
+ url: this.getUrl("/org/user-groups")
+ });
this.orgMenu.push({
text: "API Keys",
url: this.getUrl("/org/apikeys")
diff --git a/public/app/core/routes/routes.ts b/public/app/core/routes/routes.ts
index a7565b8e167..9faa7211da4 100644
--- a/public/app/core/routes/routes.ts
+++ b/public/app/core/routes/routes.ts
@@ -83,6 +83,12 @@ function setupAngularRoutes($routeProvider, $locationProvider) {
controller : 'OrgApiKeysCtrl',
resolve: loadOrgBundle,
})
+ .when('/org/user-groups', {
+ templateUrl: 'public/app/features/org/partials/user_groups.html',
+ controller : 'UserGroupsCtrl',
+ controllerAs: 'ctrl',
+ resolve: loadOrgBundle,
+ })
.when('/profile', {
templateUrl: 'public/app/features/org/partials/profile.html',
controller : 'ProfileCtrl',
diff --git a/public/app/features/org/all.js b/public/app/features/org/all.js
index e206583a8c7..07328700a7a 100644
--- a/public/app/features/org/all.js
+++ b/public/app/features/org/all.js
@@ -1,7 +1,6 @@
define([
'./org_users_ctrl',
'./profile_ctrl',
- './org_users_ctrl',
'./select_org_ctrl',
'./change_password_ctrl',
'./newOrgCtrl',
@@ -9,4 +8,5 @@ define([
'./orgApiKeysCtrl',
'./orgDetailsCtrl',
'./prefs_control',
+ './user_groups_ctrl',
], function () {});
diff --git a/public/app/features/org/partials/user_groups.html b/public/app/features/org/partials/user_groups.html
new file mode 100644
index 00000000000..fbaaccd42e2
--- /dev/null
+++ b/public/app/features/org/partials/user_groups.html
@@ -0,0 +1,60 @@
+
Id | +Name | ++ |
---|---|---|
{{userGroup.id}} | +{{userGroup.name}} | ++ + + Edit + + + + + + | +