diff --git a/gratipay/models/team.py b/gratipay/models/team.py index b2a181bbfe..5e0ec230fe 100644 --- a/gratipay/models/team.py +++ b/gratipay/models/team.py @@ -1,12 +1,15 @@ """Teams on Gratipay receive payments and distribute payroll. """ import re +from decimal import Decimal + import requests from aspen import json, log from gratipay.exceptions import InvalidTeamName from gratipay.models import add_event from postgres.orm import Model +from gratipay.billing.exchanges import MINIMUM_CHARGE # Should have at least one letter. TEAM_NAME_PATTERN = re.compile(r'^(?=.*[A-Za-z])([A-Za-z0-9.,-_ ]+)$') @@ -87,6 +90,60 @@ def insert(cls, owner, **fields): """, fields) + def get_payment_distribution(self): + """ + Returns a data structure in the form of:: + [ + [PAYMENT1, PAYMENT2...PAYMENTN], + nreceiving_from, + total_amount_received + ] + where each PAYMENTN is in the form:: + [ + amount, + number_of_tippers_for_this_amount, + total_amount_given_at_this_amount, + proportion_of_payments_at_this_amount, + proportion_of_total_amount_at_this_amount + ] + """ + SQL = """ + SELECT amount + , count(amount) AS nreceiving_from + FROM ( SELECT DISTINCT ON (participant) + amount + , participant + FROM payment_instructions + JOIN participants p ON p.username = participant + WHERE team=%s + AND is_funded + AND p.is_suspicious IS NOT true + ORDER BY participant + , mtime DESC + ) AS foo + WHERE amount > 0 + GROUP BY amount + ORDER BY amount + """ + + tip_amounts = [] + + npatrons = 0.0 # float to trigger float division + total_amount = Decimal('0.00') + for rec in self.db.all(SQL, (self.slug,)): + tip_amounts.append([ rec.amount + , rec.nreceiving_from + , rec.amount * rec.nreceiving_from + ]) + total_amount += tip_amounts[-1][2] + npatrons += rec.nreceiving_from + + for row in tip_amounts: + row.append((row[1] / npatrons) if npatrons > 0 else 0) + row.append((row[2] / total_amount) if total_amount > 0 else 0) + + return tip_amounts, npatrons, total_amount + def update(self, **kw): updateable = frozenset(['name', 'product_or_service', 'homepage', @@ -115,6 +172,45 @@ def update(self, **kw): )) self.set_attributes(**kw) + + def get_dues(self): + rec = self.db.one(""" + WITH our_cpi AS ( + SELECT due, is_funded + FROM current_payment_instructions cpi + WHERE team=%(slug)s + ) + SELECT ( + SELECT COALESCE(SUM(due), 0) + FROM our_cpi + WHERE is_funded + ) AS funded + , ( + SELECT COALESCE(SUM(due), 0) + FROM our_cpi + WHERE NOT is_funded + ) AS unfunded + """, {'slug': self.slug}) + + return rec.funded, rec.unfunded + + + def get_upcoming_payment(self): + return self.db.one(""" + SELECT COALESCE(SUM(amount + due), 0) + FROM current_payment_instructions cpi + JOIN participants p ON cpi.participant = p.username + WHERE team = %(slug)s + AND is_funded -- Check whether the payment is funded + AND ( -- Check whether the user will hit the minimum charge + SELECT SUM(amount + due) + FROM current_payment_instructions cpi2 + WHERE cpi2.participant = p.username + AND cpi2.is_funded + ) >= %(mcharge)s + """, {'slug': self.slug, 'mcharge': MINIMUM_CHARGE}) + + def create_github_review_issue(self): """POST to GitHub, and return the URL of the new issue. """ diff --git a/scss/components/tip-distribution.scss b/scss/components/payment-distribution.scss similarity index 96% rename from scss/components/tip-distribution.scss rename to scss/components/payment-distribution.scss index ad89888a70..437bde85de 100644 --- a/scss/components/tip-distribution.scss +++ b/scss/components/payment-distribution.scss @@ -1,4 +1,4 @@ -.tip-distribution { +.payment-distribution { font: normal 12px $Mono; .dollar-sign { padding: 0 2pt 0 24pt; diff --git a/templates/filtered-nav.html b/templates/filtered-nav.html new file mode 100644 index 0000000000..815ac1443d --- /dev/null +++ b/templates/filtered-nav.html @@ -0,0 +1,9 @@ + diff --git a/templates/payment-distribution-amount.html b/templates/payment-distribution-amount.html new file mode 100644 index 0000000000..07d55ac706 --- /dev/null +++ b/templates/payment-distribution-amount.html @@ -0,0 +1,14 @@ + + {% for amount, ncontributors, summed, pcontributors, psummed in payment_distribution %} + + + + + + + {% endfor %} +
${{ amount }} + + {{ "%5.1f" % (psummed * 100) }}% + (${{ summed }}) +
diff --git a/templates/payment-distribution-number.html b/templates/payment-distribution-number.html new file mode 100644 index 0000000000..c531f8cc5f --- /dev/null +++ b/templates/payment-distribution-number.html @@ -0,0 +1,13 @@ + + {% for amount, ncontributors, summed, pcontributors, psummed in payment_distribution %} + + + + + + {% endfor %} +
${{ amount }} + + {{ "%5.1f" % (pcontributors * 100) }}% + ({{ ncontributors }}) +
diff --git a/templates/profile.html b/templates/profile.html index 992d6369ef..7b9295e051 100644 --- a/templates/profile.html +++ b/templates/profile.html @@ -112,10 +112,6 @@ ] %} {% set pages = filter_profile_nav(user, participant, pages) %} {% if pages %} - +{% include "templates/filtered-nav.html" %} {% endif %} {% endblock %} diff --git a/templates/team-base.html b/templates/team-base.html new file mode 100644 index 0000000000..7e1f8b7d47 --- /dev/null +++ b/templates/team-base.html @@ -0,0 +1,77 @@ +{% extends "templates/base.html" %} + +{% block banner %} +
+ +
+ +
+ {% if team.is_approved %} + + + + + + + + + + + + + + + + +
{{ _("Weekly") }}Σ  n
{{ _("Receiving") }}{{ format_currency(team.receiving, 'USD') }}{{ team.nreceiving_from }}
{{ _("Sharing") }}{{ format_currency(0, 'USD') }}0
+ {% endif %} + +

