Skip to content

Commit 7afd340

Browse files
nejchJohnVillalovos
authored andcommitted
feat: add support for group and project invitations API
1 parent 4794ecc commit 7afd340

File tree

8 files changed

+331
-0
lines changed

8 files changed

+331
-0
lines changed

docs/api-objects.rst

+1
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ API examples
2626
gl_objects/geo_nodes
2727
gl_objects/groups
2828
gl_objects/group_access_tokens
29+
gl_objects/invitations
2930
gl_objects/issues
3031
gl_objects/keys
3132
gl_objects/boards

docs/gl_objects/invitations.rst

+73
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
###########
2+
Invitations
3+
###########
4+
5+
Invitations let you invite or add users to a group or project.
6+
7+
Reference
8+
---------
9+
10+
* v4 API:
11+
12+
+ :class:`gitlab.v4.objects.GroupInvitation`
13+
+ :class:`gitlab.v4.objects.GroupInvitationManager`
14+
+ :attr:`gitlab.v4.objects.Group.invitations`
15+
+ :class:`gitlab.v4.objects.ProjectInvitation`
16+
+ :class:`gitlab.v4.objects.ProjectInvitationManager`
17+
+ :attr:`gitlab.v4.objects.Project.invitations`
18+
19+
* GitLab API: https://docs.gitlab.com/ce/api/invitations.html
20+
21+
Examples
22+
--------
23+
24+
.. danger::
25+
26+
Creating an invitation with ``create()`` returns a status response,
27+
rather than invitation details, because it allows sending multiple
28+
invitations at the same time.
29+
30+
Thus when using several emails, you do not create a real invitation
31+
object you can manipulate, because python-gitlab cannot know which email
32+
to track as the ID.
33+
34+
In that case, use a **lazy** ``get()`` method shown below using a specific
35+
email address to create an invitation object you can manipulate.
36+
37+
Create an invitation::
38+
39+
invitation = group_or_project.invitations.create(
40+
{
41+
"email": "email@example.com",
42+
"access_level": gitlab.const.AccessLevel.DEVELOPER,
43+
}
44+
)
45+
46+
List invitations for a group or project::
47+
48+
invitations = group_or_project.invitations.list()
49+
50+
.. warning::
51+
52+
As mentioned above, GitLab does not provide a real GET endpoint for a single
53+
invitation. We can create a lazy object to later manipulate it.
54+
55+
Update an invitation::
56+
57+
invitation = group_or_project.invitations.get("email@example.com", lazy=True)
58+
invitation.access_level = gitlab.const.AccessLevel.DEVELOPER
59+
invitation.save()
60+
61+
# or
62+
group_or_project.invitations.update(
63+
"email@example.com",
64+
{"access_level": gitlab.const.AccessLevel.DEVELOPER}
65+
)
66+
67+
Delete an invitation::
68+
69+
invitation = group_or_project.invitations.get("email@example.com", lazy=True)
70+
invitation.delete()
71+
72+
# or
73+
group_or_project.invitations.delete("email@example.com")

gitlab/exceptions.py

+4
Original file line numberDiff line numberDiff line change
@@ -250,6 +250,10 @@ class GitlabImportError(GitlabOperationError):
250250
pass
251251

252252

253+
class GitlabInvitationError(GitlabOperationError):
254+
pass
255+
256+
253257
class GitlabCherryPickError(GitlabOperationError):
254258
pass
255259

gitlab/v4/objects/__init__.py

+1
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@
4545
from .groups import *
4646
from .hooks import *
4747
from .integrations import *
48+
from .invitations import *
4849
from .issues import *
4950
from .jobs import *
5051
from .keys import *

gitlab/v4/objects/groups.py

