Skip to content
This repository has been archived by the owner on Feb 8, 2018. It is now read-only.

Commit

Permalink
implement takes
Browse files Browse the repository at this point in the history
  • Loading branch information
chadwhitacre committed May 12, 2016
1 parent 9d6a8d6 commit 94ab07e
Show file tree
Hide file tree
Showing 5 changed files with 354 additions and 1 deletion.
3 changes: 2 additions & 1 deletion gratipay/models/team/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -30,7 +31,7 @@ def slugize(name):
return slug


class Team(Model):
class Team(Model, mixins.Takes):
"""Represent a Gratipay team.
"""

Expand Down
3 changes: 3 additions & 0 deletions gratipay/models/team/mixins/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
from .takes import TakesMixin as Takes

__all__ = ['Takes']
177 changes: 177 additions & 0 deletions gratipay/models/team/mixins/takes.py
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)
36 changes: 36 additions & 0 deletions sql/branch.sql
Original file line number Diff line number Diff line change
Expand Up @@ -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;
136 changes: 136 additions & 0 deletions tests/py/test_team_takes.py
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')

0 comments on commit 94ab07e

Please sign in to comment.