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

ledger #3786

Closed
wants to merge 31 commits into from
Closed

ledger #3786

Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
e081503
Reimplement CSS using a renderer
chadwhitacre Sep 16, 2015
c96df4e
Backbone app barely working
chadwhitacre Sep 16, 2015
6fb9b3d
Refactor and get templating working
chadwhitacre Sep 16, 2015
fe93c11
Everyone seems to say "type" of account
chadwhitacre Sep 16, 2015
d27510c
Implement routing
chadwhitacre Sep 16, 2015
e211fda
Start styling the Dashboard
chadwhitacre Sep 16, 2015
f632d21
Fix tests
chadwhitacre Sep 16, 2015
050fd20
Center the dashboard
chadwhitacre Sep 16, 2015
8c26796
Markup and wiring to add account; API needed
chadwhitacre Sep 16, 2015
d10ef73
Update stub data
chadwhitacre Sep 16, 2015
b5c833a
Refactor to push add widget down
chadwhitacre Sep 16, 2015
b2dbb2e
Minimally write new accounts to the db
chadwhitacre Sep 17, 2015
a985a72
Tighten up widths on the accounts table
chadwhitacre Sep 17, 2015
5a3aa45
Persistence!
chadwhitacre Sep 17, 2015
3d7cec9
Fix test_pages again
chadwhitacre Sep 17, 2015
81fa191
Implement account editing
chadwhitacre Sep 17, 2015
19c7ab4
Test the v2/accounts API
chadwhitacre Sep 17, 2015
1de32dc
Fix bug where dupe inserts were 404 instead of 400
chadwhitacre Sep 17, 2015
0d4c6cc
Move new API to api/
chadwhitacre Sep 17, 2015
0dbe6a1
Integrate HTML documentation with the API
chadwhitacre Sep 17, 2015
a4bd171
Trim whitespace
chadwhitacre Sep 17, 2015
4d16505
Flatten the accounts endpoint
chadwhitacre Sep 17, 2015
ce8613b
Cap accounts listing until we implement paging
chadwhitacre Sep 17, 2015
78138d1
s/bank/banking
chadwhitacre Sep 17, 2015
c2660b4
Fill out the API main page
chadwhitacre Sep 17, 2015
4a05cf5
Clean up curl examples in API
chadwhitacre Sep 17, 2015
0852582
Exercise the {"you": "foo"} behavior on /api/v2/
chadwhitacre Sep 17, 2015
bd8b346
Standardize curl calls
chadwhitacre Sep 17, 2015
d272878
Point people from accounts/detail to accounts/
chadwhitacre Sep 17, 2015
f1b5256
Write up docs for new GL Accounts schema
chadwhitacre Sep 21, 2015
544429d
Rename accounts/ to general-ledger-accounts/
chadwhitacre Sep 21, 2015
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion gratipay/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
from gratipay.security import authentication, csrf, security_headers
from gratipay.utils import erase_cookie, http_caching, i18n, set_cookie, timer
from gratipay.version import get_version
from gratipay.renderers import csv_dump, jinja2_htmlescaped, eval_
from gratipay.renderers import csv_dump, jinja2_htmlescaped, eval_, scss

import aspen
from aspen.website import Website
Expand All @@ -27,8 +27,10 @@
website.renderer_factories['csv_dump'] = csv_dump.Factory(website)
website.renderer_factories['eval'] = eval_.Factory(website)
website.renderer_factories['jinja2_htmlescaped'] = jinja2_htmlescaped.Factory(website)
website.renderer_factories['scss'] = scss.Factory(website)
website.default_renderers_by_media_type['text/html'] = 'jinja2_htmlescaped'
website.default_renderers_by_media_type['text/plain'] = 'jinja2' # unescaped is fine here
website.default_renderers_by_media_type['text/css'] = 'scss'
website.default_renderers_by_media_type['image/*'] = 'eval'