+ {% if team.is_approved %} + {{ _('Joined {ago}', ago=to_age(team.ctime, add_direction=True)) }} + {% else %} + {{ _('Applied {ago}', ago=to_age(team.ctime, add_direction=True)) }} + {% endif %} +

+
+ +{{ super() }} +{% endblock %} + +{% block sidebar %} + +{% if user.ADMIN %} +
+ +
+
+ +
+
+
+{% endif %} + +{% 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')) + ] %} + {% if pages %} + {% include "templates/nav.html" %} +
+ {% endif %} +{% endif %} + +{% if (user.participant.username != team.owner) and team.is_approved %} + {% include "templates/your-payment.html" %} +{% endif %} + +{% endblock %} diff --git a/templates/tip-distribution.html b/templates/tip-distribution.html deleted file mode 100644 index 265290d1c9..0000000000 --- a/templates/tip-distribution.html +++ /dev/null @@ -1,13 +0,0 @@ - - {% for amount, ncontributors, summed, pcontributors, psummed in tip_distribution%} - - - - - - {% endfor %} -
${{ amount }} - - {{ "%5.1f" % (pcontributors * 100) }}% - ({{ ncontributors }}) -
diff --git a/tests/py/test_teams.py b/tests/py/test_teams.py index 59608e9086..f93d7fa554 100644 --- a/tests/py/test_teams.py +++ b/tests/py/test_teams.py @@ -340,6 +340,9 @@ def test_stripping_required_inputs(self): assert self.db.one("SELECT COUNT(*) FROM teams") == 0 assert "Please fill out the 'Team Name' field." in r.body + # Migrate Tips + # ============ + def test_migrate_tips_to_payment_instructions(self): alice = self.make_participant('alice', claimed_time='now') bob = self.make_participant('bob', claimed_time='now') @@ -390,7 +393,40 @@ def test_migrate_tips_checks_for_multiple_teams(self): assert len(payment_instructions) == 1 - # cached values - receiving, nreceiving_from + # Dues, Upcoming Payment + # ====================== + + def test_get_dues(self): + alice = self.make_participant('alice', claimed_time='now', last_bill_result='') + bob = self.make_participant('bob', claimed_time='now', last_bill_result='Fail!') + team = self.make_team(is_approved=True) + + alice.set_payment_instruction(team, '3.00') # Funded + bob.set_payment_instruction(team, '5.00') # Unfunded + + # Simulate dues + self.db.run("UPDATE payment_instructions SET due = amount") + + assert team.get_dues() == (3, 5) + + + def test_upcoming_payment(self): + alice = self.make_participant('alice', claimed_time='now', last_bill_result='') + bob = self.make_participant('bob', claimed_time='now', last_bill_result='') + carl = self.make_participant('carl', claimed_time='now', last_bill_result='Fail!') + team = self.make_team(is_approved=True) + + alice.set_payment_instruction(team, '5.00') # Funded + bob.set_payment_instruction(team, '3.00') # Funded, but won't hit minimum charge + carl.set_payment_instruction(team, '10.00') # Unfunded + + # Simulate dues + self.db.run("UPDATE payment_instructions SET due = amount") + + assert team.get_upcoming_payment() == 10 # 2 * Alice's $5 + + # Cached Values + # ============= def test_receiving_only_includes_funded_payment_instructions(self): alice = self.make_participant('alice', claimed_time='now', last_bill_result='') diff --git a/www/%team/charts.json.spt b/www/%team/charts.json.spt index 4af812972e..d5acbff9d4 100644 --- a/www/%team/charts.json.spt +++ b/www/%team/charts.json.spt @@ -1,19 +1,13 @@ -"""Return an array of objects with interesting data for the user. +"""Return an array of objects with interesting data for the team. -We want one object per payday, but the user probably didn't participate in -every payday. Our solution is to fetch all paydays and all of the user's -transfers, and then loop through transfers and aggregate into the relevant +We want one object per payday, but the team probably didn't participate in +every payday. Our solution is to fetch all paydays and all of the team's +payments, and then loop through payments and aggregate into the relevant payday object. -If the user has never received, we return an empty array. Client code can take +If the team has never received, we return an empty array. Client code can take this to mean, "no chart." -We specifically don't worry about double-counting npatrons for out-of-band -transfers. That's a rare case right now, pretty much only when someone -deactivates their account and distributes their balance as a final gift. It -could become much more common in a one-off payments world, depending on how the -implementation. - """ import re @@ -25,22 +19,7 @@ callback_pattern = re.compile(r'^[_A-Za-z0-9.]+$') [---] -username = request.path['username'] - -anonymous = website.db.one(""" - - SELECT anonymous_receiving - FROM participants - WHERE username = %s - -""", (username,)) - -if anonymous: - if user.ANON: - raise Response(401) - elif user.participant.username != username and not user.ADMIN: - raise Response(403) - +slug = request.path['team'] # Fetch data from the database. # ============================= @@ -49,29 +28,29 @@ paydays = website.db.all(""" SELECT p.ts_start , p.ts_start::date AS date - , 0 AS npatrons + , 0 AS nreceiving_from , 0.00 AS receipts FROM paydays p + WHERE id > 198 -- (Gratipay 2.0) ORDER BY ts_start DESC """, back_as=dict) -transfers = website.db.all("""\ +payments = website.db.all("""\ SELECT timestamp , amount - , tipper - FROM transfers - WHERE tippee=%s - AND context <> 'take-over' + FROM payments + WHERE team=%s + AND direction='to-team' ORDER BY timestamp DESC -""", (username,), back_as=dict) +""", (slug,), back_as=dict) -if not transfers: +if not payments: - # This user has never received money. + # This team has never received money. # =================================== # Send out an empty array, to trigger no charts. @@ -84,30 +63,33 @@ if paydays: # ============================================= def genpaydays(): + cur_week = 153 + len(paydays) # 154 was the first week of Gratipay 2.0 for payday in paydays: + payday['xText'] = cur_week + cur_week -= 1 yield payday + paydaygen = genpaydays() curpayday = next(paydaygen) - # Loop through transfers, advancing payday cursor as appropriate. # =============================================================== - for transfer in transfers: - while transfer['timestamp'] < curpayday['ts_start']: - del curpayday['ts_start'] # done with it, don't want it in output + for payment in payments: + while payment['timestamp'] < curpayday['ts_start']: + del curpayday['ts_start'] # done with it, don't want it in output curpayday = next(paydaygen) - curpayday['npatrons'] += 1 - curpayday['receipts'] += transfer['amount'] + curpayday['nreceiving_from'] += 1 + curpayday['receipts'] += payment['amount'] # Prepare response. # ================= response.headers["Access-Control-Allow-Origin"] = "*" -out = paydays[:-1] # don't show Gratipay #0 +out = paydays # JSONP - see https://github.com/gratipay/aspen-python/issues/138 callback = request.qs.get('callback') diff --git a/www/%team/index.html.spt b/www/%team/index.html.spt index ea0d6342a5..037563d218 100644 --- a/www/%team/index.html.spt +++ b/www/%team/index.html.spt @@ -13,7 +13,7 @@ suppress_sidebar = not(team.is_approved or user.ADMIN) is_team_owner = not user.ANON and team.owner == user.participant.username [-----------------------------------------------------------------------------] -{% extends "templates/base.html" %} +{% extends "templates/team-base.html" %} {% block head %} {% endblock %} -{% block banner %} -
- -
- -
- {% if team.is_approved %} - - - - - - - - - - - - - - - - -
{{ _("Weekly") }}Σ  n
{{ _("Receiving") }}{{ format_currency(team.receiving, 'USD') }}{{ team.nreceiving_from }}
{{ _("Sharing") }}{{ format_currency(0, 'USD') }}0
- {% endif %} - -

- {% if team.is_approved %} - {{ _('Joined {ago}', ago=to_age(team.ctime, add_direction=True)) }} - {% else %} - {{ _('Applied {ago}', ago=to_age(team.ctime, add_direction=True)) }} - {% endif %} -

-
- -{{ super() }} -{% endblock %} - -{% block sidebar %} -{% if user.ADMIN %} -
- -
-
- -
-
-
-{% endif %} -{% if team.is_approved %} -{% include "templates/your-payment.html" %} -{% endif %} -{% endblock %} - {% block content %}

diff --git a/www/%team/receiving/index.html.spt b/www/%team/receiving/index.html.spt index 5f8d62c9a9..611069c979 100644 --- a/www/%team/receiving/index.html.spt +++ b/www/%team/receiving/index.html.spt @@ -1,26 +1,24 @@ from aspen import Response -from gratipay.utils import get_participant +from gratipay.utils import get_team [-----------------------------------------------------------------------------] -website.redirect('../', base_url='') team = get_team(state) if team.is_approved in (None, False): - if user.ANON: - raise Response(401) + raise Response(404) + +if not (user.ADMIN or user.participant.username == team.owner): + raise Response(401) + banner = team.name title = _("Receiving") [-----------------------------------------------------------------------------] -{% extends "templates/profile.html" %} +{% extends "templates/team-base.html" %} {% block content %} -{% set receiving = format_currency(participant.receiving, "USD") %} -{% if participant == user.participant %} -

{{ _( "You receive {0} per week", receiving) }}

-{% else %} -

{{ _("{0} receives {1} per week", participant.username, receiving) }}

-{% endif %} +{% set receiving = format_currency(team.receiving, "USD") %} +

{{ _("{0} receives {1} per week from {2} ~users.", team.name, "%s"|safe % receiving, team.nreceiving_from) }}

@@ -34,31 +32,28 @@ title = _("Receiving")
{{ _("weeks") }}
- -{% if participant.receiving > 0 and - not user.ANON and (user.participant == participant or user.ADMIN) %} -{% set tip_distribution = participant.get_tip_distribution()[0] %} - -

{{ _("Tips Received, by Number of Tips") }}

-{% include "templates/tip-distribution.html" %} - -

{{ _("Tips Received, by Dollar Amount") }}

- - {% for amount, ncontributors, summed, pcontributors, psummed in tip_distribution %} - - - - - - - {% endfor %} -
${{ amount }} - - {{ "%5.1f" % (psummed * 100) }}% - (${{ summed }}) -
+

Dues

+ +{% set funded_dues, unfunded_dues = team.get_dues() %} + +Funded Dues: {{ format_currency(funded_dues, "USD") }} +
+Unfunded Dues: {{ format_currency(unfunded_dues, "USD") }} +

+ +Estimated payment for next week: {{ format_currency(team.get_upcoming_payment(), "USD") }} + +{% if team.receiving > 0 %} + {% set payment_distribution = team.get_payment_distribution()[0] %} + +

{{ _("Payments Received, by Number of Payments") }}

+ {% include "templates/payment-distribution-number.html" %} + +

{{ _("Payments Received, by Dollar Amount") }}

+ {% include "templates/payment-distribution-amount.html" %} {% endif %} + {% endblock %} {% block scripts %} diff --git a/www/assets/gratipay.css.spt b/www/assets/gratipay.css.spt index 4838bc5b00..5d1217bc0e 100644 --- a/www/assets/gratipay.css.spt +++ b/www/assets/gratipay.css.spt @@ -43,7 +43,7 @@ @import "scss/components/status-icon"; @import "scss/components/support_gratipay"; @import "scss/components/table"; -@import "scss/components/tip-distribution"; +@import "scss/components/payment-distribution"; @import "scss/components/tipr"; @import "scss/components/upgrade";