This repository has been archived by the owner on Feb 8, 2018. It is now read-only.
-
Notifications
You must be signed in to change notification settings - Fork 308
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
1 parent
9d6a8d6
commit 94ab07e
Showing
5 changed files
with
354 additions
and
1 deletion.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,3 @@ | ||
from .takes import TakesMixin as Takes | ||
|
||
__all__ = ['Takes'] |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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='[email protected]' | ||
, 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='[email protected]' | ||
, 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='[email protected]') | ||
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='[email protected]') | ||
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') |