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 11, 2016
1 parent 77fd818 commit 0371cf2
Show file tree
Hide file tree
Showing 5 changed files with 314 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']
160 changes: 160 additions & 0 deletions gratipay/models/team/mixins/takes.py
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
44 changes: 44 additions & 0 deletions sql/branch.sql
Original file line number Diff line number Diff line change
Expand Up @@ -41,3 +41,47 @@ CREATE TRIGGER enforce_email_for_participant_identity
-- participants.has_verified_identity

ALTER TABLE participants ADD COLUMN has_verified_identity bool NOT NULL DEFAULT false;


-- https://github.com/gratipay/gratipay.com/pull/4018

BEGIN;

-- Farewell, old takes table!
DROP VIEW current_takes;
DROP TABLE takes;

-- Be gone, payroll! I never knew you.
DROP VIEW current_payroll;
DROP TABLE payroll;

-- 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 int 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 int default 0;
ALTER TABLE teams ADD COLUMN ntakes_unclaimed int default 0;
ALTER TABLE teams ADD COLUMN ntakes_claimed int 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;
105 changes: 105 additions & 0 deletions tests/py/test_team_takes.py
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

0 comments on commit 0371cf2

Please sign in to comment.