Skip to content

Commit d1d4a5a

Browse files
authored
Merge pull request #3020 from athenianco/DEV-5150-openapi-align-list-team-members
[DEV-5150] openapi align list team members
2 parents 5508247 + 9a38808 commit d1d4a5a

File tree

7 files changed

+316
-12
lines changed

7 files changed

+316
-12
lines changed

server/athenian/api/align/queries/members.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,9 @@ async def resolve_members(
3232

3333
team, meta_ids = await gather(
3434
# teamId 0 means root team
35-
get_root_team(accountId, sdb) if teamId == 0 else get_team_from_db(accountId, teamId, sdb),
35+
get_root_team(accountId, sdb)
36+
if teamId == 0
37+
else get_team_from_db(teamId, accountId, None, sdb),
3638
get_metadata_account_ids(accountId, sdb, cache),
3739
)
3840

server/athenian/api/align/spec

Submodule spec updated from 440e1b2 to d11e008
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
from typing import Optional
2+
3+
from aiohttp import web
4+
5+
from athenian.api.internal.account import get_metadata_account_ids
6+
from athenian.api.internal.team import (
7+
fetch_team_members_recursively,
8+
get_all_team_members,
9+
get_root_team,
10+
get_team_from_db,
11+
)
12+
from athenian.api.models.state.models import Team
13+
from athenian.api.models.web import BadRequestError, Contributor
14+
from athenian.api.request import AthenianWebRequest
15+
from athenian.api.response import ResponseError, model_response
16+
17+
18+
async def list_team_members(
19+
request: AthenianWebRequest,
20+
team_id: int,
21+
recursive: bool,
22+
account: Optional[int] = None,
23+
) -> web.Response:
24+
"""List the members of a team."""
25+
sdb, mdb, cache = request.sdb, request.mdb, request.cache
26+
27+
if team_id == 0:
28+
if account is None:
29+
msg = "Parameter account is required with team_id 0"
30+
raise ResponseError(BadRequestError(detail=msg))
31+
team = await get_root_team(account, sdb)
32+
else:
33+
team = await get_team_from_db(team_id, account, request.uid, sdb)
34+
account = team[Team.owner_id.name]
35+
36+
meta_ids = await get_metadata_account_ids(account, sdb, cache)
37+
38+
if recursive:
39+
member_ids = await fetch_team_members_recursively(account, sdb, team[Team.id.name])
40+
else:
41+
member_ids = team[Team.members.name]
42+
members = await get_all_team_members(member_ids, account, meta_ids, mdb, sdb, cache)
43+
44+
def sort_key(member: Contributor) -> tuple[bool, str, str]:
45+
# first users with a name
46+
return (member.name is None, (member.name or "").lower(), member.login.lower())
47+
48+
return model_response(sorted(members.values(), key=sort_key))

server/athenian/api/internal/team.py

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
from athenian.api.db import Connection, Database, DatabaseLike, Row, conn_in_transaction
1414
from athenian.api.internal.jira import load_mapped_jira_users
1515
from athenian.api.models.metadata.github import User
16-
from athenian.api.models.state.models import Team
16+
from athenian.api.models.state.models import Team, UserAccount
1717
from athenian.api.models.web import BadRequestError, Contributor, GenericError
1818
from athenian.api.response import ResponseError
1919
from athenian.api.tracing import sentry_span
@@ -77,9 +77,30 @@ async def get_root_team(account_id: int, sdb_conn: DatabaseLike) -> Row:
7777

7878

7979
@sentry_span
80-
async def get_team_from_db(account_id: int, team_id: int, sdb_conn: DatabaseLike) -> Row:
81-
"""Return a team owned by an account."""
82-
stmt = sa.select(Team).where(sa.and_(Team.owner_id == account_id, Team.id == team_id))
80+
async def get_team_from_db(
81+
team_id: int,
82+
account_id: Optional[int],
83+
user_id: Optional[str],
84+
sdb_conn: DatabaseLike,
85+
) -> Row:
86+
"""Return a team owned by an account.
87+
88+
Team can be filtered by owner account and/or user id with access to team.
89+
At least one of the two filtering conditions must be specified.
90+
91+
"""
92+
if account_id is None and user_id is None:
93+
raise ValueError("At least one between account_id and user_id must be specified")
94+
95+
where = Team.id == team_id
96+
if account_id is not None:
97+
where = sa.and_(where, Team.owner_id == account_id)
98+
from_ = Team
99+
if user_id is not None:
100+
from_ = sa.join(Team, UserAccount, Team.owner_id == UserAccount.account_id)
101+
where = sa.and_(where, UserAccount.user_id == user_id)
102+
103+
stmt = sa.select(Team).select_from(from_).where(where)
83104
team = await sdb_conn.fetch_one(stmt)
84105
if team is None:
85106
raise TeamNotFoundError(team_id)

server/tests/controllers/align/__init__.py

Whitespace-only changes.
Lines changed: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,191 @@
1+
from typing import Optional
2+
3+
from athenian.api.db import Database
4+
from tests.testutils.auth import force_request_auth
5+
from tests.testutils.db import DBCleaner, models_insert
6+
from tests.testutils.factory import metadata as md_factory
7+
from tests.testutils.factory.state import (
8+
MappedJIRAIdentityFactory,
9+
TeamFactory,
10+
UserAccountFactory,
11+
)
12+
from tests.testutils.requester import Requester
13+
14+
15+
class BaseListTeamMembersTest(Requester):
16+
async def _request(
17+
self,
18+
team_id: int,
19+
assert_status: int = 200,
20+
recursive: Optional[bool] = None,
21+
account: Optional[int] = None,
22+
headers: Optional[dict] = None,
23+
user_id: Optional[str] = None,
24+
) -> list[dict] | dict:
25+
path = f"/private/align/members/{team_id}"
26+
params = {}
27+
if recursive is not None:
28+
params["recursive"] = str(recursive).lower()
29+
if account is not None:
30+
params["account"] = str(account)
31+
headers = self.headers if headers is None else (self.headers | headers)
32+
33+
with force_request_auth(user_id, headers) as headers:
34+
response = await self.client.request(
35+
method="GET", path=path, headers=headers, params=params,
36+
)
37+
38+
assert response.status == assert_status
39+
return await response.json()
40+
41+
42+
class TestListTeamMembersErrors(BaseListTeamMembersTest):
43+
async def test_invalid_auth_token(self, sdb: Database) -> None:
44+
await models_insert(sdb, TeamFactory(id=1, members=[1, 2]))
45+
headers = {"Authorization": "Bearer invalid"}
46+
res = await self._request(1, 401, headers=headers)
47+
assert isinstance(res, dict)
48+
assert res["type"] == "/errors/Unauthorized"
49+
50+
async def test_team_not_found(self) -> None:
51+
res = await self._request(999, 404)
52+
assert isinstance(res, dict)
53+
assert res["detail"] == "Team 999 not found or access denied"
54+
55+
async def test_team_not_found_recursive(self) -> None:
56+
res = await self._request(999, 404, recursive=True)
57+
assert isinstance(res, dict)
58+
assert res["detail"] == "Team 999 not found or access denied"
59+
60+
async def test_team_account_mismatch(self, sdb: Database) -> None:
61+
await models_insert(sdb, TeamFactory(id=10, owner_id=3))
62+
res = await self._request(10, 404)
63+
assert isinstance(res, dict)
64+
assert res["detail"] == "Team 10 not found or access denied"
65+
66+
async def test_team_correct_account_param_mismatch(self, sdb: Database) -> None:
67+
await models_insert(sdb, TeamFactory(id=10))
68+
res = await self._request(10, 404, account=3)
69+
assert isinstance(res, dict)
70+
assert res["detail"] == "Team 10 not found or access denied"
71+
72+
async def test_team_account_mismatch_non_default_user(self, sdb: Database) -> None:
73+
await models_insert(
74+
sdb, UserAccountFactory(user_id="my-user", account_id=2), TeamFactory(id=10),
75+
)
76+
await self._request(10, 404, user_id="my-user")
77+
78+
async def test_root_team_multiple_roots(self, sdb: Database) -> None:
79+
await models_insert(sdb, TeamFactory(), TeamFactory())
80+
res = await self._request(0, 500, account=1)
81+
assert isinstance(res, dict)
82+
assert res["title"] == "Multiple root teams"
83+
assert res["detail"] == "Account 1 has multiple root teams"
84+
85+
async def test_root_team_no_team_exists(self, sdb: Database) -> None:
86+
res = await self._request(0, 404, account=1)
87+
assert isinstance(res, dict)
88+
assert res["type"] == "/errors/teams/TeamNotFound"
89+
assert res["detail"] == "Root team not found or access denied"
90+
91+
async def test_root_team_missing_account(self, sdb: Database) -> None:
92+
res = await self._request(0, 400, account=None)
93+
assert isinstance(res, dict)
94+
assert res["type"] == "/errors/BadRequest"
95+
96+
97+
class TestListTeamMembers(BaseListTeamMembersTest):
98+
async def test_explicit_non_root_team(self, sdb: Database) -> None:
99+
# members id are from the 6 MB metadata db fixture
100+
await models_insert(
101+
sdb,
102+
TeamFactory(id=1, members=[]),
103+
TeamFactory(id=2, members=[40078, 39652900], parent_id=1),
104+
)
105+
106+
res = await self._request(2)
107+
assert [m["login"] for m in res] == ["github.com/leantrace", "github.com/reujab"]
108+
assert [m["name"] for m in res] == ["Alexander Schamne", "Christopher Knight"]
109+
110+
assert "email" in res[0]
111+
assert "picture" in res[0]
112+
113+
async def test_explicit_root_team(self, sdb: Database) -> None:
114+
await models_insert(sdb, TeamFactory(id=1, members=[40078], parent_id=None))
115+
res = await self._request(1)
116+
assert [m["login"] for m in res] == ["github.com/reujab"]
117+
118+
async def test_implicit_root_team_empty(self, sdb: Database) -> None:
119+
await models_insert(
120+
sdb,
121+
TeamFactory(id=10, members=[]),
122+
TeamFactory(id=20, parent_id=10, members=[3]),
123+
TeamFactory(id=30, parent_id=10, members=[3, 4]),
124+
)
125+
res = await self._request(0, account=1)
126+
assert res == []
127+
128+
async def test_implicit_root_team(self, sdb: Database) -> None:
129+
await models_insert(
130+
sdb,
131+
TeamFactory(id=10, members=[40078]),
132+
TeamFactory(id=11, parent_id=10, members=[39652900]),
133+
)
134+
res = await self._request(0, account=1)
135+
assert [m["login"] for m in res] == ["github.com/reujab"]
136+
137+
async def test_recursive(self, sdb: Database) -> None:
138+
await models_insert(
139+
sdb,
140+
TeamFactory(id=1, members=[39652900]),
141+
TeamFactory(id=2, parent_id=1, members=[40078]),
142+
)
143+
144+
res = await self._request(1, recursive=True)
145+
assert [m["login"] for m in res] == ["github.com/leantrace", "github.com/reujab"]
146+
147+
res = await self._request(1, recursive=False)
148+
assert [m["login"] for m in res] == ["github.com/leantrace"]
149+
150+
res = await self._request(1)
151+
assert [m["login"] for m in res] == ["github.com/leantrace"]
152+
153+
async def test_result_ordering(self, sdb: Database, mdb_rw: Database) -> None:
154+
await models_insert(
155+
sdb,
156+
TeamFactory(id=1, members=[100, 101, 102, 103]),
157+
)
158+
159+
async with DBCleaner(mdb_rw) as mdb_cleaner:
160+
models = [
161+
md_factory.UserFactory(node_id=100, html_url="https://c", name="Foo Bar"),
162+
md_factory.UserFactory(node_id=101, html_url="https://a", name=None),
163+
md_factory.UserFactory(node_id=102, html_url="https://b", name="zz-Top"),
164+
md_factory.UserFactory(node_id=103, html_url="https://d", name="aa"),
165+
]
166+
mdb_cleaner.add_models(*models)
167+
await models_insert(mdb_rw, *models)
168+
169+
res = await self._request(1, recursive=True)
170+
171+
assert [m["login"] for m in res] == ["d", "c", "b", "a"]
172+
assert [m.get("name") for m in res] == ["aa", "Foo Bar", "zz-Top", None]
173+
174+
async def test_jira_user_field(self, sdb: Database, mdb_rw: Database) -> None:
175+
await models_insert(
176+
sdb,
177+
TeamFactory(id=1, members=[100, 101]),
178+
MappedJIRAIdentityFactory(github_user_id=100, jira_user_id="200"),
179+
)
180+
181+
async with DBCleaner(mdb_rw) as mdb_cleaner:
182+
models = [
183+
md_factory.UserFactory(node_id=100),
184+
md_factory.UserFactory(node_id=101),
185+
md_factory.JIRAUserFactory(id="200", display_name="My JIRA name"),
186+
]
187+
mdb_cleaner.add_models(*models)
188+
await models_insert(mdb_rw, *models)
189+
res = await self._request(1)
190+
191+
assert [m.get("jira_user") for m in res] == ["My JIRA name", None]

server/tests/internal/test_team.py

Lines changed: 48 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,12 @@
2323
models_insert,
2424
transaction_conn,
2525
)
26-
from tests.testutils.factory.state import GoalFactory, TeamFactory, TeamGoalFactory
26+
from tests.testutils.factory.state import (
27+
GoalFactory,
28+
TeamFactory,
29+
TeamGoalFactory,
30+
UserAccountFactory,
31+
)
2732