website.renderer_factories['jinja2'].Renderer.global_context = {
Expand Down
43 changes: 43 additions & 0 deletions gratipay/renderers/scss.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
from __future__ import absolute_import, division, print_function, unicode_literals

import re
from urlparse import urlsplit

import sass
from aspen import renderers


class Renderer(renderers.Renderer):

def __init__(self, *a, **kw):
renderers.Renderer.__init__(self, *a, **kw)
self.website = self._factory._configuration

url_re = re.compile(r"""\burl\((['"])(.+?)['"]\)""")

def url_sub(self, m):
url = urlsplit(m.group(2))
if url.scheme or url.netloc:
# We need both tests because "//example.com" has no scheme and "data:"
# has no netloc. In either case, we want to leave the URL untouched.
return m.group(0)
repl = self.website.asset(url.path) \
+ (url.query and '&'+url.query) \
+ (url.fragment and '#'+url.fragment)
return 'url({0}{1}{0})'.format(m.group(1), repl)

def replace_urls(self, css):
return self.url_re.sub(self.url_sub, css)

def render_content(self, context):
output_style = 'compressed' if self.website.compress_assets else 'nested'
kw = dict(output_style=output_style, string=self.compiled)
if self.website.project_root is not None:
kw['include_paths'] = self.website.project_root
css = sass.compile(**kw)
if self.website.cache_static:
css = self.replace_urls(css)
return css

class Factory(renderers.Factory):
Renderer = Renderer
3 changes: 2 additions & 1 deletion gratipay/utils/markdown.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
def render(markdown):
return Markup(m.html(
markdown,
extensions=m.EXT_AUTOLINK | m.EXT_STRIKETHROUGH | m.EXT_NO_INTRA_EMPHASIS,
extensions=m.EXT_AUTOLINK | m.EXT_STRIKETHROUGH | m.EXT_NO_INTRA_EMPHASIS |
m.EXT_FENCED_CODE,
render_flags=m.HTML_SKIP_HTML | m.HTML_TOC | m.HTML_SMARTYPANTS | m.HTML_SAFELINK
))
3 changes: 3 additions & 0 deletions scss/elements/elements.scss
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,9 @@ table.simple {
padding-right: 42px;
}
}
b, strong {
font-weight: bold;
}
.mono {
font: normal 12px/16px $Mono;
}
Expand Down
66 changes: 0 additions & 66 deletions scss/gratipay.scss

This file was deleted.

8 changes: 8 additions & 0 deletions sql/branch.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
BEGIN;

CREATE TABLE accounts
( number varchar(32) UNIQUE NOT NULL
, name varchar(32) UNIQUE NOT NULL
);

END;
9 changes: 9 additions & 0 deletions templates/api-database.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{% extends "templates/api.html" %}
{% block subnav %}
{% set current_page = request.path.raw.split('/')[4] %}
{% set nav_base = '/api/v2/database' %}
{% set pages = [ ('/', _('Overview'))
, ('/continuants', _('Continuants'))
] %}
{% include "templates/nav.html" %}
{% endblock %}
9 changes: 9 additions & 0 deletions templates/api-general-ledger-accounts.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{% extends "templates/api.html" %}
{% block subnav %}
{% set current_page = request.path.raw.split('/')[4] %}
{% set nav_base = '/api/v2/general-ledger-accounts' %}
{% set pages = [ ('/', _('Listing'))
, ('/number', _('Detail'))
] %}
{% include "templates/nav.html" %}
{% endblock %}
15 changes: 15 additions & 0 deletions templates/api.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
{% extends "templates/base.html" %}
{% block sidebar %}
{% set current_page = request.path.raw.split('/')[3] %}
{% set nav_base = "/api/v2" %}
{% set pages = [ ('/', _('Overview'))
, ('/general-ledger-accounts/', _('GL Accounts'))
, ('/database/', _('Database'))
] %}
{% include "templates/nav.html" %}
{% endblock %}
{% block scripts %}
<link rel="stylesheet" href="{{ website.asset("hljs/agate.css") }}">
<script src="{{ website.asset("hljs/highlight.js") }}"></script>
<script>hljs.initHighlightingOnLoad();</script>
{% endblock %}
4 changes: 3 additions & 1 deletion tests/py/test_pages.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,9 @@ def browse(self, setup=None, **kw):
.replace('/%exchange_id.int', '/%s' % exchange_id) \
.replace('/%redirect_to', '/giving') \
.replace('/%endpoint', '/public') \
.replace('/about/me/%sub', '/about/me')
.replace('/about/me/%sub', '/about/me') \
.replace('/dashboard/%index', '') \
.replace('/v2/general-ledger-accounts/%number', '/v2/accounts/100')
assert '/%' not in url
if 'index' in url.split('/')[-1]:
url = url.rsplit('/', 1)[0] + '/'
Expand Down
21 changes: 21 additions & 0 deletions tests/py/test_v2_api.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
from __future__ import absolute_import, division, print_function, unicode_literals

