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

plumb takes #4023

Closed
wants to merge 4 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/autolib.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ def rst_for_module(toc_path):
w(f, heading)
w(f, "=" * len(heading))
w(f, ".. automodule:: {}", dotted)
w(f, " :member-order: bysource")

return f

Expand Down
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
111 changes: 19 additions & 92 deletions gratipay/models/team/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,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 @@ -32,7 +33,7 @@ def slugize(name):
return slug


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

Expand All @@ -49,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 Expand Up @@ -303,50 +321,6 @@ def to_dict(self):
'todo_url': self.todo_url
}

def migrate_tips(self):
"""Migrate the Team owner's Gratipay 1.0 tips into 2.0 payment instructions to the Team.

:return: ``None``
:raises: :py:exc:`~gratipay.models.team.AlreadyMigrated` if payment
instructions already exist for this Team

This method gets called under :py:func:`migrate_all_tips` during payday.

"""
payment_instructions = self.db.all("""
SELECT pi.*
FROM payment_instructions pi
JOIN teams t ON t.id = pi.team_id
WHERE t.owner = %s
AND pi.ctime < t.ctime
""", (self.owner, ))

# Make sure the migration hasn't been done already
if payment_instructions:
raise AlreadyMigrated

return self.db.one("""
WITH rows AS (

INSERT INTO payment_instructions
(ctime, mtime, participant_id, team_id, amount, is_funded)
SELECT ct.ctime
, ct.mtime
, (SELECT id FROM participants WHERE username=ct.tipper)
, %(team_id)s
, ct.amount
, ct.is_funded
FROM current_tips ct
JOIN participants p ON p.username = tipper
WHERE ct.tippee=%(owner)s
AND p.claimed_time IS NOT NULL
AND p.is_suspicious IS NOT TRUE
AND p.is_closed IS NOT TRUE
RETURNING 1

) SELECT count(*) FROM rows;
""", {'team_id': self.id, 'owner': self.owner})


# Images
# ======
Expand Down Expand Up @@ -389,50 +363,3 @@ def load_image(self, size):
with self.db.get_connection() as c:
image = c.lobject(oid, mode='rb').read()
return image


def migrate_all_tips(db, print=print):
"""Migrate tips for all teams.

:param GratipayDB db: a database object
:param func print: a function that takes lines of log output
:returns: ``None``

This function loads :py:class:`~gratipay.models.team.Team` objects for all
Teams where the owner had tips under Gratipay 1.0 but those tips have not
yet been migrated into payment instructions under Gratipay 2.0. It then
migrates the tips using :py:meth:`~gratipay.models.team.Team.migrate_tips`.

This function is wrapped in a script, ``bin/migrate-tips.py``, which is
`used during payday`_.

.. _used during payday: http://inside.gratipay.com/howto/run-payday

"""
teams = db.all("""
SELECT distinct ON (t.id) t.*::teams
FROM teams t
JOIN tips ON t.owner = tips.tippee -- Only fetch teams whose owners had tips under Gratipay 1.0
WHERE t.is_approved IS TRUE -- Only fetch approved teams
AND NOT EXISTS ( -- Make sure tips haven't been migrated for any teams with same owner
SELECT 1
FROM payment_instructions pi
JOIN teams t2 ON t2.id = pi.team_id
WHERE t2.owner = t.owner
AND pi.ctime < t2.ctime
)
""")

for team in teams:
try:
ntips = team.migrate_tips()
print("Migrated {} tip(s) for '{}'".format(ntips, team.slug))
except AlreadyMigrated:
print("'%s' already migrated." % team.slug)

print("Done.")


class AlreadyMigrated(Exception):
"""Raised by :py:meth:`~gratipay.models.team.migrate_tips`.
"""
4 changes: 4 additions & 0 deletions gratipay/models/team/mixins/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
from .takes import TakesMixin as Takes
from .tip_migration import TipMigrationMixin as 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``.
"""
Loading