Skip to content

Commit 7a4f4c2

Browse files
committed
Clean-up new organization invitations work
There were several problems preventing us from being able to merge the new organization invitations work: - Merge conflicts - Method naming - API usage (using one API instead of the correct API) - Parameters - Deprecations - Testing - Style issues This commit cleans up the hard work in the original pull request so it can be merged safely and cleanly.
1 parent c6117c7 commit 7a4f4c2

5 files changed

Lines changed: 266 additions & 70 deletions

File tree

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
1.2.0: unreleased
2+
-----------------
3+
4+
This is a small release with some enhancments.
5+
6+
Features Added
7+
``````````````
8+
9+
- Organization Invitations Preview API is now supported. This includes an
10+
additional ``Invitation`` object. This is the result of hard work by Hal
11+
Wine.

github3/orgs.py

Lines changed: 205 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,8 @@ class _Team(models.GitHubCore):
2020

2121
class_name = '_Team'
2222
# Roles available to members on a team.
23-
members_roles = frozenset(['member', 'maintainer', 'all'])
23+
member_roles = frozenset(['member', 'maintainer'])
24+
filterable_member_roles = member_roles.union(['all'])
2425

2526
def _update_attributes(self, team):
2627
self._api = team['url']
@@ -38,6 +39,10 @@ def _repr(self):
3839
def add_member(self, username):
3940
"""Add ``username`` to this team.
4041
42+
.. deprecated:: 1.0.0
43+
44+
Use :meth:`add_or_update_membership` instead.
45+
4146
:param str username:
4247
the username of the user you would like to add to this team.
4348
:returns:
@@ -53,6 +58,37 @@ def add_member(self, username):
5358
url = self._build_url('members', username, base_url=self._api)
5459
return self._boolean(self._put(url), 204, 404)
5560

61+
@requires_auth
62+
def add_or_update_membership(self, username, role='member'):
63+
"""Add or update the user's membership in this team.
64+
65+
This returns a dictionary like so::
66+
67+
{
68+
'state': 'pending',
69+
'url': 'https://api.github.com/teams/...',
70+
'role': 'member',
71+
}
72+
73+
:param str username:
74+
(required), login of user whose membership is being modified
75+
:param str role:
76+
(optional), the role the user should have once their membership
77+
has been modified. Options: 'member', 'maintainer'. Default:
78+
'member'
79+
:returns:
80+
dictionary of the invitation response
81+
:rtype:
82+
dict
83+
"""
84+
if role not in self.member_roles:
85+
raise ValueError("'role' must be one of {}".format(', '.join(
86+
sorted(self.member_roles)
87+
)))
88+
data = {'role': role}
89+
url = self._build_url('memberships', username, base_url=self._api)
90+
return self._json(self._put(url, json=data), 200)
91+
5692
@requires_auth
5793
def add_repository(self, repository, permission=''):
5894
"""Add ``repository`` to this team.
@@ -125,6 +161,10 @@ def has_repository(self, repository):
125161
def invite(self, username):
126162
"""Invite the user to join this team.
127163
164+
.. deprecated:: 1.2.0
165+
166+
Use :meth:`add_or_update_membership` instead.
167+
128168
This returns a dictionary like so::
129169
130170
{'state': 'pending', 'url': 'https://api.github.com/teams/...'}
@@ -136,8 +176,11 @@ def invite(self, username):
136176
:rtype:
137177
dict
138178
"""
139-
url = self._build_url('memberships', username, base_url=self._api)
140-
return self._json(self._put(url), 200)
179+
warnings.warn(
180+
'This method is deprecated. Please use '
181+
'``add_or_update_membership`` instead.',
182+
DeprecationWarning)
183+
return self.add_or_update_membership(username)
141184