from aspen import json
from gratipay.testing import Harness


class Tests(Harness):

def load(self, auth_as=None):
response = self.client.GET( '/api/v2/'
, auth_as=auth_as
, **{b'HTTP_ACCEPT': b'application/json'
})
return json.loads(response.body)

def test_anon_gets_anon(self):
assert self.load() == {'you': 'anon'}

def test_non_anon_gets_their_username(self):
self.make_participant('alice')
assert self.load('alice') == {'you': 'alice'}
95 changes: 95 additions & 0 deletions tests/py/test_v2_general-ledger-accounts.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
from __future__ import absolute_import, division, print_function, unicode_literals

from aspen import json, Response
from gratipay.testing import Harness
from pytest import raises


class Tests(Harness):

def setUp(self):
self.make_participant('admin', is_admin=True)
self.db.run("INSERT INTO accounts VALUES ('100', 'Cash')")

def load(self, number):
return json.loads(self.client.GET('/api/v2/general-ledger-accounts/'+number, auth_as='admin').body)


def test_can_list_accounts(self):
actual = json.loads(self.client.GET('/api/v2/general-ledger-accounts/', auth_as='admin').body)
assert actual == [{"number": "100", "name": "Cash"}]

def test_can_get_one_account(self):
assert self.load('100') == {"number": "100", "name": "Cash"}

def test_401_for_anon(self):
assert self.client.GxT('/api/v2/general-ledger-accounts/').code == 401
assert self.client.GxT('/api/v2/general-ledger-accounts/100').code == 401

def test_403_for_non_admin(self):
self.make_participant('alice')
assert self.client.GxT('/api/v2/general-ledger-accounts/', auth_as='alice').code == 403
assert self.client.GxT('/api/v2/general-ledger-accounts/100', auth_as='alice').code == 403

def test_can_change_name(self):
self.client.hit( 'PUT'
, '/api/v2/general-ledger-accounts/100'
, data={'number': '100', 'name': 'Cash'}
, auth_as='admin'
)
assert self.load('100') == {"number": "100", "name": "Cash"}

def test_bad_name_is_400(self):
response = self.client.hit( 'PUT'
, '/api/v2/general-ledger-accounts/100'
, data={'number': '100', 'name': 'Cash!'}
, auth_as='admin'
, raise_immediately=False
, **{b'HTTP_ACCEPT': b'application/json'}
)
assert response.code == 400
assert json.loads(response.body)['error_message_long'] == 'Invalid name.'
assert self.load('100') == {"number": "100", "name": "Cash"}

def test_can_save_new_account(self):
assert raises(Response, self.load, '101').value.code == 404
self.client.hit( 'PUT'
, '/api/v2/general-ledger-accounts/101'
, data={'number': '101', 'name': 'Accounts Receivable'}
, auth_as='admin'
)
assert self.load('101') == {"number": "101", "name": "Accounts Receivable"}

