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 Jul 1, 2016
1 parent 296e390 commit f6429c0
Show file tree
Hide file tree
Showing 10 changed files with 384 additions and 32 deletions.
39 changes: 15 additions & 24 deletions gratipay/models/participant/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
from gratipay.models.account_elsewhere import AccountElsewhere
from gratipay.models.exchange_route import ExchangeRoute
from gratipay.models.team import Team
from gratipay.models.team.mixins.takes import ZERO
from gratipay.models.participant import mixins
from gratipay.security.crypto import constant_time_compare
from gratipay.utils import (
Expand Down Expand Up @@ -336,9 +337,8 @@ def clear_payment_instructions(self, cursor):
def clear_takes(self, cursor):
"""Leave all teams by zeroing all takes.
"""
for team, nmembers in self.get_old_teams():
t = Participant.from_username(team)
t.set_take_for(self, Decimal(0), self, cursor)
for team in self.get_teams():
team.set_take_for(self, ZERO, cursor=cursor)


def clear_personal_information(self, cursor):
Expand Down Expand Up @@ -1070,32 +1070,23 @@ def profile_url(self):


def get_teams(self, only_approved=False, cursor=None):
"""Return a list of teams this user is the owner of.
"""Return a list of teams this user is an owner or member of.
"""
teams = (cursor or self.db).all( "SELECT teams.*::teams FROM teams WHERE owner=%s"
, (self.username,)
teams = (cursor or self.db).all("""
SELECT teams.*::teams FROM teams WHERE owner=%s
UNION
SELECT teams.*::teams FROM teams WHERE id IN (
SELECT team_id FROM current_takes WHERE participant_id=%s
)
""", (self.username, self.id)
)
if only_approved:
teams = [t for t in teams if t.is_approved]
return teams


def get_old_teams(self):
"""Return a list of old-style teams this user was a member of.
"""
return self.db.all("""
SELECT team AS name
, ( SELECT count(*)
FROM current_takes
WHERE team=x.team
) AS nmembers
FROM current_takes x
WHERE member=%s;
""", (self.username,))


def insert_into_communities(self, is_member, name, slug):
participant_id = self.id
self.db.run("""
Expand Down Expand Up @@ -1179,14 +1170,14 @@ def get_age_in_seconds(self):
return out


class StillATeamOwner(Exception): pass
class StillOnATeam(Exception): pass
class BalanceIsNotZero(Exception): pass

def final_check(self, cursor):
"""Sanity-check that teams and balance have been dealt with.
"""
if self.get_teams(cursor=cursor):
raise self.StillATeamOwner
raise self.StillOnATeam
if self.balance != 0:
raise self.BalanceIsNotZero

Expand Down
21 changes: 19 additions & 2 deletions gratipay/models/team/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,10 @@
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
from gratipay.models.team.mixins import TipMigration

# Should have at least one letter.
TEAM_NAME_PATTERN = re.compile(r'^(?=.*[A-Za-z])([A-Za-z0-9.,-_ ]+)$')
Expand All @@ -33,7 +33,7 @@ def slugize(name):
return slug


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

Expand All @@ -50,6 +50,23 @@ def __ne__(self, other):
return self.id != other.id


# Computed Values
# ===============

#: The total amount of money this team receives during payday. Read-only;
#: modified by
#: :py:meth:`~gratipay.models.participant.Participant.set_payment_instruction`.

receiving = 0


#: The number of participants that are giving to this team. Read-only;
#: modified by
#: :py:meth:`~gratipay.models.participant.Participant.set_payment_instruction`.

nreceiving_from = 0


# Constructors
# ============

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

__all__ = ['TipMigration']
__all__ = ['Takes', 'TipMigration']
145 changes: 145 additions & 0 deletions gratipay/models/team/mixins/takes.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
from __future__ import absolute_import, division, print_function, unicode_literals

from decimal import Decimal as D


PENNY = D('0.01')
ZERO = D('0.00')


class TakesMixin(object):
""":py:class:`~gratipay.models.participant.Participant` s who are members
of a :py:class:`~gratipay.models.team.Team` may take money from the team
during :py:class:`~gratipay.billing.payday.Payday`. Only the team owner may
add a new member, by setting their take to a penny, but team owners may
*only* set their take to a penny---no more. Team owners may also remove
members, by setting their take to zero, as may the members themselves, who
may also set their take to whatever they wish.
"""

#: The total amount of money the team distributes to participants
#: (including the owner) during payday. Read-only; equal to
#: :py:attr:`~gratipay.models.team.Team.receiving`.

distributing = 0


#: The number of participants (including the owner) that the team
#: distributes money to during payday. Read-only; modified by
#: :py:meth:`set_take_for`.

ndistributing_to = 0


def set_take_for(self, participant, take, recorder):
"""Set the amount a participant wants to take from this team during payday.
:param Participant participant: the participant to set the take for
:param int take: the amount the participant wants to take
:param Participant recorder: the participant making the change
:return: ``None``
:raises: :py:exc:`NotAllowed`
It is a bug to pass in a ``participant`` or ``recorder`` that is
suspicious, unclaimed, or without a verified email and identity.
Furthermore, :py:exc:`NotAllowed` is raised in the following circumstances:
- ``recorder`` is neither ``participant`` nor the team owner
- ``recorder`` is the team owner and ``take`` is neither zero nor $0.01
- ``recorder`` is ``participant``, but ``participant`` isn't already on the team
"""
def vet(p):
assert not p.is_suspicious, p.id
assert p.is_claimed, p.id
assert p.email_address, p.id
assert p.has_verified_identity, p.id

vet(participant)
vet(recorder)

if recorder.username == self.owner:
if take not in (ZERO, PENNY):
raise NotAllowed('owner can only add and remove members, not otherwise set takes')
elif recorder != participant:
raise NotAllowed('can only set own take')

with self.db.get_cursor() as cursor:

cursor.run("LOCK TABLE takes IN EXCLUSIVE MODE") # avoid race conditions

old_take = self.get_take_for(participant, cursor=cursor)
if recorder.username != self.owner:
if recorder == participant and not old_take:
raise NotAllowed('can only set take if already a member of the team')

ndistributing_to = self.ndistributing_to

if old_take and not take:
ndelta = -1
elif not old_take and take:
ndelta = 1
else:
ndelta = 0

ndistributing_to += ndelta
delta = take - old_take

cursor.run( "UPDATE teams SET ndistributing_to=%s WHERE id=%s"
, (ndistributing_to, self.id)
)

cursor.one( """
INSERT INTO takes
(ctime, participant_id, team_id, amount, 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, %(amount)s, %(recorder_id)s
)
RETURNING *
""", { 'participant_id': participant.id
, 'team_id': self.id
, 'amount': take
, 'recorder_id': recorder.id
})

self.set_attributes(ndistributing_to=ndistributing_to)

taking, ntaking_from = cursor.one("""
UPDATE participants
SET taking=taking+%s, ntaking_from=ntaking_from+%s
WHERE id=%s
RETURNING taking, ntaking_from
""" , (delta, ndelta, participant.id))

participant.set_attributes(taking=taking, ntaking_from=ntaking_from)


def get_take_for(self, participant, cursor=None):
"""
:param Participant participant: the participant to get the take for
:param GratipayDB cursor: a database cursor; if ``None``, a new cursor will be used
:return: the ``participant``'s take from this team, as a :py:class:`~decimal.Decimal`
"""
return (cursor or self.db).one("""
SELECT amount
FROM current_takes
WHERE team_id=%s AND participant_id=%s
""", (self.id, participant.id), default=ZERO)


class NotAllowed(Exception):
"""Raised by :py:meth:`set_take_for` if ``recorder`` is not allowed to set
the take for ``participant``.
"""
9 changes: 8 additions & 1 deletion gratipay/testing/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -166,7 +166,14 @@ def make_team(self, *a, **kw):
_kw['is_approved'] = False

if Participant.from_username(_kw['owner']) is None:
self.make_participant(_kw['owner'], claimed_time='now', last_paypal_result='')
owner = self.make_participant( _kw['owner']
, claimed_time='now'
, last_paypal_result=''
, email_address=_kw['owner']+'@example.com'
)
TT = self.db.one("SELECT id FROM countries WHERE code='TT'")
owner.store_identity_info(TT, 'nothing-enforced', {'name': 'Owner'})
owner.set_identity_verification(TT, True)

team = self.db.one("""
INSERT INTO teams
Expand Down
32 changes: 32 additions & 0 deletions sql/branch.sql
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,35 @@ BEGIN;
DROP VIEW current_payroll;
DROP TABLE payroll;
END;


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

BEGIN;
DROP VIEW current_takes;
DROP TABLE takes;

-- 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)
, amount numeric(35,2) NOT NULL
, recorder_id bigint NOT NULL REFERENCES participants(id)
, CONSTRAINT not_negative CHECK (amount >= 0)
);

CREATE VIEW current_takes 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 amount > 0;

END;
2 changes: 1 addition & 1 deletion tests/py/test_close.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ def test_close_fails_if_still_a_balance(self):
def test_close_fails_if_still_owns_a_team(self):
alice = self.make_participant('alice', claimed_time='now')
self.make_team(owner=alice)
with pytest.raises(alice.StillATeamOwner):
with pytest.raises(alice.StillOnATeam):
alice.close()

def test_close_page_is_usually_available(self):
Expand Down
2 changes: 1 addition & 1 deletion tests/py/test_participant.py
Original file line number Diff line number Diff line change
Expand Up @@ -691,7 +691,7 @@ def test_archive_fails_for_team_owner(self):
alice = self.make_participant('alice')
self.make_team(owner=alice)
with self.db.get_cursor() as cursor:
pytest.raises(alice.StillATeamOwner, alice.archive, cursor)
pytest.raises(alice.StillOnATeam, alice.archive, cursor)

def test_archive_fails_if_balance_is_positive(self):
alice = self.make_participant('alice', balance=2)
Expand Down
Loading

0 comments on commit f6429c0

Please sign in to comment.