From 36bcd4cd6b6d1131e3f812a825558fbfe5dcca20 Mon Sep 17 00:00:00 2001
From: David Svantesson <davidsvantesson@gmail.com>
Date: Tue, 1 Oct 2019 07:32:28 +0200
Subject: [PATCH] API endpoint for searching teams. (#8108)

* Api endpoint for searching teams.

Signed-off-by: dasv <david.svantesson@qrtech.se>

* Move API to /orgs/:org/teams/search

Signed-off-by: David Svantesson <davidsvantesson@gmail.com>

* Regenerate swagger

Signed-off-by: David Svantesson <davidsvantesson@gmail.com>

* Fix search is Get

Signed-off-by: David Svantesson <davidsvantesson@gmail.com>

* Add test for search team API.

Signed-off-by: David Svantesson <davidsvantesson@gmail.com>

* Update routers/api/v1/org/team.go

grammar

Co-Authored-By: Richard Mahn <richmahn@users.noreply.github.com>

* Fix review comments

Signed-off-by: David Svantesson <davidsvantesson@gmail.com>

* Fix some issues in repo collaboration team search, after changes in this PR.

Signed-off-by: David Svantesson <davidsvantesson@gmail.com>

* Remove teamUser which is not used and replace with actual user id.

Signed-off-by: David Svantesson <davidsvantesson@gmail.com>

* Remove unused search variable UserIsAdmin.

* Add paging to team search.

* Re-genereate swagger

Signed-off-by: David Svantesson <davidsvantesson@gmail.com>

* Fix review comments

Signed-off-by: David Svantesson <davidsvantesson@gmail.com>

* fix

* Regenerate swagger
---
 integrations/api_team_test.go              | 29 ++++++++
 models/org_team.go                         | 62 ++++++++++++++++
 public/js/index.js                         |  4 +-
 routers/api/v1/api.go                      |  7 +-
 routers/api/v1/org/team.go                 | 83 ++++++++++++++++++++++
 templates/repo/settings/collaboration.tmpl |  2 +-
 templates/swagger/v1_json.tmpl             | 64 +++++++++++++++++
 7 files changed, 246 insertions(+), 5 deletions(-)

diff --git a/integrations/api_team_test.go b/integrations/api_team_test.go
index a7c22d6ba1..38e202f239 100644
--- a/integrations/api_team_test.go
+++ b/integrations/api_team_test.go
@@ -107,3 +107,32 @@ func checkTeamBean(t *testing.T, id int64, name, description string, permission
 	assert.NoError(t, team.GetUnits(), "GetUnits")
 	checkTeamResponse(t, convert.ToTeam(team), name, description, permission, units)
 }
+
+type TeamSearchResults struct {
+	OK   bool        `json:"ok"`
+	Data []*api.Team `json:"data"`
+}
+
+func TestAPITeamSearch(t *testing.T) {
+	prepareTestEnv(t)
+
+	user := models.AssertExistsAndLoadBean(t, &models.User{ID: 2}).(*models.User)
+	org := models.AssertExistsAndLoadBean(t, &models.User{ID: 3}).(*models.User)
+
+	var results TeamSearchResults
+
+	session := loginUser(t, user.Name)
+	req := NewRequestf(t, "GET", "/api/v1/orgs/%s/teams/search?q=%s", org.Name, "_team")
+	resp := session.MakeRequest(t, req, http.StatusOK)
+	DecodeJSON(t, resp, &results)
+	assert.NotEmpty(t, results.Data)
+	assert.Equal(t, 1, len(results.Data))
+	assert.Equal(t, "test_team", results.Data[0].Name)
+
+	// no access if not organization member
+	user5 := models.AssertExistsAndLoadBean(t, &models.User{ID: 5}).(*models.User)
+	session = loginUser(t, user5.Name)
+	req = NewRequestf(t, "GET", "/api/v1/orgs/%s/teams/search?q=%s", org.Name, "team")
+	resp = session.MakeRequest(t, req, http.StatusForbidden)
+
+}
diff --git a/models/org_team.go b/models/org_team.go
index 90a089417d..fc5d5834ef 100644
--- a/models/org_team.go
+++ b/models/org_team.go
@@ -15,6 +15,7 @@ import (
 	"code.gitea.io/gitea/modules/setting"
 
 	"github.com/go-xorm/xorm"
+	"xorm.io/builder"
 )
 
 const ownerTeamName = "Owners"
@@ -34,6 +35,67 @@ type Team struct {
 	Units       []*TeamUnit `xorm:"-"`
 }
 
+// SearchTeamOptions holds the search options
+type SearchTeamOptions struct {
+	UserID      int64
+	Keyword     string
+	OrgID       int64
+	IncludeDesc bool
+	PageSize    int
+	Page        int
+}
+
+// SearchTeam search for teams. Caller is responsible to check permissions.
+func SearchTeam(opts *SearchTeamOptions) ([]*Team, int64, error) {
+	if opts.Page <= 0 {
+		opts.Page = 1
+	}
+	if opts.PageSize == 0 {
+		// Default limit
+		opts.PageSize = 10
+	}
+
+	var cond = builder.NewCond()
+
+	if len(opts.Keyword) > 0 {
+		lowerKeyword := strings.ToLower(opts.Keyword)
+		var keywordCond builder.Cond = builder.Like{"lower_name", lowerKeyword}
+		if opts.IncludeDesc {
+			keywordCond = keywordCond.Or(builder.Like{"LOWER(description)", lowerKeyword})
+		}
+		cond = cond.And(keywordCond)
+	}
+
+	cond = cond.And(builder.Eq{"org_id": opts.OrgID})
+
+	sess := x.NewSession()
+	defer sess.Close()
+
+	count, err := sess.
+		Where(cond).
+		Count(new(Team))
+
+	if err != nil {
+		return nil, 0, err
+	}
+
+	sess = sess.Where(cond)
+	if opts.PageSize == -1 {
+		opts.PageSize = int(count)
+	} else {
+		sess = sess.Limit(opts.PageSize, (opts.Page-1)*opts.PageSize)
+	}
+
+	teams := make([]*Team, 0, opts.PageSize)
+	if err = sess.
+		OrderBy("lower_name").
+		Find(&teams); err != nil {
+		return nil, 0, err
+	}
+
+	return teams, count, nil
+}
+
 // ColorFormat provides a basic color format for a Team
 func (t *Team) ColorFormat(s fmt.State) {
 	log.ColorFprintf(s, "%d:%s (OrgID: %d) %-v",
diff --git a/public/js/index.js b/public/js/index.js
index ad5e3912de..8a85ad9157 100644
--- a/public/js/index.js
+++ b/public/js/index.js
@@ -1766,11 +1766,11 @@ function searchTeams() {
     $searchTeamBox.search({
         minCharacters: 2,
         apiSettings: {
-            url: suburl + '/api/v1/orgs/' + $searchTeamBox.data('org') + '/teams',
+            url: suburl + '/api/v1/orgs/' + $searchTeamBox.data('org') + '/teams/search?q={query}',
             headers: {"X-Csrf-Token": csrf},
             onResponse: function(response) {
                 const items = [];
-                $.each(response, function (_i, item) {
+                $.each(response.data, function (_i, item) {
                     const title = item.name + ' (' + item.permission + ' access)';
                     items.push({
                         title: title,
diff --git a/routers/api/v1/api.go b/routers/api/v1/api.go
index c57edf6a99..04ff91fbbf 100644
--- a/routers/api/v1/api.go
+++ b/routers/api/v1/api.go
@@ -802,8 +802,11 @@ func RegisterRoutes(m *macaron.Macaron) {
 					Put(reqToken(), reqOrgMembership(), org.PublicizeMember).
 					Delete(reqToken(), reqOrgMembership(), org.ConcealMember)
 			})
-			m.Combo("/teams", reqToken(), reqOrgMembership()).Get(org.ListTeams).
-				Post(reqOrgOwnership(), bind(api.CreateTeamOption{}), org.CreateTeam)
+			m.Group("/teams", func() {
+				m.Combo("", reqToken()).Get(org.ListTeams).
+					Post(reqOrgOwnership(), bind(api.CreateTeamOption{}), org.CreateTeam)
+				m.Get("/search", org.SearchTeam)
+			}, reqOrgMembership())
 			m.Group("/hooks", func() {
 				m.Combo("").Get(org.ListHooks).
 					Post(bind(api.CreateHookOption{}), org.CreateHook)
diff --git a/routers/api/v1/org/team.go b/routers/api/v1/org/team.go
index 7b8fd12fba..d01f051626 100644
--- a/routers/api/v1/org/team.go
+++ b/routers/api/v1/org/team.go
@@ -6,8 +6,11 @@
 package org
 
 import (
+	"strings"
+
 	"code.gitea.io/gitea/models"
 	"code.gitea.io/gitea/modules/context"
+	"code.gitea.io/gitea/modules/log"
 	api "code.gitea.io/gitea/modules/structs"
 	"code.gitea.io/gitea/routers/api/v1/convert"
 	"code.gitea.io/gitea/routers/api/v1/user"
@@ -504,3 +507,83 @@ func RemoveTeamRepository(ctx *context.APIContext) {
 	}
 	ctx.Status(204)
 }
+
+// SearchTeam api for searching teams
+func SearchTeam(ctx *context.APIContext) {
+	// swagger:operation GET /orgs/{org}/teams/search organization teamSearch
+	// ---
+	// summary: Search for teams within an organization
+	// produces:
+	// - application/json
+	// parameters:
+	// - name: org
+	//   in: path
+	//   description: name of the organization
+	//   type: string
+	//   required: true
+	// - name: q
+	//   in: query
+	//   description: keywords to search
+	//   type: string
+	// - name: include_desc
+	//   in: query
+	//   description: include search within team description (defaults to true)
+	//   type: boolean
+	// - name: limit
+	//   in: query
+	//   description: limit size of results
+	//   type: integer
+	// - name: page
+	//   in: query
+	//   description: page number of results to return (1-based)
+	//   type: integer
+	// responses:
+	//   "200":
+	//     description: "SearchResults of a successful search"
+	//     schema:
+	//       type: object
+	//       properties:
+	//         ok:
+	//           type: boolean
+	//         data:
+	//           type: array
+	//           items:
+	//             "$ref": "#/definitions/Team"
+	opts := &models.SearchTeamOptions{
+		UserID:      ctx.User.ID,
+		Keyword:     strings.TrimSpace(ctx.Query("q")),
+		OrgID:       ctx.Org.Organization.ID,
+		IncludeDesc: (ctx.Query("include_desc") == "" || ctx.QueryBool("include_desc")),
+		PageSize:    ctx.QueryInt("limit"),
+		Page:        ctx.QueryInt("page"),
+	}
+
+	teams, _, err := models.SearchTeam(opts)
+	if err != nil {
+		log.Error("SearchTeam failed: %v", err)
+		ctx.JSON(500, map[string]interface{}{
+			"ok":    false,
+			"error": "SearchTeam internal failure",
+		})
+		return
+	}
+
+	apiTeams := make([]*api.Team, len(teams))
+	for i := range teams {
+		if err := teams[i].GetUnits(); err != nil {
+			log.Error("Team GetUnits failed: %v", err)
+			ctx.JSON(500, map[string]interface{}{
+				"ok":    false,
+				"error": "SearchTeam failed to get units",
+			})
+			return
+		}
+		apiTeams[i] = convert.ToTeam(teams[i])
+	}
+
+	ctx.JSON(200, map[string]interface{}{
+		"ok":   true,
+		"data": apiTeams,
+	})
+
+}
diff --git a/templates/repo/settings/collaboration.tmpl b/templates/repo/settings/collaboration.tmpl
index 61feb4ec18..c0b444dce3 100644
--- a/templates/repo/settings/collaboration.tmpl
+++ b/templates/repo/settings/collaboration.tmpl
@@ -95,7 +95,7 @@
 				<form class="ui form" id="repo-collab-team-form" action="{{.Link}}/team" method="post">
 					{{.CsrfTokenHtml}}
 					<div class="inline field ui left">
-						<div id="search-team-box" class="ui search" data-org="{{.OrgID}}">
+						<div id="search-team-box" class="ui search" data-org="{{.OrgName}}">
 							<div class="ui input">
 								<input class="prompt" name="team" placeholder="Search teams..." autocomplete="off" autofocus required>
 							</div>
diff --git a/templates/swagger/v1_json.tmpl b/templates/swagger/v1_json.tmpl
index a5fef2f5e6..fcc26f5c54 100644
--- a/templates/swagger/v1_json.tmpl
+++ b/templates/swagger/v1_json.tmpl
@@ -1047,6 +1047,70 @@
         }
       }
     },
+    "/orgs/{org}/teams/search": {
+      "get": {
+        "produces": [
+          "application/json"
+        ],
+        "tags": [
+          "organization"
+        ],
+        "summary": "Search for teams within an organization",
+        "operationId": "teamSearch",
+        "parameters": [
+          {
+            "type": "string",
+            "description": "name of the organization",
+            "name": "org",
+            "in": "path",
+            "required": true
+          },
+          {
+            "type": "string",
+            "description": "keywords to search",
+            "name": "q",
+            "in": "query"
+          },
+          {
+            "type": "boolean",
+            "description": "include search within team description (defaults to true)",
+            "name": "include_desc",
+            "in": "query"
+          },
+          {
+            "type": "integer",
+            "description": "limit size of results",
+            "name": "limit",
+            "in": "query"
+          },
+          {
+            "type": "integer",
+            "description": "page number of results to return (1-based)",
+            "name": "page",
+            "in": "query"
+          }
+        ],
+        "responses": {
+          "200": {
+            "description": "SearchResults of a successful search",
+            "schema": {
+              "type": "object",
+              "properties": {
+                "data": {
+                  "type": "array",
+                  "items": {
+                    "$ref": "#/definitions/Team"
+                  }
+                },
+                "ok": {
+                  "type": "boolean"
+                }
+              }
+            }
+          }
+        }
+      }
+    },
     "/repos/migrate": {
       "post": {
         "consumes": [