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

Start fixing the distributing UI #4073

Merged
merged 10 commits into from
Jul 13, 2016
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
94 changes: 2 additions & 92 deletions gratipay/models/team/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
from postgres.orm import Model

from gratipay.billing.exchanges import MINIMUM_CHARGE
from gratipay.models.team import mixins

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


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

Expand Down Expand Up @@ -303,50 +304,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 +346,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 .available import AvailableMixin as Available
from .tip_migration import TipMigrationMixin as TipMigration

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


class AvailableMixin(object):
"""Teams can make money available for their members to take.
"""

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

#: The amount of money this team makes available for members to take each
#: week. Read-only; modified manually.

available = 0
101 changes: 101 additions & 0 deletions gratipay/models/team/mixins/tip_migration.py
Original file line number Diff line number Diff line change
@@ -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`.
"""
7 changes: 5 additions & 2 deletions gratipay/testing/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -169,16 +169,19 @@ def make_team(self, *a, **kw):
_kw['slug_lower'] = _kw['slug'].lower()
if 'is_approved' not in _kw:
_kw['is_approved'] = False
if 'available' not in _kw:
_kw['available'] = 0

if Participant.from_username(_kw['owner']) is None:
self.make_participant(_kw['owner'], claimed_time='now', last_paypal_result='')

team = self.db.one("""
INSERT INTO teams
(slug, slug_lower, name, homepage, product_or_service, todo_url,
onboarding_url, owner, is_approved)
onboarding_url, owner, is_approved, available)
VALUES (%(slug)s, %(slug_lower)s, %(name)s, %(homepage)s, %(product_or_service)s,
%(todo_url)s, %(onboarding_url)s, %(owner)s, %(is_approved)s)
%(todo_url)s, %(onboarding_url)s, %(owner)s, %(is_approved)s,
%(available)s)
RETURNING teams.*::teams
""", _kw)

Expand Down
6 changes: 6 additions & 0 deletions sql/branch.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
-- https://github.com/gratipay/gratipay.com/pull/4072

BEGIN;
ALTER TABLE teams ADD COLUMN available numeric(35,2) NOT NULL DEFAULT 0;
ALTER TABLE teams ADD CONSTRAINT available_not_negative CHECK ((available >= (0)::numeric));
END;
11 changes: 3 additions & 8 deletions templates/team-base.html
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,6 @@
<td class="total-receiving" data-team="{{ team.id }}">{{ format_currency(team.receiving, 'USD') }}</td>
<td class="nreceiving-from" data-team="{{ team.id }}">{{ team.nreceiving_from }}</td>
</tr>
<tr>
<td class="label">{{ _("Sharing") }}</td>
<td>{{ format_currency(0, 'USD') }}</td>
<td>0</td>
</tr>
</table>
{% endif %}

Expand Down Expand Up @@ -61,9 +56,9 @@
{% if team.is_approved and (user.participant == team.owner or user.ADMIN) %}
{% set current_page = request.path.raw.split('/')[2] %}
{% set nav_base = '/' + team.slug %}
{% set pages = [ ('/', _('Profile'))
, ('/receiving/', _('Receiving'))
] %}
{% set pages = [ ('/', _('Profile'))
, ('/receiving/', _('Receiving'))
] + ([('/distributing/', _('Distributing'))] if team.available else [])%}
{% if pages %}
{% include "templates/nav.html" %}
<br>
Expand Down
2 changes: 1 addition & 1 deletion tests/py/test_pages.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ def browse(self, setup=None, **kw):
.replace('/for/%slug/', '/for/wonderland/') \
.replace('/%platform/', '/github/') \
.replace('/%user_name/', '/gratipay/') \
.replace('/%membername', '/alan') \
.replace('/%member_id', '/1') \
.replace('/%country', '/TT') \
.replace('/%exchange_id.int', '/%s' % exchange_id) \
.replace('/%redirect_to', '/giving') \
Expand Down
23 changes: 23 additions & 0 deletions tests/py/test_team_available.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
from __future__ import absolute_import, division, print_function, unicode_literals

from gratipay.testing import Harness
from psycopg2 import IntegrityError
from pytest import raises


class Tests(Harness):

def test_available_defaults_to_zero(self):
assert self.make_team().available == 0

def test_available_cant_be_negative(self):
self.make_team()
raises(IntegrityError, self.db.run, "UPDATE teams SET available = -537")

def test_available_can_be_positive(self):
self.make_team()
self.db.run("UPDATE teams SET available = 537")
assert self.db.one("SELECT available FROM teams") == 537

def test_available_works_in_the_test_factory(self):
assert self.make_team(available=537).available == 537
2 changes: 1 addition & 1 deletion tests/py/test_tip_migration.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
35 changes: 35 additions & 0 deletions tests/py/test_www_team_distributing.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
from __future__ import absolute_import, division, print_function, unicode_literals

from gratipay.testing import Harness


class Tests(Harness):

def test_distributing_redirects_when_no_money_is_available(self):
self.make_team()
assert self.client.GxT('/TheEnterprise/distributing/').code == 302

def test_distributing_doesnt_redirect_when_money_is_available(self):
self.make_team()
self.db.run("UPDATE teams SET available=537")
assert self.client.GET('/TheEnterprise/distributing/').code == 200


def test_json_redirects_when_no_money_is_available(self):
self.make_team()
assert self.client.GxT('/TheEnterprise/distributing/index.json').code == 302

def test_json_doesnt_redirect_when_money_is_available(self):
self.make_team()
self.db.run("UPDATE teams SET available=537")
assert self.client.GET('/TheEnterprise/distributing/index.json', raise_immediately=False).code == 500


def test_member_json_redirects_when_no_money_is_available(self):
self.make_team()
assert self.client.GxT('/TheEnterprise/distributing/1.json').code == 302

def test_member_json_doesnt_redirect_when_money_is_available(self):
self.make_team()
self.db.run("UPDATE teams SET available=537")
assert self.client.GET('/TheEnterprise/distributing/1.json', raise_immediately=False).code == 500
Loading