diff --git a/gratipay/models/team/__init__.py b/gratipay/models/team/__init__.py index 13016395a9..e27857f85e 100644 --- a/gratipay/models/team/__init__.py +++ b/gratipay/models/team/__init__.py @@ -7,6 +7,7 @@ from aspen import json, log from gratipay.exceptions import InvalidTeamName from gratipay.models import add_event +from gratipay.models.team import mixins from postgres.orm import Model from gratipay.billing.exchanges import MINIMUM_CHARGE @@ -30,7 +31,7 @@ def slugize(name): return slug -class Team(Model): +class Team(Model, mixins.Takes): """Represent a Gratipay team. """ diff --git a/gratipay/models/team/mixins/__init__.py b/gratipay/models/team/mixins/__init__.py new file mode 100644 index 0000000000..ba9a106282 --- /dev/null +++ b/gratipay/models/team/mixins/__init__.py @@ -0,0 +1,3 @@ +from .takes import TakesMixin as Takes + +__all__ = ['Takes'] diff --git a/gratipay/models/team/mixins/takes.py b/gratipay/models/team/mixins/takes.py new file mode 100644 index 0000000000..55a47ba7b4 --- /dev/null +++ b/gratipay/models/team/mixins/takes.py @@ -0,0 +1,177 @@ +from __future__ import absolute_import, division, print_function, unicode_literals + +from gratipay.models import add_event + + +class TakesMixin(object): + """This mixin provides API for working with + :py:class:`~gratipay.models.team.Team` takes. + + Teams may issue "takes," which are like shares but different. Shares confer + legal ownership. Membership in a Gratipay team does 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 + out of scope for Gratipay; it's the team's responsibility. + + """ + + #: The total number of takes issued for this team. Read-only; + #: modified by :py:meth:`set_ntakes`. + + ntakes = 0 + + + #: The aggregate number of takes that have been claimed by team members. + #: Read-only; modified by :py:meth:`set_ntakes_for`. + + ntakes_claimed = 0 + + + #: The number of takes that have yet to be claimed by any team member. + #: Read-only; modified by :py:meth:`set_ntakes` and + #: :py:meth:`set_ntakes_for`. + + ntakes_unclaimed = 0 + + + def set_ntakes(self, ntakes): + """Set the total number of takes for this team. + + :param int ntakes: the target number of takes + + :return: the number of takes actually set + + This method does not alter claimed takes, so it has the effect of + diluting or concentrating membership, depending on whether you are + increasing or decreasing number of takes (respectively). If you try to + set the number of takes to fewer than the number of claimed takes, all + existing unclaimed takes are withdrawn, but claimed takes remain. If + there are no claimed takes, and you try to set the number of + outstanding takes lower than zero, it is set to zero. + + """ + with self.db.get_cursor() as cursor: + + new_ntakes, new_ntakes_unclaimed = cursor.one(""" + + UPDATE teams + SET ntakes = greatest(0, ntakes_claimed, %(ntakes)s) + , ntakes_unclaimed = greatest(0, ntakes_claimed, %(ntakes)s) - ntakes_claimed + WHERE id=%(team_id)s + RETURNING ntakes, ntakes_unclaimed + + """, dict(ntakes=ntakes, team_id=self.id)) + + add_event( cursor + , 'team' + , dict( id=self.id + , action='outstanding takes changed' + , old={'ntakes': self.ntakes, 'ntakes_unclaimed': self.ntakes_unclaimed} + , new={'ntakes': new_ntakes, 'ntakes_unclaimed': new_ntakes_unclaimed} + ) + ) + + self.set_attributes(ntakes=new_ntakes, ntakes_unclaimed=new_ntakes_unclaimed) + + return self.ntakes + + + def set_ntakes_for(self, participant, ntakes, recorder=None): + """Set the number of takes claimed by a given participant. + + :param Participant participant: the participant to set the number of + claimed takes for + :param int ntakes: the number of takes + + :return: the number of takes actually assigned + + This method will try to set the given participant's total number of + claimed takes to ``ntakes``, or as many as possible, if there are not + enough unclaimed takes that is less than ``ntakes``. + + It is a bug to set ntakes for a participant that is unclaimed, or to to + set ntakes to more than zero for a participant that is suspicious, or + without a verified email, identity, and payout route. + + """ + if not participant.is_claimed: + raise BadMember(participant, 'unclaimed') + if ntakes > 0: + if participant.is_suspicious: + raise BadMember(participant, 'suspicious') + if not participant.email_address: + raise BadMember(participant, 'missing an email') + if not participant.has_verified_identity: + raise BadMember(participant, 'missing an identity') + if not participant.has_payout_route: + raise BadMember(participant, 'missing a payout route') + + recorder = recorder or participant + + with self.db.get_cursor() as cursor: + + old_ntakes = cursor.one(""" + SELECT ntakes FROM takes WHERE participant_id=%s ORDER BY mtime DESC LIMIT 1 + """, (participant.id,)) + + ndistributing_to = self.ndistributing_to + nclaimed = self.ntakes_claimed + nunclaimed = self.ntakes_unclaimed + + if old_ntakes: + nclaimed -= old_ntakes + nunclaimed += old_ntakes + else: + ndistributing_to += 1 + + ntakes = min(ntakes, nunclaimed) + + if ntakes: + nunclaimed -= ntakes + nclaimed += ntakes + else: + ndistributing_to -= 1 + + cursor.run(""" + + UPDATE teams + SET ndistributing_to=%s + , ntakes_claimed=%s + , ntakes_unclaimed=%s + WHERE id=%s + + """, (ndistributing_to, nclaimed, nunclaimed, self.id)) + + cursor.run( """ + + INSERT INTO takes + (ctime, participant_id, team_id, ntakes, recorder_id) + VALUES ( COALESCE (( SELECT ctime + FROM takes + WHERE (participant_id=%(participant_id)s + AND team_id=%(team_id)s) + LIMIT 1 + ), CURRENT_TIMESTAMP) + , %(participant_id)s, %(team_id)s, %(ntakes)s, %(recorder_id)s + ) + + """, { 'participant_id': participant.id + , 'team_id': self.id + , 'ntakes': ntakes + , 'recorder_id': recorder.id + }) + + self.set_attributes( ntakes_claimed=nclaimed + , ntakes_unclaimed=nunclaimed + , ndistributing_to=ndistributing_to + ) + + return ntakes + + +class BadMember(Exception): + def __init__(self, participant, reason): + self.participant = participant + self.reason = reason + Exception.__init__(self, participant.id, participant.username, reason) diff --git a/sql/branch.sql b/sql/branch.sql index e291338d6e..88ea6935c0 100644 --- a/sql/branch.sql +++ b/sql/branch.sql @@ -9,3 +9,39 @@ BEGIN; DROP VIEW current_payroll; DROP TABLE payroll; END; + + +-- https://github.com/gratipay/gratipay.com/pull/4023 + +BEGIN; + + -- takes - how participants express membership in teams + CREATE TABLE takes + ( id bigserial PRIMARY KEY + , ctime timestamp with time zone NOT NULL + , mtime timestamp with time zone NOT NULL DEFAULT now() + , participant_id bigint NOT NULL REFERENCES participants(id) + , team_id bigint NOT NULL REFERENCES teams(id) + , ntakes bigint NOT NULL + , recorder_id bigint NOT NULL REFERENCES participants(id) + , CONSTRAINT not_negative CHECK (ntakes >= 0) + ); + + CREATE VIEW memberships AS + SELECT * FROM ( + SELECT DISTINCT ON (participant_id, team_id) t.* + FROM takes t + JOIN participants p ON p.id = t.participant_id + WHERE p.is_suspicious IS NOT TRUE + ORDER BY participant_id + , team_id + , mtime DESC + ) AS anon WHERE ntakes > 0; + + ALTER TABLE teams ADD COLUMN ntakes bigint default 0; + ALTER TABLE teams ADD COLUMN ntakes_unclaimed bigint default 0; + ALTER TABLE teams ADD COLUMN ntakes_claimed bigint default 0; + ALTER TABLE teams ADD CONSTRAINT ntakes_sign CHECK (ntakes >= 0); + ALTER TABLE teams ADD CONSTRAINT ntakes_sum CHECK (ntakes = ntakes_claimed + ntakes_unclaimed); + +END; diff --git a/tests/py/test_team_takes.py b/tests/py/test_team_takes.py new file mode 100644 index 0000000000..daaa33d35c --- /dev/null +++ b/tests/py/test_team_takes.py @@ -0,0 +1,136 @@ +from __future__ import absolute_import, division, print_function, unicode_literals + +from gratipay.testing import Harness +from gratipay.models.team.mixins.takes import BadMember +from pytest import raises + + +class TeamTakesHarness(Harness): + # Factored out to share with membership tests ... + + def setUp(self): + self.enterprise = self.make_team('The Enterprise') + + self.TT = self.db.one("SELECT id FROM countries WHERE code='TT'") + + self.crusher = self.make_participant( 'crusher' + , email_address='crusher@example.com' + , claimed_time='now' + , last_paypal_result='' + ) + self.crusher.store_identity_info(self.TT, 'nothing-enforced', {'name': 'Crusher'}) + self.crusher.set_identity_verification(self.TT, True) + + self.bruiser = self.make_participant( 'bruiser' + , email_address='bruiser@example.com' + , claimed_time='now' + , last_paypal_result='' + ) + self.bruiser.store_identity_info(self.TT, 'nothing-enforced', {'name': 'Bruiser'}) + self.bruiser.set_identity_verification(self.TT, True) + + +class TestSetNtakes(TeamTakesHarness): + + def test_sn_sets_ntakes(self): + assert self.enterprise.set_ntakes(1024) == 1024 + + def test_sn_actually_sets_ntakes(self): + self.enterprise.set_ntakes(1024) + assert self.db.one("SELECT ntakes FROM teams") == self.enterprise.ntakes == 1024 + + def test_sn_wont_set_ntakes_below_zero(self): + assert self.enterprise.set_ntakes(-1) == 0 + + def test_sn_wont_set_ntakes_below_nclaimed(self): + self.enterprise.set_ntakes(1024) + self.enterprise.set_ntakes_for(self.crusher, 128) + assert self.enterprise.set_ntakes(-1024) == 128 + + def test_sn_affects_cacheroonies_as_expected(self): + assert self.enterprise.ntakes == 0 + assert self.enterprise.ntakes_claimed == 0 + assert self.enterprise.ntakes_unclaimed == 0 + + self.enterprise.set_ntakes(1024) + assert self.enterprise.ntakes == 1024 + assert self.enterprise.ntakes_claimed == 0 + assert self.enterprise.ntakes_unclaimed == 1024 + + self.enterprise.set_ntakes_for(self.crusher, 128) + + self.enterprise.set_ntakes(-1024) + assert self.enterprise.ntakes == 128 + assert self.enterprise.ntakes_claimed == 128 + assert self.enterprise.ntakes_unclaimed == 0 + + +class TestSetNtakesFor(TeamTakesHarness): + + def setUp(self): + super(TestSetNtakesFor, self).setUp() + self.enterprise.set_ntakes(1000) + + + def test_snf_sets_ntakes_for(self): + assert self.enterprise.set_ntakes_for(self.crusher, 537) == 537 + + def test_snf_actually_sets_ntakes_for(self): + self.enterprise.set_ntakes_for(self.crusher, 537) + assert self.db.one("SELECT ntakes FROM takes") == 537 + + def test_snf_takes_as_much_as_is_available(self): + assert self.enterprise.set_ntakes_for(self.crusher, 1000) == 1000 + + def test_snf_caps_ntakes_to_the_number_available(self): + assert self.enterprise.set_ntakes_for(self.crusher, 1024) == 1000 + + def test_snf_works_with_another_member_present(self): + assert self.enterprise.set_ntakes_for(self.bruiser, 537) == 537 + assert self.enterprise.set_ntakes_for(self.crusher, 537) == 463 + + def test_snf_affects_cacheroonies_as_expected(self): + self.enterprise.set_ntakes_for(self.bruiser, 537) + self.enterprise.set_ntakes_for(self.crusher, 128) + assert self.enterprise.ndistributing_to == 2 + assert self.enterprise.ntakes_claimed == 665 + assert self.enterprise.ntakes_unclaimed == 335 + + def test_snf_sets_ntakes_properly_for_an_existing_member(self): + assert self.enterprise.set_ntakes_for(self.crusher, 537) == 537 + assert self.enterprise.set_ntakes_for(self.bruiser, 537) == 463 + assert self.enterprise.set_ntakes_for(self.crusher, 128) == 128 + assert self.enterprise.ndistributing_to == 2 + assert self.enterprise.ntakes_claimed == 463 + 128 == 591 + assert self.enterprise.ntakes_unclaimed == 1000 - 591 == 409 + + + def assert_bad_member(self, member, reason): + err = raises(BadMember, self.enterprise.set_ntakes_for, member, 867).value + assert err.reason == reason + assert self.enterprise.set_ntakes_for(member, 0) == 0 + + def test_snf_requires_that_member_is_claimed_even_when_setting_to_zero(self): + alice = self.make_participant('alice') + err = raises(BadMember, self.enterprise.set_ntakes_for, alice, 867).value + assert err.reason == 'unclaimed' + err = raises(BadMember, self.enterprise.set_ntakes_for, alice, 0).value + assert err.reason == 'unclaimed' + + def test_snf_requires_that_member_is_not_suspicious_except_when_setting_to_zero(self): + alice = self.make_participant('alice', claimed_time='now', is_suspicious=True) + self.assert_bad_member(alice, 'suspicious') + + def test_snf_requires_that_member_has_an_email_except_when_setting_to_zero(self): + alice = self.make_participant('alice', claimed_time='now') + self.assert_bad_member(alice, 'missing an email') + + def test_snf_requires_that_member_has_an_identity_except_when_setting_to_zero(self): + alice = self.make_participant('alice', claimed_time='now', email_address='foo@example.com') + self.assert_bad_member(alice, 'missing an identity') + + def test_snf_requires_that_member_has_a_payout_route_except_when_setting_to_zero(self): + alice = self.make_participant('alice', claimed_time='now', email_address='foo@example.com') + alice.store_identity_info(self.TT, 'nothing-enforced', {'name': 'Alice'}) + alice.set_identity_verification(self.TT, True) + self.assert_bad_member(alice, 'missing a payout route')