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

Commit

Permalink
Implement Participant.absorb (#406)
Browse files Browse the repository at this point in the history
  • Loading branch information
chadwhitacre committed Dec 8, 2012
1 parent e0d7c01 commit ccc53d1
Show file tree
Hide file tree
Showing 5 changed files with 230 additions and 8 deletions.
4 changes: 4 additions & 0 deletions gittip/elsewhere/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@
from psycopg2 import IntegrityError


class AccountElsewhere(object):
pass


class RunawayTrain(Exception):
pass

Expand Down
123 changes: 123 additions & 0 deletions gittip/participant.py
Original file line number Diff line number Diff line change
Expand Up @@ -452,3 +452,126 @@ def get_tips_and_total(self, for_payday=False, db=None):
total = Decimal('0.00')

return tips, total



# Accounts Elsewhere
# ==================

def absorb(self, other_id):
"""Absorb another Gittip participant account into self.
Here's what this means:
- consolidated tips to and fro are set up for the new participant
Amounts are summed, so if alice tips bob $1 and carl $1, and
then bob absorbs carl, then alice tips bob $2(!) and carl $0.
And if bob tips alice $1 and carl tips alice $1, and then bob
absorbs carl, then bob tips alice $2(!) and carl tips alice $0.
The ctime of each new consolidated tip is the older of the two
tips that are being consolidated.
If alice tips bob $1, and alice absorbs bob, then alice tips
bob $0.
If alice tips bob $1, and bob absorbs alice, then alice tips
bob $0.
- all tips to and from the other participant are set to zero
- the absorption is recorded in an absorptions table
- accounts elsewhere are left untouched
This is done in one transaction.
"""
typecheck(other_id, unicode)


CONSOLIDATE_TIPS_RECEIVING = """
INSERT INTO tips (ctime, tipper, tippee, amount)
SELECT min(ctime), tipper, %s AS tippee, sum(amount)
FROM ( SELECT DISTINCT ON (tipper, tippee)
ctime, tipper, tippee, amount
FROM tips
ORDER BY tipper, tippee, mtime DESC
) AS unique_tips
WHERE (tippee=%s OR tippee=%s)
AND NOT (tipper=%s AND tippee=%s)
AND NOT (tipper=%s)
GROUP BY tipper
"""

CONSOLIDATE_TIPS_GIVING = """
INSERT INTO tips (ctime, tipper, tippee, amount)
SELECT min(ctime), %s AS tipper, tippee, sum(amount)
FROM ( SELECT DISTINCT ON (tipper, tippee)
ctime, tipper, tippee, amount
FROM tips
ORDER BY tipper, tippee, mtime DESC
) AS unique_tips
WHERE (tipper=%s OR tipper=%s)
AND NOT (tipper=%s AND tippee=%s)
AND NOT (tippee=%s)
GROUP BY tippee
"""

ZERO_OUT_OLD_TIPS_RECEIVING = """
INSERT INTO tips (ctime, tipper, tippee, amount)
SELECT DISTINCT ON (tipper) ctime, tipper, tippee, 0 AS amount
FROM tips
WHERE tippee=%s
"""

ZERO_OUT_OLD_TIPS_GIVING = """
INSERT INTO tips (ctime, tipper, tippee, amount)
SELECT DISTINCT ON (tippee) ctime, tipper, tippee, 0 AS amount
FROM tips
WHERE tipper=%s
"""


x, y = self.id, other_id
with gittip.db.get_transaction() as txn:
txn.execute(CONSOLIDATE_TIPS_RECEIVING, (x, x,y, x,y, x))
txn.execute(CONSOLIDATE_TIPS_GIVING, (x, x,y, x,y, x))
txn.execute(ZERO_OUT_OLD_TIPS_RECEIVING, (other_id,))
txn.execute(ZERO_OUT_OLD_TIPS_GIVING, (other_id,))
txn.execute( "INSERT INTO absorptions (absorbed_by, absorbed) "
"VALUES (%s, %s)"
, (self.id, other_id)
)


def disconnect_account(self, elsewhere_id):
"""
"""


def connect_account(self, elsewhere_id, and_absorb=False):
"""Given an int, raise WontAbsorb or return None.
This method connects an account on another platform (GitHub, Twitter,
etc.) to an existing Gittip account.
When someone connects an AccountElsewhere to a Gittip account, it's
possible that that AccountElsewhere is already linked to another Gittip
account. If that's the case, we present the user with a confirmation
before proceeding to absorb the one Gittip account into the other.
"""
16 changes: 8 additions & 8 deletions gittip/testing.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,14 +61,14 @@ def setUpClass(cls):
def tearDown(self):
# TODO: rollback transaction here so we don't fill up test db.
# TODO: hack for now, truncate all tables.
tables = [
'participants',
'elsewhere',
'tips',
'transfers',
'paydays',
'exchanges',
]
tables = [ 'participants'
, 'elsewhere'
, 'tips'
, 'transfers'
, 'paydays'
, 'exchanges'
, 'absorptions'
]
for t in tables:
self.db.execute('truncate table %s cascade' % t)

Expand Down
11 changes: 11 additions & 0 deletions schema.sql
Original file line number Diff line number Diff line change
Expand Up @@ -216,3 +216,14 @@ ALTER TABLE participants DROP COLUMN payin_suspended;

ALTER TABLE social_network_users RENAME TO elsewhere;
ALTER TABLE elsewhere RENAME COLUMN network TO platform;


-------------------------------------------------------------------------------
-- https://github.com/whit537/www.gittip.com/issues/406

CREATE TABLE absorptions
( id serial PRIMARY KEY
, timestamp timestamp with time zone NOT NULL DEFAULT CURRENT_TIMESTAMP
, absorbed_by text NOT NULL REFERENCES participants ON DELETE RESTRICT
, absorbed text NOT NULL REFERENCES participants ON DELETE RESTRICT
);
84 changes: 84 additions & 0 deletions tests/test_participant.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
from __future__ import unicode_literals
from decimal import Decimal

from aspen.testing import assert_raises
from gittip.participant import Participant
from gittip.testing import tip_graph
from psycopg2 import IntegrityError


def test_participant_can_be_instantiated():
expected = Participant
actual = Participant(None).__class__
assert actual is expected, actual

def test_participant_can_absorb_another():
with tip_graph(("foo", "bar", 1)) as context:
Participant('foo').absorb('bar')

expected = { 'absorptions': [1,0,0]
, 'tips': [1,0,0]
}
actual = context.diff(compact=True)
assert actual == expected, actual

def test_absorbing_yourself_sets_all_to_zero():
with tip_graph(("foo", "bar", 1)) as context:
Participant('foo').absorb('bar')

expected = { 'amount': Decimal('0.00')
, 'tipper': 'foo'
, 'tippee': 'bar'
}
actual = context.diff()['tips']['inserts'][0]
del actual['ctime']; del actual['mtime']; del actual['id']
assert actual == expected, actual

def test_alice_ends_up_tipping_bob_two_dollars():
tips = [ ('alice', 'bob', 1)
, ('alice', 'carl', 1)
]
with tip_graph(*tips) as context:
Participant('bob').absorb('carl')
expected = Decimal('2.00')
actual = context.diff()['tips']['inserts'][0]['amount']
assert actual == expected, actual

def test_bob_ends_up_tipping_alice_two_dollars():
tips = [ ('bob', 'alice', 1)
, ('carl', 'alice', 1)
]
with tip_graph(*tips) as context:
Participant('bob').absorb('carl')
expected = Decimal('2.00')
actual = context.diff()['tips']['inserts'][0]['amount']
assert actual == expected, actual

def test_ctime_comes_from_the_older_tip():
tips = [ ('alice', 'bob', 1)
, ('alice', 'carl', 1)
]
with tip_graph(*tips) as context:
Participant('bob').absorb('carl')

tips = sorted(context.dump()['tips'].items())
first, second = tips[0][1], tips[1][1]

# sanity checks (these don't count :)
assert len(tips) == 4
assert first['ctime'] < second['ctime']
assert first['tipper'], first['tippee'] == ('alice', 'bob')
assert second['tipper'], second['tippee'] == ('alice', 'carl')

expected = first['ctime']
actual = context.diff()['tips']['inserts'][0]['ctime']
assert actual == expected, actual

def test_absorbing_unknown_fails():
tips = [ ('alice', 'bob', 1)
, ('alice', 'carl', 1)
]
with tip_graph(*tips) as context:
assert_raises(IntegrityError, Participant('bob').absorb, 'jim')
actual = context.diff()
assert actual == {}, actual

0 comments on commit ccc53d1

Please sign in to comment.