142185
@requires_auth
143186
def is_member(self, username):
@@ -150,6 +193,10 @@ def is_member(self, username):
150193
:rtype:
151194
bool
152195
"""
196+
warnings.warn(
197+
'This method is deprecated. Please use '
198+
'``membership_for`` instead.',
199+
DeprecationWarning)
153200
url = self._build_url('members', username, base_url=self._api)
154201
return self._boolean(self._get(url), 204, 404)
155202

@@ -173,7 +220,7 @@ def members(self, role=None, number=-1, etag=None):
173220
"""
174221
headers = {}
175222
params = {}
176-
if role in self.members_roles:
223+
if role in self.filterable_member_roles:
177224
params['role'] = role
178225
headers['Accept'] = 'application/vnd.github.ironman-preview+json'
179226
url = self._build_url('members', base_url=self._api)
@@ -218,6 +265,10 @@ def membership_for(self, username):
218265
def remove_member(self, username):
219266
"""Remove ``username`` from this team.
220267
268+
.. deprecated:: 1.0.0
269+
270+
Use :meth:`revoke_membership` instead.
271+
221272
:param str username:
222273
(required), username of the member to remove
223274
:returns:
@@ -374,7 +425,12 @@ class _Organization(models.GitHubCore):
374425
members_filters = frozenset(['2fa_disabled', 'all'])
375426

376427
# Roles available to members in an organization.
377-
members_roles = frozenset(['all', 'admin', 'member'])
428+
member_roles = frozenset(['admin', 'member'])
429+
filterable_member_roles = member_roles.union(['all'])
430+
431+
# Roles for invitations, see also:
432+
# https://developer.github.com/v3/orgs/members/#create-organization-invitation
433+
invitation_roles = frozenset(['admin', 'direct_member', 'billing_manager'])
378434

379435
def _update_attributes(self, org):
380436
self.avatar_url = org['avatar_url']
@@ -446,6 +502,31 @@ def add_member(self, username, team_id):
446502
url = self._build_url('teams', str(team_id), 'members', str(username))
447503
return self._boolean(self._put(url), 204, 404)
448504

505+
@requires_auth
506+
def add_or_update_membership(self, username, role='member'):
507+
"""Add a member or update their role.
508+
509+
:param str username:
510+
(required), user to add or update.
511+
:param str role:
512+
(optional), role to give to the user. Options are ``member``,
513+
``admin``. Defaults to ``member``.
514+
:returns:
515+
the created or updated membership
516+
:rtype:
517+
:class:`~github3.orgs.Membership`
518+
:raises:
519+
ValueError if role is not a valid choice
520+
"""
521+
if role not in self.member_roles:
522+
raise ValueError("'role' must be one of {}".format(', '.join(
523+
sorted(self.member_roles)
524+
)))
525+
data = {'role': role}
526+
url = self._build_url('memberships', str(username), base_url=self._api)
527+
json = self._json(self._put(url, json=data), 200)
528+
return self._instance_or_null(Membership, json)
529+
449530
@requires_auth
450531
def add_repository(self, repository, team_id): # FIXME(jlk): add perms
451532
"""Add ``repository`` to ``team``.
@@ -627,27 +708,48 @@ def edit(self, billing_email=None, company=None, email=None, location=None,
627708
return False
628709

629710
@requires_auth
630-
def invite(self, username, role=None):
711+
def invite(self, team_ids, invitee_id=None, email=None,
712+
role='direct_member'):
631713
"""Invite the user to join this organization.
632714
633-
:param str username:
634-
(required), user to invite to join this organization.
715+
:param list[int] team_ids:
716+
(required), list of team identifiers to invite the user to
717+
:param int invitee_id:
718+
(required if email is not specified), the identifier for the user
719+
being invited
720+
:param str email:
721+
(required if invitee_id is not specified), the email address of
722+
the user being invited
635723
:param str role:
636-
(optional) role from members_roles
724+
(optional) role to provide to the invited user. Must be one of
637725
:returns:
638-
dictionary resembling
639-
640-
.. code-block:: python
641-
642-
{'state': 'pending', 'url': 'https://api.github.com/orgs/...'}
726+
the created invitation
643727
:rtype:
644-
dict
728+
:class:`~github3.orgs.Invitation`
645729
"""
646-
data = {}
647-
if role in self.members_roles:
648-
data['role'] = role
649-
url = self._build_url('memberships', username, base_url=self._api)
650-
return self._json(self._put(url, data=dumps(data)), 200)
730+
if ((invitee_id is None and email is None) or
731+
(invitee_id is not None and email is not None)):
732+
raise ValueError(
733+
"One of either 'invitee_id' or 'email' must be specified"
734+
)
735+
if not team_ids:
736+
raise ValueError(
737+
"'team_ids' must be a non-empty list of integers"
738+
)
739+
data = {'team_ids': team_ids}
740+
if invitee_id is not None:
741+
data['invitee_id'] = invitee_id
742+
else:
743+
data['email'] = email
744+
if role not in self.invitation_roles:
745+
raise ValueError("'role' must be one of {}".format(', '.join(
746+
sorted(self.invitation_roles)
747+
)))
748+
headers = {'Accept': 'application/vnd.github.dazzler-preview.json'}
749+
data['role'] = role
750+
url = self._build_url('invitations', base_url=self._api)
751+
json = self._json(self._post(url, data=data, headers=headers), 200)
752+
return self._instance_or_null(Invitation, json)
651753

652754
def is_member(self, username):
653755
"""Check if the user named ``username`` is a member.
@@ -734,14 +836,16 @@ def public_events(self, number=-1, etag=None):
734836

735837
@requires_auth
736838
def invitations(self, number=-1, etag=None):
737-
r"""Iterate over outstanding invitations to this organization.
839+
"""Iterate over outstanding invitations to this organization.
738840
739-
:returns: generator of
841+
:returns:
842+
generator of invitation objects
843+
:rtype:
844+
:class:`~github3.orgs.Invitation`
740845
"""
741-
headers = {'Accept': 'application/vnd.github.korra-preview', }
742-
params = {}
846+
headers = {'Accept': 'application/vnd.github.korra-preview'}
743847
url = self._build_url('invitations', base_url=self._api)
744-
return self._iter(int(number), url, dict, params=params, etag=etag,
848+
return self._iter(int(number), url, Invitation, etag=etag,
745849
headers=headers)
746850

747851
def members(self, filter=None, role=None, number=-1, etag=None):
@@ -769,7 +873,7 @@ def members(self, filter=None, role=None, number=-1, etag=None):
769873
params = {}
770874
if filter in self.members_filters:
771875
params['filter'] = filter
772-
if role in self.members_roles:
876+
if role in self.filterable_member_roles:
773877
params['role'] = role
774878
# TODO(sigmavirus24): Determine if the preview header is still
775879
# necessary
@@ -779,7 +883,7 @@ def members(self, filter=None, role=None, number=-1, etag=None):
779883
etag=etag, headers=headers)
780884

781885
@requires_auth
782-
def membership(self, username):
886+
def membership_for(self, username):
783887
"""Obtain the membership status of ``username``.
784888
785889
Implements
@@ -788,12 +892,13 @@ def membership(self, username):
788892
:param str username:
789893
(required), username name of the user
790894
:returns:
791-
dictonary of the membership information
895+
the membership information
792896
:rtype:
793-
dict
897+
:class:`~github3.orgs.Membership`
794898
"""
795899
url = self._build_url('memberships', username, base_url=self._api)
796-
return self._json(self._get(url), 200, 404)
900+
json = self._json(self._get(url), 200, 404)
901+
return self._instance_or_null(Membership, json)
797902

798903
def public_members(self, number=-1, etag=None):
799904
"""Iterate over public members of this organization.
@@ -916,6 +1021,11 @@ def publicize_member(self, username):
9161021
def remove_member(self, username):
9171022
"""Remove the user named ``username`` from this organization.
9181023
1024+
.. note::
1025+
1026+
Only a user may publicize their own membership. See also:
1027+
https://developer.github.com/v3/orgs/members/#publicize-a-users-membership
1028+
9191029
:param str username:
9201030
name of the user to remove from the org
9211031
:returns:
@@ -1112,6 +1222,71 @@ class ShortOrganization(_Organization):
11121222
_refresh_to = Organization
11131223

11141224

1225+
class Invitation(models.GitHubCore):
1226+
"""Object representing an invitation to an organization.
1227+
1228+
.. attribute:: created_at
1229+
1230+
A :class:`~datetime.datetime` instance representing the time and date
1231+
when this invitation was created.
1232+
1233+
.. attribute:: email
1234+
1235+
The email address of the user invited to the organization.
1236+
1237+
.. attribute:: id
1238+
1239+
The unique identifier for this invitation.
1240+
1241+
.. attribute:: invitation_team_url
1242+
1243+
The API URL to retrieve the :class:`~github3.orgs.ShortTeam` objects
1244+
associated with this invitation.
1245+
1246+
.. attribute:: inviter
1247+
1248+
A :class:`~github3.users.ShortUser` representing the user who invited
1249+
the user identified by ``login``.
1250+
1251+
.. attribute:: login
1252+
1253+
The username of the user invited to the organization.
1254+
1255+
.. attribute:: team_count
1256+
1257+
The number of teams involved in this invitation.
1258+
"""
1259+
1260+
def _update_attributes(self, json):
1261+
self.created_at = self._strptime(json['created_at'])
1262+
self.email = json['email']
1263+
self.id = json['id']
1264+
self.inviter = users.ShortUser(json['inviter'], self)
1265+
self.login = json['login']
1266+
# NOTE(sigmavirus24): GitHub docs claim these should be present but
1267+
# in testing it is not.
1268+
self.invitation_team_url = json.get('invitation_team_url')
1269+
self.team_count = json.get('team_count')
1270+
1271+
def _repr(self):
1272+
return '<Invitation {} for [{}] from [{}]>'.format(
1273+
self.id, self.login, self.inviter.login
1274+
)
1275+
1276+
@requires_auth
1277+
def teams(self):
1278+
"""Retrieve the list of teams associated with this invite.
1279+
1280+
:returns:
1281+
generator of teams associated with this invitation
1282+
:rtype:
1283+
:class:`~github3.orgs.ShortTeam`
1284+
"""
1285+
return self._iter(-1, self.invitation_team_url, ShortTeam, headers={
1286+
'Accept': 'application/vnd.github.dazzler-preview.json',
1287+
})
1288+
1289+
11151290
class Membership(models.GitHubCore):
11161291
"""Object describing a user's membership in teams and organizations.
11171292

0 commit comments

Comments
 (0)