From f08932b78acf21103982f5270def7c1d2de80169 Mon Sep 17 00:00:00 2001 From: Marcus Efraimsson Date: Mon, 29 Jan 2018 13:51:01 +0100 Subject: [PATCH] WIP: folder api. #10630 --- pkg/api/api.go | 11 +- pkg/api/dashboard.go | 26 +- pkg/api/dashboard_test.go | 46 +++- pkg/api/dtos/folder.go | 18 ++ pkg/api/folders.go | 221 ++++++++++++++++ pkg/api/folders_test.go | 328 ++++++++++++++++++++++++ pkg/models/dashboards.go | 16 +- pkg/models/folders.go | 63 +++++ pkg/services/sqlstore/dashboard.go | 37 ++- pkg/services/sqlstore/dashboard_test.go | 6 +- 10 files changed, 725 insertions(+), 47 deletions(-) create mode 100644 pkg/api/dtos/folder.go create mode 100644 pkg/api/folders.go create mode 100644 pkg/api/folders_test.go create mode 100644 pkg/models/folders.go diff --git a/pkg/api/api.go b/pkg/api/api.go index 086d7345483..9a043b958b9 100644 --- a/pkg/api/api.go +++ b/pkg/api/api.go @@ -240,6 +240,15 @@ func (hs *HttpServer) registerRoutes() { apiRoute.Any("/datasources/proxy/:id/*", reqSignedIn, hs.ProxyDataSourceRequest) apiRoute.Any("/datasources/proxy/:id", reqSignedIn, hs.ProxyDataSourceRequest) + // Folders + apiRoute.Group("/folders", func(folderRoute RouteRegister) { + folderRoute.Get("/", wrap(GetFolders)) + folderRoute.Get("/:id", wrap(GetFolderById)) + folderRoute.Post("/", bind(m.CreateFolderCommand{}), wrap(CreateFolder)) + folderRoute.Put("/:id", bind(m.UpdateFolderCommand{}), wrap(UpdateFolder)) + folderRoute.Delete("/:id", wrap(DeleteFolder)) + }) + // Dashboard apiRoute.Group("/dashboards", func(dashboardRoute RouteRegister) { dashboardRoute.Get("/db/:slug", wrap(GetDashboard)) @@ -252,8 +261,6 @@ func (hs *HttpServer) registerRoutes() { dashboardRoute.Get("/tags", GetDashboardTags) dashboardRoute.Post("/import", bind(dtos.ImportDashboardCommand{}), wrap(ImportDashboard)) - dashboardRoute.Get("/folders", wrap(GetFoldersForSignedInUser)) - dashboardRoute.Group("/id/:dashboardId", func(dashIdRoute RouteRegister) { dashIdRoute.Get("/versions", wrap(GetDashboardVersions)) dashIdRoute.Get("/versions/:id", wrap(GetDashboardVersion)) diff --git a/pkg/api/dashboard.go b/pkg/api/dashboard.go index cf9f6b6bb8b..a8637dfc7e6 100644 --- a/pkg/api/dashboard.go +++ b/pkg/api/dashboard.go @@ -130,6 +130,11 @@ func getDashboardHelper(orgId int64, slug string, id int64) (*m.Dashboard, Respo if err := bus.Dispatch(&query); err != nil { return nil, ApiError(404, "Dashboard not found", err) } + + if query.Result.IsFolder { + return nil, ApiError(404, "Dashboard not found", m.ErrDashboardNotFound) + } + return query.Result, nil } @@ -164,6 +169,11 @@ func PostDashboard(c *middleware.Context, cmd m.SaveDashboardCommand) Response { // if new dashboard, use parent folder permissions instead if dashId == 0 { dashId = cmd.FolderId + } else { + _, rsp := getDashboardHelper(c.OrgId, "", dashId) + if rsp != nil { + return rsp + } } guardian := guardian.NewDashboardGuardian(dashId, c.OrgId, c.SignedInUser) @@ -439,19 +449,3 @@ func GetDashboardTags(c *middleware.Context) { c.JSON(200, query.Result) } - -func GetFoldersForSignedInUser(c *middleware.Context) Response { - title := c.Query("query") - query := m.GetFoldersForSignedInUserQuery{ - OrgId: c.OrgId, - SignedInUser: c.SignedInUser, - Title: title, - } - - err := bus.Dispatch(&query) - if err != nil { - return ApiError(500, "Failed to get folders from database", err) - } - - return Json(200, query.Result) -} diff --git a/pkg/api/dashboard_test.go b/pkg/api/dashboard_test.go index e6228878625..af64767c713 100644 --- a/pkg/api/dashboard_test.go +++ b/pkg/api/dashboard_test.go @@ -33,6 +33,44 @@ func (repo *fakeDashboardRepo) SaveDashboard(json *dashboards.SaveDashboardItem) var fakeRepo *fakeDashboardRepo func TestDashboardApiEndpoint(t *testing.T) { + Convey("Given a folder", t, func() { + fakeFolder := m.NewDashboardFolder("Folder") + fakeFolder.Id = 1 + fakeFolder.HasAcl = false + + bus.AddHandler("test", func(query *m.GetDashboardQuery) error { + query.Result = fakeFolder + return nil + }) + + cmd := m.SaveDashboardCommand{ + Dashboard: simplejson.NewFromAny(map[string]interface{}{ + "title": fakeFolder.Title, + "id": fakeFolder.Id, + }), + IsFolder: true, + } + + Convey("When user is an Org Editor", func() { + role := m.ROLE_EDITOR + + loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/dashboards/1", "/api/dashboards/:id", role, func(sc *scenarioContext) { + CallGetDashboard(sc) + So(sc.resp.Code, ShouldEqual, 404) + }) + + postDashboardScenario("When calling POST on", "/api/dashboards", "/api/dashboards", role, cmd, func(sc *scenarioContext) { + CallPostDashboard(sc) + So(sc.resp.Code, ShouldEqual, 404) + }) + + loggedInUserScenarioWithRole("When calling DELETE on", "DELETE", "/api/dashboards/1", "/api/dashboards/:id", role, func(sc *scenarioContext) { + CallDeleteDashboard(sc) + So(sc.resp.Code, ShouldEqual, 404) + }) + }) + }) + Convey("Given a dashboard with a parent folder which does not have an acl", t, func() { fakeDash := m.NewDashboard("Child dash") fakeDash.Id = 1 @@ -426,8 +464,7 @@ func TestDashboardApiEndpoint(t *testing.T) { } func GetDashboardShouldReturn200(sc *scenarioContext) dtos.DashboardFullWithMeta { - sc.handlerFunc = GetDashboard - sc.fakeReqWithParams("GET", sc.url, map[string]string{}).exec() + CallGetDashboard(sc) So(sc.resp.Code, ShouldEqual, 200) @@ -438,6 +475,11 @@ func GetDashboardShouldReturn200(sc *scenarioContext) dtos.DashboardFullWithMeta return dash } +func CallGetDashboard(sc *scenarioContext) { + sc.handlerFunc = GetDashboard + sc.fakeReqWithParams("GET", sc.url, map[string]string{}).exec() +} + func CallGetDashboardVersion(sc *scenarioContext) { bus.AddHandler("test", func(query *m.GetDashboardVersionQuery) error { query.Result = &m.DashboardVersion{} diff --git a/pkg/api/dtos/folder.go b/pkg/api/dtos/folder.go new file mode 100644 index 00000000000..7ce7825fb6a --- /dev/null +++ b/pkg/api/dtos/folder.go @@ -0,0 +1,18 @@ +package dtos + +import "time" + +type Folder struct { + Id int64 `json:"id"` + Title string `json:"title"` + Slug string `json:"slug"` + HasAcl bool `json:"hasAcl"` + CanSave bool `json:"canSave"` + CanEdit bool `json:"canEdit"` + CanAdmin bool `json:"canAdmin"` + CreatedBy string `json:"createdBy"` + Created time.Time `json:"created"` + UpdatedBy string `json:"updatedBy"` + Updated time.Time `json:"updated"` + Version int `json:"version"` +} diff --git a/pkg/api/folders.go b/pkg/api/folders.go new file mode 100644 index 00000000000..22fffc15b91 --- /dev/null +++ b/pkg/api/folders.go @@ -0,0 +1,221 @@ +package api + +import ( + "fmt" + + "github.com/grafana/grafana/pkg/api/dtos" + "github.com/grafana/grafana/pkg/bus" + "github.com/grafana/grafana/pkg/middleware" + m "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/services/dashboards" + "github.com/grafana/grafana/pkg/services/guardian" + "github.com/grafana/grafana/pkg/util" +) + +func getFolderHelper(orgId int64, slug string, id int64) (*m.Dashboard, Response) { + query := m.GetDashboardQuery{Slug: slug, Id: id, OrgId: orgId} + if err := bus.Dispatch(&query); err != nil { + if err == m.ErrDashboardNotFound { + err = m.ErrFolderNotFound + } + + return nil, ApiError(404, "Folder not found", err) + } + + if !query.Result.IsFolder { + return nil, ApiError(404, "Folder not found", m.ErrFolderNotFound) + } + + return query.Result, nil +} + +func folderGuardianResponse(err error) Response { + if err != nil { + return ApiError(500, "Error while checking folder permissions", err) + } + + return ApiError(403, "Access denied to this folder", nil) +} + +func GetFolders(c *middleware.Context) Response { + title := c.Query("query") + query := m.GetFoldersQuery{ + OrgId: c.OrgId, + SignedInUser: c.SignedInUser, + Title: title, + } + + err := bus.Dispatch(&query) + if err != nil { + return ApiError(500, "Failed to retrieve folders", err) + } + + return Json(200, query.Result) +} + +func GetFolderById(c *middleware.Context) Response { + folder, rsp := getFolderHelper(c.OrgId, "", c.ParamsInt64(":id")) + if rsp != nil { + return rsp + } + + guardian := guardian.NewDashboardGuardian(folder.Id, c.OrgId, c.SignedInUser) + if canView, err := guardian.CanView(); err != nil || !canView { + fmt.Printf("%v", err) + return folderGuardianResponse(err) + } + + return Json(200, toDto(guardian, folder)) +} + +func CreateFolder(c *middleware.Context, cmd m.CreateFolderCommand) Response { + cmd.OrgId = c.OrgId + cmd.UserId = c.UserId + + dashFolder := m.NewDashboardFolder(cmd.Title) + + guardian := guardian.NewDashboardGuardian(0, c.OrgId, c.SignedInUser) + if canSave, err := guardian.CanSave(); err != nil || !canSave { + return folderGuardianResponse(err) + } + + // Check if Title is empty + if dashFolder.Title == "" { + return ApiError(400, m.ErrFolderTitleEmpty.Error(), nil) + } + + limitReached, err := middleware.QuotaReached(c, "folder") + if err != nil { + return ApiError(500, "failed to get quota", err) + } + if limitReached { + return ApiError(403, "Quota reached", nil) + } + + dashFolder.CreatedBy = c.UserId + dashFolder.UpdatedBy = c.UserId + + dashItem := &dashboards.SaveDashboardItem{ + Dashboard: dashFolder, + OrgId: c.OrgId, + UserId: c.UserId, + } + + folder, err := dashboards.GetRepository().SaveDashboard(dashItem) + + if err != nil { + return toFolderError(err) + } + + return Json(200, toDto(guardian, folder)) +} + +func UpdateFolder(c *middleware.Context, cmd m.UpdateFolderCommand) Response { + cmd.OrgId = c.OrgId + cmd.UserId = c.UserId + + dashFolder, rsp := getFolderHelper(c.OrgId, "", c.ParamsInt64(":id")) + if rsp != nil { + return rsp + } + + guardian := guardian.NewDashboardGuardian(dashFolder.Id, c.OrgId, c.SignedInUser) + if canSave, err := guardian.CanSave(); err != nil || !canSave { + return folderGuardianResponse(err) + } + + dashFolder.Data.Set("title", cmd.Title) + dashFolder.Title = cmd.Title + dashFolder.Data.Set("version", cmd.Version) + dashFolder.Version = cmd.Version + dashFolder.UpdatedBy = c.UserId + + // Check if Title is empty + if dashFolder.Title == "" { + return ApiError(400, m.ErrFolderTitleEmpty.Error(), nil) + } + + dashItem := &dashboards.SaveDashboardItem{ + Dashboard: dashFolder, + OrgId: c.OrgId, + UserId: c.UserId, + } + + folder, err := dashboards.GetRepository().SaveDashboard(dashItem) + + if err != nil { + return toFolderError(err) + } + + return Json(200, toDto(guardian, folder)) +} + +func DeleteFolder(c *middleware.Context) Response { + dashFolder, rsp := getFolderHelper(c.OrgId, "", c.ParamsInt64(":id")) + if rsp != nil { + return rsp + } + + guardian := guardian.NewDashboardGuardian(dashFolder.Id, c.OrgId, c.SignedInUser) + if canSave, err := guardian.CanSave(); err != nil || !canSave { + return folderGuardianResponse(err) + } + + deleteCmd := m.DeleteDashboardCommand{OrgId: c.OrgId, Id: dashFolder.Id} + if err := bus.Dispatch(&deleteCmd); err != nil { + return ApiError(500, "Failed to delete folder", err) + } + + var resp = map[string]interface{}{"title": dashFolder.Title} + return Json(200, resp) +} + +func toDto(guardian *guardian.DashboardGuardian, folder *m.Dashboard) dtos.Folder { + canEdit, _ := guardian.CanEdit() + canSave, _ := guardian.CanSave() + canAdmin, _ := guardian.CanAdmin() + + // Finding creator and last updater of the folder + updater, creator := "Anonymous", "Anonymous" + if folder.UpdatedBy > 0 { + updater = getUserLogin(folder.UpdatedBy) + } + if folder.CreatedBy > 0 { + creator = getUserLogin(folder.CreatedBy) + } + + return dtos.Folder{ + Id: folder.Id, + Title: folder.Title, + Slug: folder.Slug, + HasAcl: folder.HasAcl, + CanSave: canSave, + CanEdit: canEdit, + CanAdmin: canAdmin, + CreatedBy: creator, + Created: folder.Created, + UpdatedBy: updater, + Updated: folder.Updated, + Version: folder.Version, + } +} + +func toFolderError(err error) Response { + if err == m.ErrDashboardTitleEmpty { + return ApiError(400, m.ErrFolderTitleEmpty.Error(), nil) + } + + if err == m.ErrDashboardWithSameNameExists { + return Json(412, util.DynMap{"status": "name-exists", "message": m.ErrFolderWithSameNameExists.Error()}) + } + + if err == m.ErrDashboardVersionMismatch { + return Json(412, util.DynMap{"status": "version-mismatch", "message": m.ErrFolderVersionMismatch.Error()}) + } + + if err == m.ErrDashboardNotFound { + return Json(404, util.DynMap{"status": "not-found", "message": m.ErrFolderNotFound.Error()}) + } + + return ApiError(500, "Failed to create folder", err) +} diff --git a/pkg/api/folders_test.go b/pkg/api/folders_test.go new file mode 100644 index 00000000000..cbe7eb7a280 --- /dev/null +++ b/pkg/api/folders_test.go @@ -0,0 +1,328 @@ +package api + +import ( + "encoding/json" + "path/filepath" + "testing" + + "github.com/go-macaron/session" + "github.com/grafana/grafana/pkg/api/dtos" + "github.com/grafana/grafana/pkg/bus" + "github.com/grafana/grafana/pkg/middleware" + m "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/services/dashboards" + macaron "gopkg.in/macaron.v1" + + . "github.com/smartystreets/goconvey/convey" +) + +func TestFoldersApiEndpoint(t *testing.T) { + Convey("Given a dashboard", t, func() { + fakeDash := m.NewDashboard("Child dash") + fakeDash.Id = 1 + fakeDash.FolderId = 1 + fakeDash.HasAcl = false + + bus.AddHandler("test", func(query *m.GetDashboardQuery) error { + query.Result = fakeDash + return nil + }) + + updateFolderCmd := m.UpdateFolderCommand{} + + Convey("When user is an Org Editor", func() { + role := m.ROLE_EDITOR + + loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/folders/1", "/api/folders/:id", role, func(sc *scenarioContext) { + callGetFolder(sc) + So(sc.resp.Code, ShouldEqual, 404) + }) + + updateFolderScenario("When calling PUT on", "/api/folders/1", "/api/folders/:id", role, updateFolderCmd, func(sc *scenarioContext) { + callUpdateFolder(sc) + So(sc.resp.Code, ShouldEqual, 404) + }) + + loggedInUserScenarioWithRole("When calling DELETE on", "DELETE", "/api/folders/1", "/api/folders/:id", role, func(sc *scenarioContext) { + callDeleteFolder(sc) + So(sc.resp.Code, ShouldEqual, 404) + }) + }) + }) + + Convey("Given a folder which does not have an acl", t, func() { + fakeFolder := m.NewDashboardFolder("Folder") + fakeFolder.Id = 1 + fakeFolder.HasAcl = false + + bus.AddHandler("test", func(query *m.GetDashboardQuery) error { + query.Result = fakeFolder + return nil + }) + + viewerRole := m.ROLE_VIEWER + editorRole := m.ROLE_EDITOR + + aclMockResp := []*m.DashboardAclInfoDTO{ + {Role: &viewerRole, Permission: m.PERMISSION_VIEW}, + {Role: &editorRole, Permission: m.PERMISSION_EDIT}, + } + + bus.AddHandler("test", func(query *m.GetDashboardAclInfoListQuery) error { + query.Result = aclMockResp + return nil + }) + + bus.AddHandler("test", func(query *m.GetTeamsByUserQuery) error { + query.Result = []*m.Team{} + return nil + }) + + cmd := m.CreateFolderCommand{ + Title: fakeFolder.Title, + } + + Convey("When user is an Org Viewer", func() { + role := m.ROLE_VIEWER + + loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/folders/1", "/api/folders/:id", role, func(sc *scenarioContext) { + folder := getFolderShouldReturn200(sc) + + Convey("Should not be able to edit or save folder", func() { + So(folder.CanEdit, ShouldBeFalse) + So(folder.CanSave, ShouldBeFalse) + So(folder.CanAdmin, ShouldBeFalse) + }) + }) + + loggedInUserScenarioWithRole("When calling DELETE on", "DELETE", "/api/folders/1", "/api/folders/:id", role, func(sc *scenarioContext) { + callDeleteFolder(sc) + So(sc.resp.Code, ShouldEqual, 403) + }) + + createFolderScenario("When calling POST on", "/api/folders", "/api/folders", role, cmd, func(sc *scenarioContext) { + callCreateFolder(sc) + So(sc.resp.Code, ShouldEqual, 403) + }) + }) + + Convey("When user is an Org Editor", func() { + role := m.ROLE_EDITOR + + loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/folders/1", "/api/folders/:id", role, func(sc *scenarioContext) { + folder := getFolderShouldReturn200(sc) + + Convey("Should be able to edit or save folder", func() { + So(folder.CanEdit, ShouldBeTrue) + So(folder.CanSave, ShouldBeTrue) + So(folder.CanAdmin, ShouldBeFalse) + }) + }) + + loggedInUserScenarioWithRole("When calling DELETE on", "DELETE", "/api/folders/1", "/api/folders/:id", role, func(sc *scenarioContext) { + callDeleteFolder(sc) + So(sc.resp.Code, ShouldEqual, 200) + }) + + createFolderScenario("When calling POST on", "/api/folders", "/api/folders", role, cmd, func(sc *scenarioContext) { + callCreateFolder(sc) + So(sc.resp.Code, ShouldEqual, 200) + }) + }) + }) + + Convey("Given a folder which have an acl", t, func() { + fakeFolder := m.NewDashboardFolder("Folder") + fakeFolder.Id = 1 + fakeFolder.HasAcl = true + + bus.AddHandler("test", func(query *m.GetDashboardQuery) error { + query.Result = fakeFolder + return nil + }) + + aclMockResp := []*m.DashboardAclInfoDTO{ + { + DashboardId: 1, + Permission: m.PERMISSION_EDIT, + UserId: 200, + }, + } + + bus.AddHandler("test", func(query *m.GetDashboardAclInfoListQuery) error { + query.Result = aclMockResp + return nil + }) + + bus.AddHandler("test", func(query *m.GetTeamsByUserQuery) error { + query.Result = []*m.Team{} + return nil + }) + + cmd := m.CreateFolderCommand{ + Title: fakeFolder.Title, + } + + Convey("When user is an Org Viewer and has no permissions for this folder", func() { + role := m.ROLE_VIEWER + + loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/folders/1", "/api/folders/:id", role, func(sc *scenarioContext) { + sc.handlerFunc = GetFolderById + sc.fakeReqWithParams("GET", sc.url, map[string]string{}).exec() + + Convey("Should be denied access", func() { + So(sc.resp.Code, ShouldEqual, 403) + }) + }) + + loggedInUserScenarioWithRole("When calling DELETE on", "DELETE", "/api/folders/1", "/api/folders/:id", role, func(sc *scenarioContext) { + callDeleteFolder(sc) + So(sc.resp.Code, ShouldEqual, 403) + }) + + createFolderScenario("When calling POST on", "/api/folders", "/api/folders", role, cmd, func(sc *scenarioContext) { + callCreateFolder(sc) + So(sc.resp.Code, ShouldEqual, 403) + }) + }) + + Convey("When user is an Org Editor and has no permissions for this folder", func() { + role := m.ROLE_EDITOR + + loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/folders/1", "/api/folders/:id", role, func(sc *scenarioContext) { + sc.handlerFunc = GetFolderById + sc.fakeReqWithParams("GET", sc.url, map[string]string{}).exec() + + Convey("Should be denied access", func() { + So(sc.resp.Code, ShouldEqual, 403) + }) + }) + + loggedInUserScenarioWithRole("When calling DELETE on", "DELETE", "/api/folders/1", "/api/folders/:id", role, func(sc *scenarioContext) { + callDeleteFolder(sc) + So(sc.resp.Code, ShouldEqual, 403) + }) + + createFolderScenario("When calling POST on", "/api/folders", "/api/folders", role, cmd, func(sc *scenarioContext) { + callCreateFolder(sc) + So(sc.resp.Code, ShouldEqual, 403) + }) + }) + }) +} + +func getFolderShouldReturn200(sc *scenarioContext) dtos.Folder { + callGetFolder(sc) + + So(sc.resp.Code, ShouldEqual, 200) + + folder := dtos.Folder{} + err := json.NewDecoder(sc.resp.Body).Decode(&folder) + So(err, ShouldBeNil) + + return folder +} + +func callGetFolder(sc *scenarioContext) { + sc.handlerFunc = GetFolderById + sc.fakeReqWithParams("GET", sc.url, map[string]string{}).exec() +} + +func callDeleteFolder(sc *scenarioContext) { + bus.AddHandler("test", func(cmd *m.DeleteDashboardCommand) error { + return nil + }) + + sc.handlerFunc = DeleteFolder + sc.fakeReqWithParams("DELETE", sc.url, map[string]string{}).exec() +} + +func callCreateFolder(sc *scenarioContext) { + bus.AddHandler("test", func(cmd *m.SaveDashboardCommand) error { + cmd.Result = &m.Dashboard{Id: 1, Slug: "folder", Version: 2} + return nil + }) + + sc.fakeReqWithParams("POST", sc.url, map[string]string{}).exec() +} + +func callUpdateFolder(sc *scenarioContext) { + bus.AddHandler("test", func(cmd *m.SaveDashboardCommand) error { + cmd.Result = &m.Dashboard{Id: 1, Slug: "folder", Version: 2} + return nil + }) + + sc.fakeReqWithParams("PUT", sc.url, map[string]string{}).exec() +} + +func createFolderScenario(desc string, url string, routePattern string, role m.RoleType, cmd m.CreateFolderCommand, fn scenarioFunc) { + Convey(desc+" "+url, func() { + defer bus.ClearBusHandlers() + + sc := &scenarioContext{ + url: url, + } + viewsPath, _ := filepath.Abs("../../public/views") + + sc.m = macaron.New() + sc.m.Use(macaron.Renderer(macaron.RenderOptions{ + Directory: viewsPath, + Delims: macaron.Delims{Left: "[[", Right: "]]"}, + })) + + sc.m.Use(middleware.GetContextHandler()) + sc.m.Use(middleware.Sessioner(&session.Options{})) + + sc.defaultHandler = wrap(func(c *middleware.Context) Response { + sc.context = c + sc.context.UserId = TestUserID + sc.context.OrgId = TestOrgID + sc.context.OrgRole = role + + return CreateFolder(c, cmd) + }) + + fakeRepo = &fakeDashboardRepo{} + dashboards.SetRepository(fakeRepo) + + sc.m.Post(routePattern, sc.defaultHandler) + + fn(sc) + }) +} + +func updateFolderScenario(desc string, url string, routePattern string, role m.RoleType, cmd m.UpdateFolderCommand, fn scenarioFunc) { + Convey(desc+" "+url, func() { + defer bus.ClearBusHandlers() + + sc := &scenarioContext{ + url: url, + } + viewsPath, _ := filepath.Abs("../../public/views") + + sc.m = macaron.New() + sc.m.Use(macaron.Renderer(macaron.RenderOptions{ + Directory: viewsPath, + Delims: macaron.Delims{Left: "[[", Right: "]]"}, + })) + + sc.m.Use(middleware.GetContextHandler()) + sc.m.Use(middleware.Sessioner(&session.Options{})) + + sc.defaultHandler = wrap(func(c *middleware.Context) Response { + sc.context = c + sc.context.UserId = TestUserID + sc.context.OrgId = TestOrgID + sc.context.OrgRole = role + + return UpdateFolder(c, cmd) + }) + + fakeRepo = &fakeDashboardRepo{} + dashboards.SetRepository(fakeRepo) + + sc.m.Put(routePattern, sc.defaultHandler) + + fn(sc) + }) +} diff --git a/pkg/models/dashboards.go b/pkg/models/dashboards.go index dccd93707a5..e7b0c3a5f23 100644 --- a/pkg/models/dashboards.go +++ b/pkg/models/dashboards.go @@ -73,9 +73,9 @@ func NewDashboard(title string) *Dashboard { // NewDashboardFolder creates a new dashboard folder func NewDashboardFolder(title string) *Dashboard { folder := NewDashboard(title) + folder.IsFolder = true folder.Data.Set("schemaVersion", 16) - folder.Data.Set("editable", true) - folder.Data.Set("hideControls", true) + folder.Data.Set("version", 0) return folder } @@ -209,15 +209,3 @@ type GetDashboardSlugByIdQuery struct { Id int64 Result string } - -type GetFoldersForSignedInUserQuery struct { - OrgId int64 - SignedInUser *SignedInUser - Title string - Result []*DashboardFolder -} - -type DashboardFolder struct { - Id int64 `json:"id"` - Title string `json:"title"` -} diff --git a/pkg/models/folders.go b/pkg/models/folders.go new file mode 100644 index 00000000000..4b3526b00d8 --- /dev/null +++ b/pkg/models/folders.go @@ -0,0 +1,63 @@ +package models + +import ( + "errors" + "time" +) + +// Typed errors +var ( + ErrFolderNotFound = errors.New("Folder not found") + ErrFolderVersionMismatch = errors.New("The folder has been changed by someone else") + ErrFolderTitleEmpty = errors.New("Folder title cannot be empty") + ErrFolderWithSameNameExists = errors.New("A folder/dashboard with the same title already exists") +) + +type Folder struct { + Id int64 + Title string + Slug string + OrgId int64 + Version int + + Created time.Time + Updated time.Time + + UpdatedBy int64 + CreatedBy int64 + HasAcl bool +} + +type GetFoldersQueryHitResult struct { + Id int64 `json:"id"` + Title string `json:"title"` + Slug string `json:"slug"` +} + +// +// COMMANDS +// + +type CreateFolderCommand struct { + OrgId int64 `json:"-"` + UserId int64 `json:"userId"` + Title string `json:"title"` + + Result *Folder +} + +type UpdateFolderCommand struct { + OrgId int64 `json:"-"` + UserId int64 `json:"userId"` + Title string `json:"title"` + Version int `json:"version"` + + Result *Folder +} + +type GetFoldersQuery struct { + OrgId int64 + SignedInUser *SignedInUser + Title string + Result []*GetFoldersQueryHitResult +} diff --git a/pkg/services/sqlstore/dashboard.go b/pkg/services/sqlstore/dashboard.go index f4cdab22e89..afa522036c4 100644 --- a/pkg/services/sqlstore/dashboard.go +++ b/pkg/services/sqlstore/dashboard.go @@ -50,6 +50,9 @@ func SaveDashboard(cmd *m.SaveDashboardCommand) error { if existing.PluginId != "" && cmd.Overwrite == false { return m.UpdatePluginDashboardError{PluginId: existing.PluginId} } + + dash.Created = existing.Created + dash.CreatedBy = existing.CreatedBy } sameTitleExists, err := sess.Where("org_id=? AND slug=?", dash.OrgId, dash.Slug).Get(&sameTitle) @@ -66,6 +69,9 @@ func SaveDashboard(cmd *m.SaveDashboardCommand) error { } else { return m.ErrDashboardWithSameNameExists } + } else { + dash.Created = sameTitle.Created + dash.CreatedBy = sameTitle.CreatedBy } } @@ -134,6 +140,7 @@ func SaveDashboard(cmd *m.SaveDashboardCommand) error { } } } + cmd.Result = dash return err @@ -292,19 +299,26 @@ func GetDashboardTags(query *m.GetDashboardTagsQuery) error { return err } -func GetFoldersForSignedInUser(query *m.GetFoldersForSignedInUserQuery) error { - query.Result = make([]*m.DashboardFolder, 0) +func GetFoldersForSignedInUser(query *m.GetFoldersQuery) error { + query.Result = make([]*m.GetFoldersQueryHitResult, 0) var err error + params := make([]interface{}, 0) if query.SignedInUser.OrgRole == m.ROLE_ADMIN { - sql := `SELECT distinct d.id, d.title - FROM dashboard AS d WHERE d.is_folder = ? - ORDER BY d.title ASC` + sql := `SELECT distinct d.id, d.title, d.slug + FROM dashboard AS d WHERE d.is_folder = ?` + params = append(params, dialect.BooleanStr(true)) - err = x.Sql(sql, dialect.BooleanStr(true)).Find(&query.Result) + if len(query.Title) > 0 { + sql += " AND d.title " + dialect.LikeStr() + " ?" + params = append(params, "%"+query.Title+"%") + } + + sql += ` ORDER BY d.title ASC` + + err = x.Sql(sql, params...).Find(&query.Result) } else { - params := make([]interface{}, 0) - sql := `SELECT distinct d.id, d.title + sql := `SELECT distinct d.id, d.title, d.slug FROM dashboard AS d LEFT JOIN dashboard_acl AS da ON d.id = da.dashboard_id LEFT JOIN team_member AS ugm ON ugm.team_id = da.team_id @@ -315,14 +329,17 @@ func GetFoldersForSignedInUser(query *m.GetFoldersForSignedInUserQuery) error { sql += `WHERE d.org_id = ? AND - d.is_folder = 1 AND + d.is_folder = ? AND ( - (d.has_acl = 1 AND da.permission > 1 AND (da.user_id = ? OR ugm.user_id = ? OR ou.id IS NOT NULL)) + (d.has_acl = ? AND da.permission > 1 AND (da.user_id = ? OR ugm.user_id = ? OR ou.id IS NOT NULL)) OR (d.has_acl = 0 AND ouRole.id IS NOT NULL) )` params = append(params, query.OrgId) + params = append(params, dialect.BooleanStr(true)) + params = append(params, dialect.BooleanStr(true)) params = append(params, query.SignedInUser.UserId) params = append(params, query.SignedInUser.UserId) + params = append(params, dialect.BooleanStr(false)) if len(query.Title) > 0 { sql += " AND d.title " + dialect.LikeStr() + " ?" diff --git a/pkg/services/sqlstore/dashboard_test.go b/pkg/services/sqlstore/dashboard_test.go index 3b1e05d3772..b9c5451477e 100644 --- a/pkg/services/sqlstore/dashboard_test.go +++ b/pkg/services/sqlstore/dashboard_test.go @@ -470,7 +470,7 @@ func TestDashboardDataAccess(t *testing.T) { Convey("Admin users", func() { Convey("Should have write access to all dashboard folders", func() { - query := m.GetFoldersForSignedInUserQuery{ + query := m.GetFoldersQuery{ OrgId: 1, SignedInUser: &m.SignedInUser{UserId: adminUser.Id, OrgRole: m.ROLE_ADMIN}, } @@ -485,7 +485,7 @@ func TestDashboardDataAccess(t *testing.T) { }) Convey("Editor users", func() { - query := m.GetFoldersForSignedInUserQuery{ + query := m.GetFoldersQuery{ OrgId: 1, SignedInUser: &m.SignedInUser{UserId: editorUser.Id, OrgRole: m.ROLE_EDITOR}, } @@ -511,7 +511,7 @@ func TestDashboardDataAccess(t *testing.T) { }) Convey("Viewer users", func() { - query := m.GetFoldersForSignedInUserQuery{ + query := m.GetFoldersQuery{ OrgId: 1, SignedInUser: &m.SignedInUser{UserId: viewerUser.Id, OrgRole: m.ROLE_VIEWER}, }