diff --git a/gratipay/models/team/__init__.py b/gratipay/models/team/__init__.py index e27857f85e..3c6eea6b3e 100644 --- a/gratipay/models/team/__init__.py +++ b/gratipay/models/team/__init__.py @@ -31,7 +31,7 @@ def slugize(name): return slug -class Team(Model, mixins.Takes): +class Team(Model, mixins.Takes, mixins.Membership): """Represent a Gratipay team. """ diff --git a/gratipay/models/team/mixins/__init__.py b/gratipay/models/team/mixins/__init__.py index ba9a106282..bc710294a5 100644 --- a/gratipay/models/team/mixins/__init__.py +++ b/gratipay/models/team/mixins/__init__.py @@ -1,3 +1,4 @@ +from .membership import MembershipMixin as Membership from .takes import TakesMixin as Takes -__all__ = ['Takes'] +__all__ = ['Membership', 'Takes'] diff --git a/gratipay/models/team/mixins/membership.py b/gratipay/models/team/mixins/membership.py new file mode 100644 index 0000000000..598639c8b6 --- /dev/null +++ b/gratipay/models/team/mixins/membership.py @@ -0,0 +1,55 @@ +from __future__ import absolute_import, division, print_function, unicode_literals + + +class NoRoom(Exception): + pass + + +class MembershipMixin(object): + """This mixin provides membership management for + :py:class:`~gratipay.models.team.Team` objects. It depends on API in the + :py:class:`~gratipay.models.team.mixins.Takes` mixin. + """ + + @property + def nmembers(self): + return self.ndistributing_to + + + def get_memberships(self, cursor=None): + """Return a list of memberships for this team. + """ + return (cursor or self.db).all(""" + + SELECT cm.* + , (SELECT p.*::participants + FROM participants p + WHERE p.id=participant_id) AS participant + FROM memberships cm + JOIN teams t + ON t.id = cm.team_id + WHERE t.id = %s + AND t.ntakes > 0 + + """, (self.id,)) + + + def add_member(self, participant): + """Add a participant to this team. + + :param Participant participant: the participant to add + :raises NoRoom: if are no unclaimed takes for the participant to claim + + """ + ntakes = self.set_ntakes_for(participant, 1) + if ntakes == 0: + raise NoRoom + + + def remove_member(self, participant): + """Remove a participant from this team. + + :param Participant participant: the participant to remove + + """ + self.set_ntakes_for(participant, 0) diff --git a/gratipay/models/team/mixins/takes.py b/gratipay/models/team/mixins/takes.py index db5d1e5647..a726a7011f 100644 --- a/gratipay/models/team/mixins/takes.py +++ b/gratipay/models/team/mixins/takes.py @@ -5,10 +5,11 @@ class TakesMixin(object): """This mixin provides API for working with - :py:class:`~gratipay.models.team.Team` takes. + :py:class:`~gratipay.models.team.Team` takes, which is used in the + :py:class:`~gratipay.models.team.mixins.Membership` mixin. Teams may issue "takes," which are like shares but different. Shares confer - legal ownership. Membership in a Gratipay team does not confer legal + legal ownership. Membership takes from a Gratipay team do not confer legal ownership---though a team's legal owners may "claim" takes, right alongside employees, contractors, etc. Takes simply determine how money is split each week. The legal relationship between the team and those receiving money is diff --git a/tests/py/test_team_membership.py b/tests/py/test_team_membership.py new file mode 100644 index 0000000000..9ab16939f0 --- /dev/null +++ b/tests/py/test_team_membership.py @@ -0,0 +1,77 @@ +from __future__ import absolute_import, division, print_function, unicode_literals + +from test_team_takes import TeamTakesHarness +from gratipay.models.team import mixins + + +class Tests(TeamTakesHarness): + + def setUp(self): + TeamTakesHarness.setUp(self) + self.enterprise.set_ntakes(1000) + + def assert_memberships(self, *expected): + actual = self.enterprise.get_memberships() + assert [(m.participant.username, m.ntakes) for m in actual] == list(expected) + + + def test_team_object_subclasses_takes_mixin(self): + assert isinstance(self.enterprise, mixins.Membership) + + + # gm - get_memberships + + def test_gm_returns_an_empty_list_when_there_are_no_members(self): + assert self.enterprise.get_memberships() == [] + + def test_gm_returns_memberships_when_there_are_members(self): + self.enterprise.add_member(self.crusher) + assert len(self.enterprise.get_memberships()) == 1 + + def test_gm_returns_more_memberships_when_there_are_more_members(self): + self.enterprise.add_member(self.crusher) + self.enterprise.add_member(self.bruiser) + assert len(self.enterprise.get_memberships()) == 2 + + + # am - add_member + + def test_am_adds_a_member(self): + self.enterprise.add_member(self.crusher) + self.assert_memberships(('crusher', 1)) + + def test_am_adds_another_member(self): + self.enterprise.add_member(self.crusher) + self.enterprise.add_member(self.bruiser) + self.assert_memberships(('crusher', 1), ('bruiser', 1)) + + def test_am_affects_cacheroonies_as_expected(self): + self.enterprise.add_member(self.crusher) + self.enterprise.add_member(self.bruiser) + assert self.enterprise.nmembers == 2 + assert self.enterprise.ntakes_claimed == 2 + assert self.enterprise.ntakes_unclaimed == 998 + + + # rm - remove_member + + def test_rm_removes_a_member(self): + self.enterprise.add_member(self.crusher) + self.enterprise.add_member(self.bruiser) + self.enterprise.remove_member(self.crusher) + self.assert_memberships(('bruiser', 1)) + + def test_rm_removes_another_member(self): + self.enterprise.add_member(self.crusher) + self.enterprise.add_member(self.bruiser) + self.enterprise.remove_member(self.crusher) + self.enterprise.remove_member(self.bruiser) + self.assert_memberships() + + def test_rm_affects_cacheroonies_as_expected(self): + self.enterprise.add_member(self.crusher) + self.enterprise.add_member(self.bruiser) + self.enterprise.remove_member(self.crusher) + assert self.enterprise.nmembers == 1 + assert self.enterprise.ntakes_claimed == 1 + assert self.enterprise.ntakes_unclaimed == 999