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
77fd818
commit 0371cf2
Showing
5 changed files
with
314 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,160 @@ | ||
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, which is used by 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 | ||
ownership---though a team's legal owners may "claim" takes, right alongside | ||
employees, contractors, etc. Takes determines how money is split each week. | ||
""" | ||
|
||
#: 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 pass in a participant that is suspicous, unclaimed, or | ||
without a verified email and identity. | ||
""" | ||
assert not participant.is_suspicious, participant.id | ||
assert participant.is_claimed, participant.id | ||
assert participant.email_address, participant.id | ||
assert participant.has_verified_identity, participant.id | ||
|
||
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,)) | ||
|
||
nmembers = self.nmembers | ||
nclaimed = self.ntakes_claimed | ||
nunclaimed = self.ntakes_unclaimed | ||
|
||
if old_ntakes: | ||
nclaimed -= old_ntakes | ||
nunclaimed += old_ntakes | ||
else: | ||
nmembers += 1 | ||
|
||
ntakes = min(ntakes, nunclaimed) | ||
|
||
if ntakes: | ||
nunclaimed -= ntakes | ||
nclaimed += ntakes | ||
else: | ||
nmembers -= 1 | ||
|
||
cursor.run(""" | ||
UPDATE teams | ||
SET ndistributing_to=%s | ||
, ntakes_claimed=%s | ||
, ntakes_unclaimed=%s | ||
WHERE id=%s | ||
""", (nmembers, 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=nmembers | ||
) | ||
|
||
return ntakes |
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,105 @@ | ||
from __future__ import absolute_import, division, print_function, unicode_literals | ||
|
||
from gratipay.testing import Harness | ||
|
||
|
||
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 code2='TT'") | ||
|
||
self.crusher = self.make_participant( 'crusher' | ||
, email_address='[email protected]' | ||
, claimed_time='now' | ||
) | ||
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' | ||
) | ||
self.bruiser.store_identity_info(self.TT, 'nothing-enforced', {'name': 'Bruiser'}) | ||
self.bruiser.set_identity_verification(self.TT, True) | ||
|
||
|
||
class Tests(TeamTakesHarness): | ||
|
||
# sn - set_ntakes | ||
|
||
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 | ||
|
||
|
||
# snf - set_ntakes_for | ||
|
||
def test_snf_sets_ntakes_for(self): | ||
self.enterprise.set_ntakes(1000) | ||
assert self.enterprise.set_ntakes_for(self.crusher, 537) == 537 | ||
|
||
def test_snf_actually_sets_ntakes_for(self): | ||
self.enterprise.set_ntakes(1000) | ||
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): | ||
self.enterprise.set_ntakes(1000) | ||
assert self.enterprise.set_ntakes_for(self.crusher, 1000) == 1000 | ||
|
||
def test_snf_caps_ntakes_to_the_number_available(self): | ||
self.enterprise.set_ntakes(1000) | ||
assert self.enterprise.set_ntakes_for(self.crusher, 1024) == 1000 | ||
|
||
def test_snf_works_with_another_member_present(self): | ||
self.enterprise.set_ntakes(1000) | ||
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(1000) | ||
self.enterprise.set_ntakes_for(self.bruiser, 537) | ||
self.enterprise.set_ntakes_for(self.crusher, 128) | ||
assert self.enterprise.nmembers == 2 | ||
assert self.enterprise.ntakes_claimed == 665 | ||
assert self.enterprise.ntakes_unclaimed == 335 | ||
|
||
def test_snf_sets_ntakes_properly_for_an_existing_member(self): | ||
self.enterprise.set_ntakes(1000) | ||
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.nmembers == 2 | ||
assert self.enterprise.ntakes_claimed == 463 + 128 == 591 | ||
assert self.enterprise.ntakes_unclaimed == 1000 - 591 == 409 |