소스 검색

WIP: folder api. #10630

Marcus Efraimsson 8 년 전
부모
커밋
f08932b78a
10개의 변경된 파일725개의 추가작업 그리고 47개의 파일을 삭제
  1. 9 2
      pkg/api/api.go
  2. 10 16
      pkg/api/dashboard.go
  3. 44 2
      pkg/api/dashboard_test.go
  4. 18 0
      pkg/api/dtos/folder.go
  5. 221 0
      pkg/api/folders.go
  6. 328 0
      pkg/api/folders_test.go
  7. 2 14
      pkg/models/dashboards.go
  8. 63 0
      pkg/models/folders.go
  9. 27 10
      pkg/services/sqlstore/dashboard.go
  10. 3 3
      pkg/services/sqlstore/dashboard_test.go

+ 9 - 2
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))

+ 10 - 16
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)
-}

+ 44 - 2
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{}

+ 18 - 0
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"`
+}

+ 221 - 0
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)
+}

+ 328 - 0
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)
+	})
+}

+ 2 - 14
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"`
-}

+ 63 - 0
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
+}

+ 27 - 10
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))
+
+		if len(query.Title) > 0 {
+			sql += " AND d.title " + dialect.LikeStr() + " ?"
+			params = append(params, "%"+query.Title+"%")
+		}
 
-		err = x.Sql(sql, dialect.BooleanStr(true)).Find(&query.Result)
+		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() + " ?"

+ 3 - 3
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},
 				}