From 897998957df360ed0a56fcea48dc74e14c872a93 Mon Sep 17 00:00:00 2001 From: Chad Whitacre Date: Wed, 11 Jul 2012 09:16:47 -0400 Subject: [PATCH] Add history page (#110) --- gittip/postgres.py | 8 +- gittip/utils.py | 125 ++++++++++++++ www/%participant_id/history.html | 288 +++++++++++++++++++++++++++++++ www/%participant_id/index.html | 10 +- 4 files changed, 427 insertions(+), 4 deletions(-) create mode 100644 www/%participant_id/history.html diff --git a/gittip/postgres.py b/gittip/postgres.py index bee6751578..b7126d968b 100644 --- a/gittip/postgres.py +++ b/gittip/postgres.py @@ -111,8 +111,10 @@ def fetchall(self, *a, **kw): """Execute the query and yield the results. """ with self.get_cursor(*a, **kw) as cursor: - for row in cursor: - yield row + if cursor.rowcount > 0: + return cursor + else: + return [] def get_cursor(self, *a, **kw): """Execute the query and return a context manager wrapping the cursor. @@ -128,7 +130,7 @@ def get_connection(self): class PostgresConnection(psycopg2.extensions.connection): """Subclass to change transaction, encoding, and cursor behavior. - THE DBAPI 2.0 spec calls for transactions to be left open by default. I + THE DBAPI 2.0 spec calls for transactions to be left open by default. I don't think we want this. We set autocommit to True. We enforce UTF-8. diff --git a/gittip/utils.py b/gittip/utils.py index 1a76a24f54..94c7476171 100644 --- a/gittip/utils.py +++ b/gittip/utils.py @@ -1,3 +1,6 @@ +import math +import datetime + from aspen.utils import typecheck from aspen._tornado.escape import linkify @@ -9,3 +12,125 @@ def wrap(u): u = linkify(u) # Do this first, because it calls xthml_escape. u = u.replace(u'\r\n', u'
\r\n').replace(u'\n', u'
\n') return u if u else '...' + + +def commaize(n, places=1): + """Given a number, return a string with commas and a decimal -- 1,000.0. + """ + out = ("%%.0%df" % places) % n + try: + whole, fraction = out.split('.') + except ValueError: + whole, fraction = (out, '') + _whole = [] + for i, digit in enumerate(reversed(whole), start=1): + _whole.insert(0, digit) + if i % 3 == 0: + _whole.insert(0, ',') + out = ''.join(_whole + ['.', fraction]).lstrip(',').rstrip('.') + return out + + +def total_seconds(td): + """ + Python 2.7 adds a total_seconds method to timedelta objects. + See http://docs.python.org/library/datetime.html#datetime.timedelta.total_seconds + + This function is taken from https://bitbucket.org/jaraco/jaraco.compat/src/e5806e6c1bcb/py26compat/__init__.py#cl-26 + + """ + try: + result = td.total_seconds() + except AttributeError: + result = (td.microseconds + (td.seconds + td.days * 24 * 3600) * 10**6) / 10**6 + return result + + +def dt2age(dt): + """Given a Unix timestamp (UTC) or a datetime object, return an age string + relative to now. + + range denomination example + ====================================================================== + 0-1 second "just a moment" + 1-59 seconds seconds 13 seconds + 60 sec - 59 min minutes 13 minutes + 60 min - 23 hrs, 59 min hours 13 hours + 24 hrs - 13 days, 23 hrs, 59 min days 13 days + 14 days - 27 days, 23 hrs, 59 min weeks 3 weeks + 28 days - 12 months, 31 days, 23 hrs, 59 mn months 6 months + 1 year - years 1 year + + We'll go up to years for now. + + Times in the future are indicated by "in (denomination)" and times + already passed are indicated by "(denomination) ago". + + """ + + if not isinstance(dt, datetime.datetime): + dt = datetime.datetime.utcfromtimestamp(dt) + + + # Define some helpful constants. + # ============================== + + sec = 1 + min = 60 * sec + hr = 60 * min + day = 24 * hr + wk = 7 * day + mn = 4 * wk + yr = 365 * day + + + # Get the raw age in seconds. + # =========================== + + now = datetime.datetime.now(dt.tzinfo) + age = total_seconds(abs(now - dt)) + + + # Convert it to a string. + # ======================= + # We start with the coarsest unit and filter to the finest. Pluralization + # is centralized. + + if age < 1: + return 'just a moment ago' + + elif age >= yr: # years + amount = age / yr + unit = 'year' + elif age >= mn: # months + amount = age / mn + unit = 'month' + elif age >= (2 * wk): # weeks + amount = age / wk + unit = 'week' + elif age >= day: # days + amount = age / day + unit = 'day' + elif age >= hr: # hours + amount = age / hr + unit = 'hour' + elif age >= min: # minutes + amount = age / min + unit = 'minute' + else: # seconds + amount = age + unit = 'second' + + + # Pluralize and return. + # ===================== + + amount = int(math.floor(amount)) + if amount != 1: + unit += 's' + if amount < 10: + amount = ['zero', 'a', 'two', 'three', 'four', 'five', 'six', + 'seven', 'eight', 'nine'][amount] + age = ' '.join([str(amount), unit]) + fmt = 'in {age}' if dt > now else '{age} ago' + return fmt.format(age=age) diff --git a/www/%participant_id/history.html b/www/%participant_id/history.html new file mode 100644 index 0000000000..c20bddfcff --- /dev/null +++ b/www/%participant_id/history.html @@ -0,0 +1,288 @@ +from decimal import Decimal + +from aspen import Response, log +from gittip import db, utils, AMOUNTS + + +class Paydays(object): + + def __init__(self, participant_id, balance): + self._participant_id = participant_id + self._paydays = db.fetchall("SELECT ts_start, ts_end FROM paydays " + "ORDER BY ts_end DESC") + self._npaydays = self._paydays.rowcount + self._exchanges = list(db.fetchall( "SELECT * FROM exchanges " + "WHERE participant_id=%s " + "ORDER BY timestamp ASC" + , (participant_id,) + )) + self._transfers = list(db.fetchall( "SELECT * FROM transfers " + "WHERE tipper=%s OR tippee=%s " + "ORDER BY timestamp ASC" + , (participant_id, participant_id) + )) + self._balance = balance + + + def __iter__(self): + """Yield iterators of events. + + Each payday is expected to encompass 0 or 1 exchanges and 0 or more + transfers per participant. Here we knit them together along with start + and end events for each payday. If we have exchanges or transfers that + fall outside of a payday, then we have a logic bug. I am 50% confident + that this will manifest some day. + + """ + _i = 1 + for payday in self._paydays: + + if not (self._exchanges or self._transfers): + # Show all paydays since the user started really participating. + break + + payday_start = { 'event': 'payday-start' + , 'timestamp': payday['ts_start'] + , 'number': self._npaydays - _i + , 'balance': Decimal('0.00') + } + payday_end = { 'event': 'payday-end' + , 'timestamp': payday['ts_end'] + , 'number': self._npaydays - _i + } + received = { 'event': 'received' + , 'amount': Decimal('0.00') + , 'n': 0 + } + _i += 1 + + + events = [] + while (self._exchanges or self._transfers): + + # Take the next event, either an exchange or transfer. + # ==================================================== + # We do this by peeking at both lists, and popping the list + # that has the next event. + + exchange = self._exchanges[-1] if self._exchanges else None + transfer = self._transfers[-1] if self._transfers else None + + if exchange is None: + event = self._transfers.pop() + elif transfer is None: + event = self._exchanges.pop() + elif transfer['timestamp'] > exchange['timestamp']: + event = self._transfers.pop() + else: + event = self._exchanges.pop() + + event['event'] = 'exchange' if 'fee' in event else 'transfer' + + + # Record the next event. + # ====================== + + if event['timestamp'] < payday_start['timestamp']: + if event['event'] == 'exchange': + back_on = self._exchanges + else: + back_on = self._transfers + back_on.append(event) + break + + if event['event'] == 'transfer': + if event['tippee'] == self._participant_id: + + # Don't leak details about who tipped you. Only show + # aggregates for that. + + received['amount'] += event['amount'] + received['n'] += 1 + + #continue # Don't leak! + + events.append(event) + + if not events: + continue + + + # Calculate balance. + # ================== + + prev = events[0] + prev['balance'] = self._balance + for event in events[1:] + [payday_start]: + if prev['event'] == 'exchange': + balance = prev['balance'] - prev['amount'] + elif prev['event'] == 'transfer': + if prev['tippee'] == self._participant_id: + balance = prev['balance'] - prev['amount'] + else: + balance = prev['balance'] + prev['amount'] + event['balance'] = balance + prev = event + self._balance = payday_start['balance'] + + yield payday_start + for event in reversed(events): + yield event + yield payday_end + + # This should catch that logic bug. + if self._exchanges or self._transfers: + log("These should be empty:", self._exchanges, self._transfers) + raise "Logic bug in payday timestamping." + +# ========================================================================== ^L + +if user.ANON: + raise Response(404) + +participant_id = path['participant_id'] + +if participant_id != user.id: + if not user.ADMIN: + raise Response(403) + +info = db.fetchone( "SELECT balance,claimed_time FROM participants WHERE id=%s" + , (participant_id,) + ) +if info is None: + raise Response(404) +claimed_time = info['claimed_time'] +paydays = Paydays(participant_id, info['balance']) + +# ========================================================================== ^L +{% extends templates/base.html %} +{% block body %} + +
+

{{ participant_id == user.id and "You" or participant_id }} joined + {{ utils.dt2age(claimed_time) }}.

+ +

{{ participant_id == user.id and "Your" or "Their" }} balance is + ${{ info['balance'] }}.

+ + + {% for event in paydays %} + {% if event['event'] == 'payday-start' %} + + + + + + + + + + + + + + + + + + + + + + + + {% elif event['event'] == 'balance' %} + + + + + + + + + {% elif event['event'] == 'exchange' %} + + + + + + + + + {% elif event['event'] == 'transfer' %} + + + + + + {% if event['tippee'] == participant_id %} + + {% else %} + + {% end %} + + + + {% if event['tippee'] == participant_id %} + {% if user.ADMIN and (participant_id != user.id or 'override' in qs) %} + + {% else %} + + {% end %} + {% else %} + {% if user.ADMIN %} + + {% else %} + + {% end %} + {% end %} + + + {% end %} + {% end %} +
Gittip #{{ event['number'] }} + —{{ event['timestamp'].strftime("%B %d, %Y").replace(' 0', ' ') }} + ({{ utils.dt2age(event['timestamp']) }})
← OutsideInside Gittip →
CardFeeIn/OutTipBalanceNote
{{ event['balance'] }}
{{ event['balance'] }}
{{ event['amount'] + event['fee'] }}{{ event['fee'] }}{{ event['amount'] }}{{ event['balance'] }}
{{ event['amount'] }}{{ event['amount'] }}{{ event['balance'] }}from {{ event['tipper'] }}from someoneto + {{ event['tippee'] }}to + {{ event['tippee'] }}
+ +
+ +{% end %} diff --git a/www/%participant_id/index.html b/www/%participant_id/index.html index 72f317b946..ff683871cf 100644 --- a/www/%participant_id/index.html +++ b/www/%participant_id/index.html @@ -12,7 +12,7 @@ import requests from aspen import json, Response from gittip import AMOUNTS, db, get_tip, get_tips_and_total, get_tipjar -from gittip.utils import wrap +from gittip.utils import wrap, dt2age from gittip.networks import github @@ -278,6 +278,10 @@

You {{ tipjar }}

{% end %} +

You joined {{ dt2age(user.claimed_time) }}. + History +

+ {% else %}

{{ participant['id'] }} {{ get_tipjar(participant['id'], claimed=True) }}

@@ -297,6 +301,10 @@

Linked Accounts

+

{{ participant['id'] }} joined {{ dt2age(participant['claimed_time']) }}.{% if user.ADMIN or user.id == participant['id'] %} + History + {% end %}

+ {% end %} {% end %}