+2
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
from .export_import import GroupExportManager, GroupImportManager # noqa: F401
2323
from .group_access_tokens import GroupAccessTokenManager # noqa: F401
2424
from .hooks import GroupHookManager # noqa: F401
25+
from .invitations import GroupInvitationManager # noqa: F401
2526
from .issues import GroupIssueManager # noqa: F401
2627
from .labels import GroupLabelManager # noqa: F401
2728
from .members import ( # noqa: F401
@@ -67,6 +68,7 @@ class Group(SaveMixin, ObjectDeleteMixin, RESTObject):
6768
exports: GroupExportManager
6869
hooks: GroupHookManager
6970
imports: GroupImportManager
71+
invitations: GroupInvitationManager
7072
issues: GroupIssueManager
7173
issues_statistics: GroupIssuesStatisticsManager
7274
labels: GroupLabelManager

gitlab/v4/objects/invitations.py

+89
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
from typing import Any, cast, Union
2+
3+
from gitlab.base import RESTManager, RESTObject
4+
from gitlab.exceptions import GitlabInvitationError
5+
from gitlab.mixins import CRUDMixin, ObjectDeleteMixin, SaveMixin
6+
from gitlab.types import CommaSeparatedListAttribute, RequiredOptional
7+
8+
__all__ = [
9+
"ProjectInvitation",
10+
"ProjectInvitationManager",
11+
"GroupInvitation",
12+
"GroupInvitationManager",
13+
]
14+
15+
16+
class InvitationMixin(CRUDMixin):
17+
def create(self, *args: Any, **kwargs: Any) -> RESTObject:
18+
invitation = super().create(*args, **kwargs)
19+
20+
if invitation.status == "error":
21+
raise GitlabInvitationError(invitation.message)
22+
23+
return invitation
24+
25+
26+
class ProjectInvitation(SaveMixin, ObjectDeleteMixin, RESTObject):
27+
_id_attr = "email"
28+
29+
30+
class ProjectInvitationManager(InvitationMixin, RESTManager):
31+
_path = "/projects/{project_id}/invitations"
32+
_obj_cls = ProjectInvitation
33+
_from_parent_attrs = {"project_id": "id"}
34+
_create_attrs = RequiredOptional(
35+
required=("access_level",),
36+
optional=(
37+
"expires_at",
38+
"invite_source",
39+
"tasks_to_be_done",
40+
"tasks_project_id",
41+
),
42+
exclusive=("email", "user_id"),
43+
)
44+
_update_attrs = RequiredOptional(
45+
optional=("access_level", "expires_at"),
46+
)
47+
_list_filters = ("query",)
48+
_types = {
49+
"email": CommaSeparatedListAttribute,
50+
"user_id": CommaSeparatedListAttribute,
51+
}
52+
53+
def get(
54+
self, id: Union[str, int], lazy: bool = False, **kwargs: Any
55+
) -> ProjectInvitation:
56+
return cast(ProjectInvitation, super().get(id=id, lazy=lazy, **kwargs))
57+
58+
59+
class GroupInvitation(SaveMixin, ObjectDeleteMixin, RESTObject):
60+
_id_attr = "email"
61+
62+
63+
class GroupInvitationManager(InvitationMixin, RESTManager):
64+
_path = "/groups/{group_id}/invitations"
65+
_obj_cls = GroupInvitation
66+
_from_parent_attrs = {"group_id": "id"}
67+
_create_attrs = RequiredOptional(
68+
required=("access_level",),
69+
optional=(
70+
"expires_at",
71+
"invite_source",
72+
"tasks_to_be_done",
73+
"tasks_project_id",
74+
),
75+
exclusive=("email", "user_id"),
76+
)
77+
_update_attrs = RequiredOptional(
78+
optional=("access_level", "expires_at"),
79+
)
80+
_list_filters = ("query",)
81+
_types = {
82+
"email": CommaSeparatedListAttribute,
83+
"user_id": CommaSeparatedListAttribute,
84+
}
85+
86+
def get(
87+
self, id: Union[str, int], lazy: bool = False, **kwargs: Any
88+
) -> GroupInvitation:
89+
return cast(GroupInvitation, super().get(id=id, lazy=lazy, **kwargs))

gitlab/v4/objects/projects.py

+2
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@
5555
from .files import ProjectFileManager # noqa: F401
5656
from .hooks import ProjectHookManager # noqa: F401
5757
from .integrations import ProjectIntegrationManager, ProjectServiceManager # noqa: F401
58+
from .invitations import ProjectInvitationManager # noqa: F401
5859
from .issues import ProjectIssueManager # noqa: F401
5960
from .jobs import ProjectJobManager # noqa: F401
6061
from .labels import ProjectLabelManager # noqa: F401
@@ -179,6 +180,7 @@ class Project(RefreshMixin, SaveMixin, ObjectDeleteMixin, RepositoryMixin, RESTO
179180
hooks: ProjectHookManager
180181
imports: ProjectImportManager
181182
integrations: ProjectIntegrationManager
183+
invitations: ProjectInvitationManager
182184
issues: ProjectIssueManager
183185
issues_statistics: ProjectIssuesStatisticsManager
184186
jobs: ProjectJobManager
+159
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
"""
2+
GitLab API: https://docs.gitlab.com/ce/api/invitations.html
3+
"""
4+
5+
import re
6+
7+
import pytest
8+
import responses
9+
10+
from gitlab.exceptions import GitlabInvitationError
11+
12+
create_content = {"email": "email@example.com", "access_level": 30}
13+
success_content = {"status": "success"}
14+
error_content = {
15+
"status": "error",
16+
"message": {
17+
"test@example.com": "Invite email has already been taken",
18+
"test2@example.com": "User already exists in source",
19+
"test_username": "Access level is not included in the list",
20+
},
21+
}
22+
invitations_content = [
23+
{
24+
"id": 1,
25+
"invite_email": "member@example.org",
26+
"created_at": "2020-10-22T14:13:35Z",
27+
"access_level": 30,
28+
"expires_at": "2020-11-22T14:13:35Z",
29+
"user_name": "Raymond Smith",
30+
"created_by_name": "Administrator",
31+
},
32+
]
33+
invitation_content = {
34+
"expires_at": "2012-10-22T14:13:35Z",
35+
"access_level": 40,
36+
}
37+
38+
39+
@pytest.fixture
40+
def resp_invitations_list():
41+
with responses.RequestsMock() as rsps:
42+
rsps.add(
43+
method=responses.GET,
44+
url=re.compile(r"http://localhost/api/v4/(groups|projects)/1/invitations"),
45+
json=invitations_content,
46+
content_type="application/json",
47+
status=200,
48+
)
49+
yield rsps
50+
51+
52+
@pytest.fixture
53+
def resp_invitation_create():
54+
with responses.RequestsMock() as rsps:
55+
rsps.add(
56+
method=responses.POST,
57+
url=re.compile(r"http://localhost/api/v4/(groups|projects)/1/invitations"),
58+
json=success_content,
59+
content_type="application/json",
60+
status=200,
61+
)
62+
yield rsps
63+
64+
65+
@pytest.fixture
66+
def resp_invitation_create_error():
67+
with responses.RequestsMock() as rsps:
68+
rsps.add(
69+
method=responses.POST,
70+
url=re.compile(r"http://localhost/api/v4/(groups|projects)/1/invitations"),
71+
json=error_content,
72+
content_type="application/json",
73+
status=200,
74+
)
75+
yield rsps
76+
77+
78+
@pytest.fixture
79+
def resp_invitation_update():
80+
with responses.RequestsMock() as rsps:
81+
pattern = re.compile(
82+
r"http://localhost/api/v4/(groups|projects)/1/invitations/email%40example.com"
83+
)
84+
rsps.add(
85+
method=responses.PUT,
86+
url=pattern,
87+
json=invitation_content,
88+
content_type="application/json",
89+
status=200,
90+
)
91+
yield rsps
92+
93+
94+
@pytest.fixture
95+
def resp_invitation_delete():
96+
with responses.RequestsMock() as rsps:
97+
pattern = re.compile(
98+
r"http://localhost/api/v4/(groups|projects)/1/invitations/email%40example.com"
99+
)
100+
rsps.add(
101+
method=responses.DELETE,
102+
url=pattern,
103+
status=204,
104+
)
105+
yield rsps
106+
107+
108+
def test_list_group_invitations(group, resp_invitations_list):
109+
invitations = group.invitations.list()
110+
assert invitations[0].invite_email == "member@example.org"
111+
112+
113+
def test_create_group_invitation(group, resp_invitation_create):
114+
invitation = group.invitations.create(create_content)
115+
assert invitation.status == "success"
116+
117+
118+
def test_update_group_invitation(group, resp_invitation_update):
119+
invitation = group.invitations.get("email@example.com", lazy=True)
120+
invitation.access_level = 30
121+
invitation.save()
122+
123+
124+
def test_delete_group_invitation(group, resp_invitation_delete):
125+
invitation = group.invitations.get("email@example.com", lazy=True)
126+
invitation.delete()
127+
group.invitations.delete("email@example.com")
128+
129+
130+
def test_list_project_invitations(project, resp_invitations_list):
131+
invitations = project.invitations.list()
132+
assert invitations[0].invite_email == "member@example.org"
133+
134+
135+
def test_create_project_invitation(project, resp_invitation_create):
136+
invitation = project.invitations.create(create_content)
137+
assert invitation.status == "success"
138+
139+
140+
def test_update_project_invitation(project, resp_invitation_update):
141+
invitation = project.invitations.get("email@example.com", lazy=True)
142+
invitation.access_level = 30
143+
invitation.save()
144+
145+
146+
def test_delete_project_invitation(project, resp_invitation_delete):
147+
invitation = project.invitations.get("email@example.com", lazy=True)
148+
invitation.delete()
149+
project.invitations.delete("email@example.com")
150+
151+
152+
def test_create_group_invitation_raises(group, resp_invitation_create_error):
153+
with pytest.raises(GitlabInvitationError, match="User already exists"):
154+
group.invitations.create(create_content)
155+
156+
157+
def test_create_project_invitation_raises(project, resp_invitation_create_error):
158+
with pytest.raises(GitlabInvitationError, match="User already exists"):
159+
project.invitations.create(create_content)

0 commit comments

Comments
 (0)