2833

2934
class TestGetRootTeam:
@@ -44,15 +49,52 @@ async def test_multiple_root_teams(self, sdb: Database) -> None:
4449

4550

4651
class TestGetTeamFromDB:
47-
async def test_found(self, sdb: Database) -> None:
52+
async def test_found_by_account(self, sdb: Database) -> None:
4853
await models_insert(sdb, TeamFactory(id=99, name="TEAM 99"))
49-
team = await get_team_from_db(1, 99, sdb)
50-
assert team["name"] == "TEAM 99"
54+
team = await get_team_from_db(99, 1, None, sdb)
55+
assert team[Team.name.name] == "TEAM 99"
5156

52-
async def test_not_found(self, sdb: Database) -> None:
57+
async def test_not_found_by_account(self, sdb: Database) -> None:
5358
await models_insert(sdb, TeamFactory(id=99, owner_id=2))
5459
with pytest.raises(TeamNotFoundError):
55-
await get_team_from_db(1, 99, sdb)
60+
await get_team_from_db(99, 1, None, sdb)
61+
62+
async def test_found_by_uid(self, sdb: Database) -> None:
63+
await models_insert(
64+
sdb, TeamFactory(id=99, owner_id=2), UserAccountFactory(user_id="u0", account_id=2),
65+
)
66+
team = await get_team_from_db(99, None, "u0", sdb)
67+
assert team[Team.id.name] == 99
68+
69+
async def test_not_found_by_uid(self, sdb: Database) -> None:
70+
await models_insert(sdb, UserAccountFactory(user_id="u0", account_id=2))
71+
with pytest.raises(TeamNotFoundError):
72+
await get_team_from_db(99, None, "u0", sdb)
73+
74+
async def test_not_found_by_uid_not_owned_team(self, sdb: Database) -> None:
75+
await models_insert(
76+
sdb, TeamFactory(id=99, owner_id=1), UserAccountFactory(user_id="u0", account_id=2),
77+
)
78+
with pytest.raises(TeamNotFoundError):
79+
await get_team_from_db(99, None, "u0", sdb)
80+
81+
async def test_both_filters(self, sdb: Database) -> None:
82+
await models_insert(
83+
sdb, TeamFactory(id=9, owner_id=2), UserAccountFactory(user_id="u0", account_id=2),
84+
)
85+
team = await get_team_from_db(9, 2, "u0", sdb)
86+
assert team[Team.id.name] == 9
87+
88+
with pytest.raises(TeamNotFoundError):
89+
await get_team_from_db(9, 1, "u0", sdb)
90+
91+
with pytest.raises(TeamNotFoundError):
92+
await get_team_from_db(9, 2, "u1", sdb)
93+
94+
async def test_no_filter(self, sdb: Database) -> None:
95+
await models_insert(sdb, TeamFactory(id=9))
96+
with pytest.raises(ValueError):
97+
await get_team_from_db(9, None, None, sdb)
5698

5799

58100
class TestGetBotsTeam:

0 commit comments

Comments
 (0)