diff --git a/gratipay/models/team/__init__.py b/gratipay/models/team/__init__.py index efdecdace2..1dc244eca3 100644 --- a/gratipay/models/team/__init__.py +++ b/gratipay/models/team/__init__.py @@ -33,7 +33,7 @@ def slugize(name): return slug -class Team(Model, mixins.Takes, mixins.TipMigration): +class Team(Model, mixins.Takes, mixins.TipMigration, mixins.Membership): """Represent a Gratipay team. """ diff --git a/gratipay/models/team/mixins/__init__.py b/gratipay/models/team/mixins/__init__.py index 06596442c7..0a37e2cfea 100644 --- a/gratipay/models/team/mixins/__init__.py +++ b/gratipay/models/team/mixins/__init__.py @@ -1,4 +1,5 @@ +from .membership import MembershipMixin as Membership from .takes import TakesMixin as Takes from .tip_migration import TipMigrationMixin as TipMigration -__all__ = ['Takes', 'TipMigration'] +__all__ = ['Membership', 'Takes', 'TipMigration'] diff --git a/gratipay/models/team/mixins/membership.py b/gratipay/models/team/mixins/membership.py new file mode 100644 index 0000000000..d97a73cc40 --- /dev/null +++ b/gratipay/models/team/mixins/membership.py @@ -0,0 +1,54 @@ +from __future__ import absolute_import, division, print_function, unicode_literals + +from gratipay.models.team.mixins.takes import ZERO, PENNY + + +class MembershipMixin(object): + """Teams may have zero or more members. + """ + + + @property + def nmembers(self): + """The number of members. Read-only and computed (not in the db); equal to + :py:attr:`~gratipay.models.team.mixins.takes.ndistributing_to`. + """ + return self.ndistributing_to + + + def get_memberships(self, cursor=None): + """Return a list of membership records for this team. + """ + return (cursor or self.db).all(""" + + SELECT ct.* + , (SELECT p.*::participants + FROM participants p + WHERE p.id=participant_id) AS participant + FROM current_takes ct + JOIN teams t + ON t.id = ct.team_id + WHERE t.id = %s + AND ct.amount > 0 + + """, (self.id,)) + + + def add_member(self, participant, recorder): + """Add a participant to this team. + + :param Participant participant: the participant to add + :param Participant recorder: the participant making the change + + """ + self.set_take_for(participant, PENNY, recorder) + + + def remove_member(self, participant, recorder): + """Remove a participant from this team. + + :param Participant participant: the participant to remove + :param Participant recorder: the participant making the change + + """ + self.set_take_for(participant, ZERO, recorder) diff --git a/tests/py/test_team_membership.py b/tests/py/test_team_membership.py new file mode 100644 index 0000000000..b2eaf6bcc6 --- /dev/null +++ b/tests/py/test_team_membership.py @@ -0,0 +1,72 @@ +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) + + def assert_memberships(self, *expected): + actual = self.enterprise.get_memberships() + assert [m.participant.username 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, self.picard) + 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.picard) + self.enterprise.add_member(self.bruiser, self.picard) + assert len(self.enterprise.get_memberships()) == 2 + + + # am - add_member + + def test_am_adds_a_member(self): + self.enterprise.add_member(self.crusher, self.picard) + self.assert_memberships('crusher') + + def test_am_adds_another_member(self): + self.enterprise.add_member(self.crusher, self.picard) + self.enterprise.add_member(self.bruiser, self.picard) + self.assert_memberships('crusher', 'bruiser') + + def test_am_affects_computed_values_as_expected(self): + self.enterprise.add_member(self.crusher, self.picard) + self.enterprise.add_member(self.bruiser, self.picard) + assert self.enterprise.nmembers == 2 + + + # rm - remove_member + + def test_rm_removes_a_member(self): + self.enterprise.add_member(self.crusher, self.picard) + self.enterprise.add_member(self.bruiser, self.picard) + self.enterprise.remove_member(self.crusher, self.crusher) + self.assert_memberships('bruiser') + + def test_rm_removes_another_member(self): + self.enterprise.add_member(self.crusher, self.picard) + self.enterprise.add_member(self.bruiser, self.picard) + self.enterprise.remove_member(self.crusher, self.crusher) + self.enterprise.remove_member(self.bruiser, self.picard) + self.assert_memberships() + + def test_rm_affects_computed_values_as_expected(self): + self.enterprise.add_member(self.crusher, self.picard) + self.enterprise.add_member(self.bruiser, self.picard) + self.enterprise.remove_member(self.crusher, self.crusher) + assert self.enterprise.nmembers == 1