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
This commit is contained in:
parent
d3bc3dd4d1
commit
36bcd4cd6b
7 changed files with 246 additions and 5 deletions
|
@ -107,3 +107,32 @@ func checkTeamBean(t *testing.T, id int64, name, description string, permission
|
||||||
assert.NoError(t, team.GetUnits(), "GetUnits")
|
assert.NoError(t, team.GetUnits(), "GetUnits")
|
||||||
checkTeamResponse(t, convert.ToTeam(team), name, description, permission, units)
|
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)
|
||||||
|
|
||||||
|
}
|
||||||
|
|
|
@ -15,6 +15,7 @@ import (
|
||||||
"code.gitea.io/gitea/modules/setting"
|
"code.gitea.io/gitea/modules/setting"
|
||||||
|
|
||||||
"github.com/go-xorm/xorm"
|
"github.com/go-xorm/xorm"
|
||||||
|
"xorm.io/builder"
|
||||||
)
|
)
|
||||||
|
|
||||||
const ownerTeamName = "Owners"
|
const ownerTeamName = "Owners"
|
||||||
|
@ -34,6 +35,67 @@ type Team struct {
|
||||||
Units []*TeamUnit `xorm:"-"`
|
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
|
// ColorFormat provides a basic color format for a Team
|
||||||
func (t *Team) ColorFormat(s fmt.State) {
|
func (t *Team) ColorFormat(s fmt.State) {
|
||||||
log.ColorFprintf(s, "%d:%s (OrgID: %d) %-v",
|
log.ColorFprintf(s, "%d:%s (OrgID: %d) %-v",
|
||||||
|
|
|
@ -1766,11 +1766,11 @@ function searchTeams() {
|
||||||
$searchTeamBox.search({
|
$searchTeamBox.search({
|
||||||
minCharacters: 2,
|
minCharacters: 2,
|
||||||
apiSettings: {
|
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},
|
headers: {"X-Csrf-Token": csrf},
|
||||||
onResponse: function(response) {
|
onResponse: function(response) {
|
||||||
const items = [];
|
const items = [];
|
||||||
$.each(response, function (_i, item) {
|
$.each(response.data, function (_i, item) {
|
||||||
const title = item.name + ' (' + item.permission + ' access)';
|
const title = item.name + ' (' + item.permission + ' access)';
|
||||||
items.push({
|
items.push({
|
||||||
title: title,
|
title: title,
|
||||||
|
|
|
@ -802,8 +802,11 @@ func RegisterRoutes(m *macaron.Macaron) {
|
||||||
Put(reqToken(), reqOrgMembership(), org.PublicizeMember).
|
Put(reqToken(), reqOrgMembership(), org.PublicizeMember).
|
||||||
Delete(reqToken(), reqOrgMembership(), org.ConcealMember)
|
Delete(reqToken(), reqOrgMembership(), org.ConcealMember)
|
||||||
})
|
})
|
||||||
m.Combo("/teams", reqToken(), reqOrgMembership()).Get(org.ListTeams).
|
m.Group("/teams", func() {
|
||||||
Post(reqOrgOwnership(), bind(api.CreateTeamOption{}), org.CreateTeam)
|
m.Combo("", reqToken()).Get(org.ListTeams).
|
||||||
|
Post(reqOrgOwnership(), bind(api.CreateTeamOption{}), org.CreateTeam)
|
||||||
|
m.Get("/search", org.SearchTeam)
|
||||||
|
}, reqOrgMembership())
|
||||||
m.Group("/hooks", func() {
|
m.Group("/hooks", func() {
|
||||||
m.Combo("").Get(org.ListHooks).
|
m.Combo("").Get(org.ListHooks).
|
||||||
Post(bind(api.CreateHookOption{}), org.CreateHook)
|
Post(bind(api.CreateHookOption{}), org.CreateHook)
|
||||||
|
|
|
@ -6,8 +6,11 @@
|
||||||
package org
|
package org
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
"code.gitea.io/gitea/models"
|
"code.gitea.io/gitea/models"
|
||||||
"code.gitea.io/gitea/modules/context"
|
"code.gitea.io/gitea/modules/context"
|
||||||
|
"code.gitea.io/gitea/modules/log"
|
||||||
api "code.gitea.io/gitea/modules/structs"
|
api "code.gitea.io/gitea/modules/structs"
|
||||||
"code.gitea.io/gitea/routers/api/v1/convert"
|
"code.gitea.io/gitea/routers/api/v1/convert"
|
||||||
"code.gitea.io/gitea/routers/api/v1/user"
|
"code.gitea.io/gitea/routers/api/v1/user"
|
||||||
|
@ -504,3 +507,83 @@ func RemoveTeamRepository(ctx *context.APIContext) {
|
||||||
}
|
}
|
||||||
ctx.Status(204)
|
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,
|
||||||
|
})
|
||||||
|
|
||||||
|
}
|
||||||
|
|
|
@ -95,7 +95,7 @@
|
||||||
<form class="ui form" id="repo-collab-team-form" action="{{.Link}}/team" method="post">
|
<form class="ui form" id="repo-collab-team-form" action="{{.Link}}/team" method="post">
|
||||||
{{.CsrfTokenHtml}}
|
{{.CsrfTokenHtml}}
|
||||||
<div class="inline field ui left">
|
<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">
|
<div class="ui input">
|
||||||
<input class="prompt" name="team" placeholder="Search teams..." autocomplete="off" autofocus required>
|
<input class="prompt" name="team" placeholder="Search teams..." autocomplete="off" autofocus required>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -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": {
|
"/repos/migrate": {
|
||||||
"post": {
|
"post": {
|
||||||
"consumes": [
|
"consumes": [
|
||||||
|
|
Reference in a new issue