diff --git a/docs/autolib.py b/docs/autolib.py index 7ecfc13a87..2f64690182 100755 --- a/docs/autolib.py +++ b/docs/autolib.py @@ -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 diff --git a/gratipay/models/participant/__init__.py b/gratipay/models/participant/__init__.py index 551e60b089..37a20fa918 100644 --- a/gratipay/models/participant/__init__.py +++ b/gratipay/models/participant/__init__.py @@ -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 ( @@ -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): @@ -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(""" @@ -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 diff --git a/gratipay/models/team/__init__.py b/gratipay/models/team/__init__.py index 22cb3a93c0..efdecdace2 100644 --- a/gratipay/models/team/__init__.py +++ b/gratipay/models/team/__init__.py @@ -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 @@ -32,7 +33,7 @@ def slugize(name): return slug -class Team(Model): +class Team(Model, mixins.Takes, mixins.TipMigration): """Represent a Gratipay team. """ @@ -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 # ============ @@ -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 # ====== @@ -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`. - """ diff --git a/gratipay/models/team/mixins/__init__.py b/gratipay/models/team/mixins/__init__.py new file mode 100644 index 0000000000..06596442c7 --- /dev/null +++ b/gratipay/models/team/mixins/__init__.py @@ -0,0 +1,4 @@ +from .takes import TakesMixin as Takes +from .tip_migration import TipMigrationMixin as TipMigration + +__all__ = ['Takes', 'TipMigration'] diff --git a/gratipay/models/team/mixins/takes.py b/gratipay/models/team/mixins/takes.py new file mode 100644 index 0000000000..ed3e1bef4b --- /dev/null +++ b/gratipay/models/team/mixins/takes.py @@ -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``. + """ diff --git a/gratipay/models/team/mixins/tip_migration.py b/gratipay/models/team/mixins/tip_migration.py new file mode 100644 index 0000000000..bef47a3c0e --- /dev/null +++ b/gratipay/models/team/mixins/tip_migration.py @@ -0,0 +1,101 @@ +"""Participants who received tips directly under Gittipay 1.0 will have their +tips migrated if and when they become the owner of a new Gratipay 2.0 team. +""" + +from __future__ import absolute_import, division, print_function, unicode_literals + + +class TipMigrationMixin(object): + """This mixin provides tip migration for teams. + """ + + 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}) + + +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`. + """ diff --git a/gratipay/testing/__init__.py b/gratipay/testing/__init__.py index dc037deacb..eaf31bf10a 100644 --- a/gratipay/testing/__init__.py +++ b/gratipay/testing/__init__.py @@ -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 diff --git a/sql/branch.sql b/sql/branch.sql index 003bc1601e..867770a5e5 100644 --- a/sql/branch.sql +++ b/sql/branch.sql @@ -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; diff --git a/tests/py/test_close.py b/tests/py/test_close.py index 0b47d7dbbd..4a7707f644 100644 --- a/tests/py/test_close.py +++ b/tests/py/test_close.py @@ -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): diff --git a/tests/py/test_participant.py b/tests/py/test_participant.py index 4669383700..f5e21d1263 100644 --- a/tests/py/test_participant.py +++ b/tests/py/test_participant.py @@ -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) diff --git a/tests/py/test_team_takes.py b/tests/py/test_team_takes.py new file mode 100644 index 0000000000..31fdb79090 --- /dev/null +++ b/tests/py/test_team_takes.py @@ -0,0 +1,159 @@ +from __future__ import absolute_import, division, print_function, unicode_literals + +from pytest import raises +from gratipay.models.participant import Participant +from gratipay.models.team import Team +from gratipay.models.team.mixins.takes import NotAllowed, PENNY, ZERO +from gratipay.testing import Harness + + +T = Team.from_slug +P = Participant.from_username + + +class TeamTakesHarness(Harness): + # Factored out to share with membership tests ... + + def setUp(self): + self.enterprise = self.make_team('The Enterprise') + self.picard = P('picard') + + self.TT = self.db.one("SELECT id FROM countries WHERE code='TT'") + + self.crusher = self.make_participant( 'crusher' + , email_address='crusher@example.com' + , 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='bruiser@example.com' + , 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): + + # gtf - get_take_for + + def test_gtf_returns_zero_for_unknown(self): + assert self.enterprise.get_take_for(self.crusher) == 0 + + def test_gtf_returns_amount_for_known(self): + self.enterprise.set_take_for(self.crusher, PENNY, self.picard) + assert self.enterprise.get_take_for(self.crusher) == PENNY + + def test_gtf_returns_correct_amount_for_multiple_members(self): + self.enterprise.set_take_for(self.crusher, PENNY, self.picard) + self.enterprise.set_take_for(self.bruiser, PENNY, self.picard) + self.enterprise.set_take_for(self.bruiser, PENNY * 2, self.bruiser) + assert self.enterprise.get_take_for(self.crusher) == PENNY + assert self.enterprise.get_take_for(self.bruiser) == PENNY * 2 + + def test_gtf_returns_correct_amount_for_multiple_teams(self): + self.enterprise.set_take_for(self.crusher, PENNY, self.picard) + + trident = self.make_team('The Trident', owner='shelby') + trident.set_take_for(self.crusher, PENNY, P('shelby')) + trident.set_take_for(self.crusher, PENNY * 2, self.crusher) + + assert self.enterprise.get_take_for(self.crusher) == PENNY + assert trident.get_take_for(self.crusher) == PENNY * 2 + + + # stf - set_take_for + + def test_stf_sets_take_for_new_member(self): + self.enterprise.set_take_for(self.crusher, PENNY, self.picard) + assert self.enterprise.get_take_for(self.crusher) == PENNY + + def test_stf_updates_take_for_an_existing_member(self): + self.enterprise.set_take_for(self.crusher, PENNY, self.picard) + self.enterprise.set_take_for(self.crusher, 537, self.crusher) + assert self.enterprise.get_take_for(self.crusher) == 537 + + + def test_stf_can_increase_ndistributing_to(self): + self.enterprise.set_take_for(self.bruiser, PENNY, self.picard) + assert self.enterprise.ndistributing_to == 1 + self.enterprise.set_take_for(self.crusher, PENNY, self.picard) + assert self.enterprise.ndistributing_to == 2 + + def test_stf_doesnt_increase_ndistributing_to_for_an_existing_member(self): + self.enterprise.set_take_for(self.crusher, PENNY, self.picard) + self.enterprise.set_take_for(self.crusher, PENNY, self.crusher) + self.enterprise.set_take_for(self.crusher, 64, self.crusher) + assert self.enterprise.ndistributing_to == 1 + + def test_stf_can_decrease_ndistributing_to(self): + self.enterprise.set_take_for(self.bruiser, PENNY, self.picard) + self.enterprise.set_take_for(self.crusher, PENNY, self.picard) + self.enterprise.set_take_for(self.crusher, 0, self.crusher) + assert self.enterprise.ndistributing_to == 1 + self.enterprise.set_take_for(self.bruiser, 0, self.bruiser) + assert self.enterprise.ndistributing_to == 0 + + def test_stf_doesnt_decrease_ndistributing_to_below_zero(self): + self.enterprise.set_take_for(self.crusher, PENNY, self.picard) + self.enterprise.set_take_for(self.crusher, 0, self.picard) + self.enterprise.set_take_for(self.crusher, 0, self.picard) + self.enterprise.set_take_for(self.crusher, 0, self.picard) + self.enterprise.set_take_for(self.crusher, 0, self.picard) + assert self.enterprise.ndistributing_to == 0 + + def test_stf_updates_ndistributing_to_in_the_db(self): + self.enterprise.set_take_for(self.crusher, PENNY, self.picard) + assert T('TheEnterprise').ndistributing_to == 1 + + + def test_stf_updates_taking_for_member(self): + assert self.crusher.taking == ZERO + self.enterprise.set_take_for(self.crusher, PENNY, self.picard) + assert self.crusher.taking == PENNY + + + # stf permissions + + def test_stf_lets_owner_add_member(self): + self.enterprise.set_take_for(self.crusher, PENNY, self.picard) + assert self.enterprise.ndistributing_to == 1 + + def test_stf_lets_owner_add_themselves(self): + self.enterprise.set_take_for(self.picard, PENNY, self.picard) + assert self.enterprise.ndistributing_to == 1 + + def test_stf_lets_owner_remove_member(self): + self.enterprise.set_take_for(self.crusher, PENNY, self.picard) + self.enterprise.set_take_for(self.crusher, ZERO, self.picard) + assert self.enterprise.ndistributing_to == 0 + + def test_stf_lets_owner_remove_themselves(self): + self.enterprise.set_take_for(self.picard, PENNY, self.picard) + self.enterprise.set_take_for(self.picard, ZERO, self.picard) + assert self.enterprise.ndistributing_to == 0 + + def err(self, *a): + return raises(NotAllowed, self.enterprise.set_take_for, *a).value.args[0] + + def test_stf_doesnt_let_owner_increase_take_beyond_a_penny(self): + actual = self.err(self.crusher, PENNY * 2, self.picard) + assert actual == 'owner can only add and remove members, not otherwise set takes' + + def test_stf_doesnt_let_anyone_else_set_a_take(self): + actual = self.err(self.crusher, PENNY * 1, self.bruiser) + assert actual == 'can only set own take' + + def test_stf_doesnt_let_anyone_else_set_a_take_even_to_zero(self): + actual = self.err(self.crusher, 0, self.bruiser) + assert actual == 'can only set own take' + + def test_stf_doesnt_let_anyone_set_a_take_who_is_not_already_on_the_team(self): + actual = self.err(self.crusher, PENNY, self.crusher) + assert actual == 'can only set take if already a member of the team' + + def test_stf_doesnt_let_anyone_set_a_take_who_is_not_already_on_the_team_even_to_zero(self): + actual = self.err(self.crusher, 0, self.crusher) + assert actual == 'can only set take if already a member of the team' diff --git a/tests/py/test_teams.py b/tests/py/test_teams.py index b398673896..05b8abbe2b 100644 --- a/tests/py/test_teams.py +++ b/tests/py/test_teams.py @@ -421,7 +421,7 @@ def test_save_image_saves_image(self): def test_save_image_records_the_event(self): team = self.make_team() oids = team.save_image(IMAGE, IMAGE, IMAGE, 'image/png') - event = self.db.one('SELECT * FROM events') + event = self.db.all('SELECT * FROM events ORDER BY ts DESC')[0] assert event.payload == { 'action': 'upsert_image' , 'original': oids['original'] , 'large': oids['large'] @@ -474,7 +474,7 @@ def test_can_only_update_allowed_fields(self): def test_update_records_the_old_values_as_events(self): team = self.make_team(slug='enterprise', product_or_service='Product') team.update(name='Enterprise', product_or_service='We save galaxies.') - event = self.db.one('SELECT * FROM events') + event = self.db.all('SELECT * FROM events ORDER BY ts DESC')[0] assert event.payload == { 'action': 'update' , 'id': team.id , 'name': 'The Enterprise' diff --git a/tests/py/test_tip_migration.py b/tests/py/test_tip_migration.py index fe1c53c0e3..3dcd1e11b4 100644 --- a/tests/py/test_tip_migration.py +++ b/tests/py/test_tip_migration.py @@ -2,7 +2,7 @@ import pytest from gratipay.testing import Harness -from gratipay.models.team import AlreadyMigrated, migrate_all_tips +from gratipay.models.team.mixins.tip_migration import AlreadyMigrated, migrate_all_tips class Tests(Harness):