* 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 swaggertags/v1.11.0-dev
@@ -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). | |||||
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.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": [ | ||||