def test_inserting_a_duplicate_name_is_400(self):
response = self.client.hit( 'PUT'
, '/api/v2/general-ledger-accounts/101'
, data={'number': '101', 'name': 'Cash'}
, auth_as='admin'
, raise_immediately=False
, **{b'HTTP_ACCEPT': b'application/json'}
)
assert response.code == 400
assert json.loads(response.body)['error_message_long'] == \
'Failed to upsert (101, Cash). Probably a duplicate name?'
assert self.load('100') == {"number": "100", "name": "Cash"}
assert raises(Response, self.load, '101').value.code == 404

def test_updating_a_duplicate_name_is_400(self):
self.client.hit( 'PUT'
, '/api/v2/general-ledger-accounts/101'
, data={'number': '101', 'name': 'Accounts Receivable'}
, auth_as='admin'
)
response = self.client.hit( 'PUT'
, '/api/v2/general-ledger-accounts/101'
, data={'number': '101', 'name': 'Cash'}
, auth_as='admin'
, raise_immediately=False
, **{b'HTTP_ACCEPT': b'application/json'}
)
assert response.code == 400
assert json.loads(response.body)['error_message_long'] == \
'duplicate key value violates unique constraint "accounts_name_key"\n' \
'DETAIL: Key (name)=(Cash) already exists.\n'
assert self.load('100') == {"number": "100", "name": "Cash"}
assert self.load('101') == {"number": "101", "name": "Accounts Receivable"}
3 changes: 3 additions & 0 deletions www/api/index.spt
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
[---]
website.redirect('./v2', base_url='')
[---]
26 changes: 26 additions & 0 deletions www/api/v2/database/continuants.spt
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
from gratipay.utils import markdown
[---]
banner = _("API")
title = _("Continuants")
[---] text/html
{% extends "templates/api-database.html" %}
{% block content %}{{ markdown.render("""

The continuant schema design pattern begins with the notion of a \"foo\" object
that persists through time (or \"continues,\" hence, \"continuant\") despite
changes to its accidental properties. We encode such an object as multiple
records in a `foo` table, which is effectively a log of all changes to all Foos
over time. A `current_foo` view exposes each Foo as it exists at present.

The `foo` table will have `ctime` and `mtime` fields, timestamps at which the
notional continuant \"foo\" was created and modified (respectively). The
`ctime` field is carried forward on each `foo` record for convenience (using
[`COALESCE`](http://www.postgresql.org/docs/9.3/static/functions-conditional.html#FUNCTIONS-COALESCE-NVL-IFNULL)),
while the `mtime` field is unique for each record. Certain fields in `foo` are
essential to the identity of each Foo, and are carried forward along with the
`ctime`.

Grep the codebase for `payment_instructions` for an instance of this pattern,
or check out [`accounts`](/api/v2/accounts).

""") }}{% endblock %}
33 changes: 33 additions & 0 deletions www/api/v2/database/index.spt
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
from gratipay.utils import markdown
[---]
banner = _("API")
title = _("Database")
[---] text/html
{% extends "templates/api-database.html" %}
{% block content %}{{ markdown.render("""

We store our data in [PostgreSQL
9.3](http://www.postgresql.org/docs/9.3/static/), and we write a fair amount of
the [Postgres dialect of
SQL](http://www.postgresql.org/docs/9.3/static/sql.html). We use and maintain
the [postgres.py](http://postgres-py.readthedocs.org/) client library.

Our database schema is a mish-mash of different techniques and patterns.
Ideally we can standardize and be consistent, but that's gonna take some work.
Let's start with some documentation! Shall we? :-)


## Auditability Patterns

One of the things we want our database schema to support is auditability: we
want both ~users and admins to be able to review the history of changes to data
over time. We have two competing patterns for this:

- the [\"continuant\"](/api/v2/database/continuants) pattern, and
- an `events` table (not documented yet).

The continuant pattern came first historically. We started replacing it with an
`events` table, but we never finished. See
[1549](https://github.com/gratipay/gratipay.com/issues/1549) for backstory.

""") }}{% endblock %}
Loading