56 |
57 |
--------------------------------------------------------------------------------
/money_templates/static/jquery.selectfocus.js:
--------------------------------------------------------------------------------
1 | /* SelectFocus jQuery plugin
2 | *
3 | * Copyright (C) 2011 James Nylen
4 | *
5 | * Released under MIT license
6 | * For details see:
7 | * https://github.com/nylen/selectfocus
8 | */
9 |
10 | (function($) {
11 | var ns = '.selectfocus';
12 | var dataKey = 'lastEvent' + ns;
13 |
14 | /* It seems like just calling select() from a focus event handler SHOULD be
15 | * enough:
16 | *
17 | * $('selector').focus(function() { this.select(); });
18 | *
19 | * However, Chrome and Safari have a bug that causes this not to work -
20 | * clicking on a textbox, clicking off of it, then clicking back onto its
21 | * text causes the cursor to appear at the point in the text where the
22 | * textbox was clicked.
23 | *
24 | * See http://code.google.com/p/chromium/issues/detail?id=4505 for details.
25 | *
26 | * Recent versions of Firefox (4.x+?) appear to have the same issue, but it
27 | * only appears once every two clicks. Very strange.
28 | *
29 | * To work around this, we look for the following sequence of events in a
30 | * particular text box:
31 | *
32 | * mousedown -> focus -> mouseup ( -> click )
33 | *
34 | * If we get that chain of events, call preventDefault() in the mouseup event
35 | * handler as suggested on the Chromium bug page. This fixes the issue in
36 | * both Webkit and Firefox. In IE, we also need to call this.select() in the
37 | * click event handler.
38 | */
39 |
40 | var functions = {
41 | mousedown: function(e) {
42 | $(this).data(dataKey, 'mousedown');
43 | },
44 |
45 | focus: function(e) {
46 | $(this).data(dataKey,
47 | ($(this).data(dataKey) == 'mousedown' ? 'focus' : ''));
48 |
49 | this.select();
50 | },
51 |
52 | mouseup: function(e) {
53 | if($(this).data(dataKey) == 'focus') {
54 | e.preventDefault();
55 | }
56 |
57 | $(this).data(dataKey,
58 | ($(this).data(dataKey) == 'focus' ? 'mouseup' : ''));
59 | },
60 |
61 | click: function() {
62 | if($(this).data(dataKey) == 'mouseup') {
63 | this.select();
64 | }
65 |
66 | $(this).data(dataKey, 'click');
67 | },
68 |
69 | blur: function(e) {
70 | // Just for good measure
71 | $(this).data(dataKey, 'blur');
72 | }
73 | };
74 |
75 | $.fn.selectfocus = function(opts) {
76 | var toReturn = this.noselectfocus();
77 | $.each(functions, function(e, fn) {
78 | toReturn = toReturn[opts && opts.live ? 'live' : 'bind'](e + ns, fn);
79 | });
80 | return toReturn;
81 | };
82 |
83 | $.fn.noselectfocus = function() {
84 | var toReturn = this;
85 | // .die('.namespace') does not appear to work in jQuery 1.5.1 or 1.6.2.
86 | // Loop through events one at a time.
87 | $.each(functions, function(e, fn) {
88 | toReturn = toReturn.die(e + ns, fn);
89 | });
90 | return toReturn.unbind(ns).removeData(dataKey);
91 | };
92 | })(jQuery);
93 |
--------------------------------------------------------------------------------
/money_templates/templates/page_batch_categorize.html:
--------------------------------------------------------------------------------
1 | {% extends "page_base.html" %}
2 |
3 | {% load template_extras %}
4 |
5 | {% block title %}Categorize multiple transactions{% endblock %}
6 |
7 | {% block scripts %}
8 |
9 | {% endblock %}
10 |
11 | {% block body %}
12 |
13 |
14 | Use the form below to categorize multiple transactions at once, and
15 | create rules that will be applied to all future transactions with the
16 | same description. Only uncategorized transactions (those that
17 | currently balance to the Imbalance-USD account) will be
18 | modified.
19 |
20 |
21 |
73 | {% endblock body %}
74 |
--------------------------------------------------------------------------------
/money_templates/static/vendor/c3-0.4.10/README.md:
--------------------------------------------------------------------------------
1 | c3 [](https://travis-ci.org/masayuki0812/c3) [](https://david-dm.org/masayuki0812/c3) [](https://david-dm.org/masayuki0812/c3#info=devDependencies) [](https://github.com/masayuki0812/c3/blob/master/LICENSE)
2 | ==
3 |
4 | c3 is a D3-based reusable chart library that enables deeper integration of charts into web applications.
5 |
6 | Follow the link for more information: [http://c3js.org](http://c3js.org/)
7 |
8 | ## Tutorial and Examples
9 |
10 | + [Getting Started](http://c3js.org/gettingstarted.html)
11 | + [Examples](http://c3js.org/examples.html)
12 |
13 | Additional samples can be found in this repository:
14 | + [https://github.com/masayuki0812/c3/tree/master/htdocs/samples](https://github.com/masayuki0812/c3/tree/master/htdocs/samples)
15 |
16 | You can run these samples as:
17 | ```
18 | $ cd c3/htdocs
19 | $ python -m SimpleHTTPServer 8080
20 | ```
21 |
22 | ## Google Group
23 | For general C3.js-related discussion, please visit our [Google Group at https://groups.google.com/forum/#!forum/c3js](https://groups.google.com/forum/#!forum/c3js).
24 |
25 | ## Using the issue queue
26 | The [issue queue](https://github.com/masayuki0812/c3/issues) is to be used for reporting defects and problems with C3.js, in addition to feature requests and ideas. It is **not** a catch-all support forum. **For general support enquiries, please use the [Google Group](https://groups.google.com/forum/#!forum/c3js) at https://groups.google.com/forum/#!forum/c3js.** All questions involving the interplay between C3.js and any other library (such as AngularJS) should be posted there first!
27 |
28 | Before reporting an issue, please do the following:
29 | 1. [Search for existing issues](https://github.com/masayuki0812/c3/issues) to ensure you're not posting a duplicate.
30 |
31 | 1. [Search the Google Group](https://groups.google.com/forum/#!forum/c3js) to ensure it hasn't been addressed there already.
32 |
33 | 1. Create a JSFiddle or Plunkr highlighting the issue. Please don't include any unnecessary dependencies so we can isolate that the issue is in fact with C3. *Please be advised that custom CSS can modify C3.js output!*
34 |
35 | 1. When posting the issue, please use a descriptive title and include the version of C3 (or, if cloning from Git, the commit hash — C3 is under active development and the master branch contains the latest dev commits!), along with any platform/browser/OS information that may be relevant.
36 |
37 | ## Pull requests
38 | Pull requests are welcome, though please post an issue first to see whether such a change is desirable.
39 | If you choose to submit a pull request, please do not bump the version number unless asked to, and please include test cases for any new features!
40 |
41 | ## Playground
42 | Please fork this fiddle:
43 | + [http://jsfiddle.net/masayuki0812/7kYJu/](http://jsfiddle.net/masayuki0812/7kYJu/)
44 |
45 | ## Dependency
46 | + [D3.js](https://github.com/mbostock/d3) `<=3.5.0`
47 |
48 | ## License
49 | MIT
50 |
51 | [](https://flattr.com/submit/auto?user_id=masayuki0812&url=https://github.com/masayuki0812/c3&title=c3&language=javascript&tags=github&category=software)
52 |
--------------------------------------------------------------------------------
/utils/templatetags/query_string.py:
--------------------------------------------------------------------------------
1 | from django import template
2 | from django.utils.safestring import mark_safe
3 |
4 | register = template.Library()
5 |
6 | def is_quoted_string(s):
7 | return (len(s) > 0 and s[0] == s[-1] and s[0] in ('"', "'"))
8 |
9 | @register.tag
10 | def query_string(parser, token):
11 | """
12 | Allows you to manipulate the query string of a page by adding and removing keywords.
13 | If a given value is a context variable it will resolve it.
14 | Based on similiar snippet by user "dnordberg".
15 |
16 | requires you to add:
17 |
18 | TEMPLATE_CONTEXT_PROCESSORS = (
19 | 'django.core.context_processors.request',
20 | )
21 |
22 | to your django settings.
23 |
24 | Usage:
25 | http://www.url.com/{% query_string "param_to_add=value, param_to_add=value" "param_to_remove, params_to_remove" %}
26 |
27 | Example:
28 | http://www.url.com/{% query_string "" "filter" %}filter={{new_filter}}
29 | http://www.url.com/{% query_string "page=page_obj.number" "sort" %}
30 |
31 | """
32 | pieces = token.split_contents()
33 | tag_name = pieces[0]
34 | add_string = "''"
35 | remove_string = "''"
36 | if len(pieces) > 1:
37 | add_string = pieces[1]
38 | if len(pieces) > 2:
39 | remove_string = pieces[2]
40 | if not is_quoted_string(add_string) or not is_quoted_string(remove_string):
41 | raise template.TemplateSyntaxError, "%r tag's argument should be in quotes" % tag_name
42 |
43 | add = string_to_dict_of_lists(add_string[1:-1])
44 | remove = string_to_list(remove_string[1:-1])
45 |
46 | return QueryStringNode(add, remove)
47 |
48 | class QueryStringNode(template.Node):
49 | def __init__(self, add, remove):
50 | self.add = add
51 | self.remove = remove
52 |
53 | def render(self, context):
54 | p = {}
55 | for k, v in context["request"].GET.lists():
56 | p[k] = v
57 |
58 | return get_query_string(p, self.add, self.remove, context)
59 |
60 | def get_query_string(p, new_params, remove, context):
61 | """
62 | Add and remove query parameters. Adapted from `django.contrib.admin`.
63 | """
64 | for r in remove:
65 | if r in p:
66 | del p[r]
67 |
68 | for k, v in new_params.items():
69 | if k in p and v is None:
70 | del p[k]
71 | elif v is not None:
72 | p[k] = v
73 |
74 | pairs = []
75 | for k, vl in p.items():
76 | for v in vl:
77 | try:
78 | v = template.Variable(v).resolve(context)
79 | except:
80 | pass
81 | pairs.append(u'%s=%s' % (k, v))
82 |
83 | if len(pairs) > 0:
84 | return mark_safe('?' + '&'.join(pairs).replace(' ', '%20'))
85 | else:
86 | return mark_safe('')
87 |
88 |
89 | # Adapted from lib/utils.py
90 |
91 | def string_to_dict_of_lists(s):
92 | d = {}
93 | for arg in str(s).split(','):
94 | arg = arg.strip()
95 | if arg == '': continue
96 | key, val = arg.split('=', 1)
97 | if key in d:
98 | d[key].append(val)
99 | else:
100 | d[key] = [val]
101 | return d
102 |
103 | def string_to_list(s):
104 | args = []
105 | for arg in str(s).split(','):
106 | arg = arg.strip()
107 | if arg == '': continue
108 | args.append(arg)
109 | return args
110 |
--------------------------------------------------------------------------------
/money_templates/static/vendor/c3-0.4.10/c3.css:
--------------------------------------------------------------------------------
1 | /*-- Chart --*/
2 | .c3 svg {
3 | font: 10px sans-serif; }
4 |
5 | .c3 path, .c3 line {
6 | fill: none;
7 | stroke: #000; }
8 |
9 | .c3 text {
10 | -webkit-user-select: none;
11 | -moz-user-select: none;
12 | user-select: none; }
13 |
14 | .c3-legend-item-tile, .c3-xgrid-focus, .c3-ygrid, .c3-event-rect, .c3-bars path {
15 | shape-rendering: crispEdges; }
16 |
17 | .c3-chart-arc path {
18 | stroke: #fff; }
19 |
20 | .c3-chart-arc text {
21 | fill: #fff;
22 | font-size: 13px; }
23 |
24 | /*-- Axis --*/
25 | /*-- Grid --*/
26 | .c3-grid line {
27 | stroke: #aaa; }
28 |
29 | .c3-grid text {
30 | fill: #aaa; }
31 |
32 | .c3-xgrid, .c3-ygrid {
33 | stroke-dasharray: 3 3; }
34 |
35 | /*-- Text on Chart --*/
36 | .c3-text.c3-empty {
37 | fill: #808080;
38 | font-size: 2em; }
39 |
40 | /*-- Line --*/
41 | .c3-line {
42 | stroke-width: 1px; }
43 |
44 | /*-- Point --*/
45 | .c3-circle._expanded_ {
46 | stroke-width: 1px;
47 | stroke: white; }
48 |
49 | .c3-selected-circle {
50 | fill: white;
51 | stroke-width: 2px; }
52 |
53 | /*-- Bar --*/
54 | .c3-bar {
55 | stroke-width: 0; }
56 |
57 | .c3-bar._expanded_ {
58 | fill-opacity: 0.75; }
59 |
60 | /*-- Focus --*/
61 | .c3-target.c3-focused {
62 | opacity: 1; }
63 |
64 | .c3-target.c3-focused path.c3-line, .c3-target.c3-focused path.c3-step {
65 | stroke-width: 2px; }
66 |
67 | .c3-target.c3-defocused {
68 | opacity: 0.3 !important; }
69 |
70 | /*-- Region --*/
71 | .c3-region {
72 | fill: steelblue;
73 | fill-opacity: 0.1; }
74 |
75 | /*-- Brush --*/
76 | .c3-brush .extent {
77 | fill-opacity: 0.1; }
78 |
79 | /*-- Select - Drag --*/
80 | /*-- Legend --*/
81 | .c3-legend-item {
82 | font-size: 12px; }
83 |
84 | .c3-legend-item-hidden {
85 | opacity: 0.15; }
86 |
87 | .c3-legend-background {
88 | opacity: 0.75;
89 | fill: white;
90 | stroke: lightgray;
91 | stroke-width: 1; }
92 |
93 | /*-- Tooltip --*/
94 | .c3-tooltip-container {
95 | z-index: 10; }
96 |
97 | .c3-tooltip {
98 | border-collapse: collapse;
99 | border-spacing: 0;
100 | background-color: #fff;
101 | empty-cells: show;
102 | -webkit-box-shadow: 7px 7px 12px -9px #777777;
103 | -moz-box-shadow: 7px 7px 12px -9px #777777;
104 | box-shadow: 7px 7px 12px -9px #777777;
105 | opacity: 0.9; }
106 |
107 | .c3-tooltip tr {
108 | border: 1px solid #CCC; }
109 |
110 | .c3-tooltip th {
111 | background-color: #aaa;
112 | font-size: 14px;
113 | padding: 2px 5px;
114 | text-align: left;
115 | color: #FFF; }
116 |
117 | .c3-tooltip td {
118 | font-size: 13px;
119 | padding: 3px 6px;
120 | background-color: #fff;
121 | border-left: 1px dotted #999; }
122 |
123 | .c3-tooltip td > span {
124 | display: inline-block;
125 | width: 10px;
126 | height: 10px;
127 | margin-right: 6px; }
128 |
129 | .c3-tooltip td.value {
130 | text-align: right; }
131 |
132 | /*-- Area --*/
133 | .c3-area {
134 | stroke-width: 0;
135 | opacity: 0.2; }
136 |
137 | /*-- Arc --*/
138 | .c3-chart-arcs-title {
139 | dominant-baseline: middle;
140 | font-size: 1.3em; }
141 |
142 | .c3-chart-arcs .c3-chart-arcs-background {
143 | fill: #e0e0e0;
144 | stroke: none; }
145 |
146 | .c3-chart-arcs .c3-chart-arcs-gauge-unit {
147 | fill: #000;
148 | font-size: 16px; }
149 |
150 | .c3-chart-arcs .c3-chart-arcs-gauge-max {
151 | fill: #777; }
152 |
153 | .c3-chart-arcs .c3-chart-arcs-gauge-min {
154 | fill: #777; }
155 |
156 | .c3-chart-arc .c3-gauge-value {
157 | fill: #000;
158 | /* font-size: 28px !important;*/ }
159 |
--------------------------------------------------------------------------------
/money_templates/static/vendor/c3-0.4.10/extensions/exporter/phantom-exporter.js:
--------------------------------------------------------------------------------
1 | /**
2 | * PNG\JPEG exporter for C3.js, version 0.2
3 | * (c) 2014 Yuval Bar-On
4 | *
5 | * usage: path/to/phantomjs output options [WxH]
6 | *
7 | */
8 |
9 | // useful python-styled string formatting, "hello {0}! Javascript is {1}".format("world", "awesome");
10 | if (!String.prototype.format) {
11 | String.prototype.format = function() {
12 | var args = arguments;
13 | return this.replace(/{(\d+)}/g, function(match, number) {
14 | return typeof args[number] != 'undefined'
15 | ? args[number]
16 | : match
17 | ;
18 | });
19 | };
20 | }
21 |
22 | // defaults
23 | var page = require('webpage').create(),
24 | fs = require('fs'),
25 | system = require('system'),
26 | config = JSON.parse( fs.read('config.json') ),
27 | output, size;
28 |
29 | if (system.args.length < 3 ) {
30 | console.log('Usage: phantasm.js filename html [WxH]');
31 | phantom.exit(1);
32 | } else {
33 | out = system.args[1];
34 | opts = JSON.parse( system.args[2] );
35 |
36 | if (system.args[3]) {
37 | var dimensions = system.args[3].split('x'),
38 | width = dimensions[0],
39 | height = dimensions[1];
40 |
41 | function checkNum(check) {
42 | check = parseInt(check);
43 | if (!isNaN(check))
44 | return check;
45 | return false;
46 | }
47 |
48 | width = checkNum(width);
49 | height = checkNum(height);
50 |
51 | if (width && height) {
52 | page.viewportSize = {
53 | height: height,
54 | width: width
55 | }
56 | }
57 |
58 | // fit chart size to img size, if undefined
59 | if (!opts.size) {
60 | opts.size = {
61 | "height": height,
62 | "width": width
63 | };
64 | }
65 | } else {
66 | // check if size is defined in chart,
67 | // else apply defaults
68 | page.viewportSize = {
69 | height: (opts.size && opts.size.height) ? opts.size.height : 320,
70 | width: (opts.size && opts.size.width ) ? opts.size.width : 710,
71 | }
72 | }
73 | }
74 |
75 | page.onResourceRequested = function(requestData, request) {
76 | console.log('::loading resource ', requestData['url']);
77 | };
78 |
79 | // helpful debug functions
80 | page.onConsoleMessage = function(msg){
81 | console.log(msg);
82 | };
83 |
84 | page.onError = function(msg, trace) {
85 | var msgStack = ['ERROR: ' + msg];
86 |
87 | if (trace && trace.length) {
88 | msgStack.push('TRACE:');
89 | trace.forEach(function(t) {
90 | msgStack.push(' -> ' + t.file + ': ' + t.line + (t.function ? ' (in function "' + t.function +'")' : ''));
91 | });
92 | }
93 |
94 | console.error(msgStack.join('\n'));
95 | };
96 |
97 | // render page
98 | function injectVerify(script) {
99 | var req = page.injectJs(script);
100 | if (!req) {
101 | console.log( '\nError!\n' + script + ' not found!\n' );
102 | phantom.exit(1);
103 | }
104 | }
105 |
106 | page.onLoadFinished = function() {
107 | console.log('::rendering');
108 |
109 | for (var j in config.js) {
110 | injectVerify(config.js[j]);
111 | }
112 |
113 | page.evaluate(function(chartoptions) {
114 | // phantomjs doesn't know how to handle .bind, so we override
115 | Function.prototype.bind = Function.prototype.bind || function (thisp) {
116 | var fn = this;
117 | return function () {
118 | return fn.apply(thisp, arguments);
119 | };
120 | };
121 |
122 | // generate chart
123 | c3.generate(chartoptions);
124 |
125 | }, opts);
126 |
127 | // setting transition to 0 has proven not to work thus far, but 300ms isn't much
128 | // so this is acceptable for now
129 | setTimeout(function() {
130 | page.render(out);
131 | phantom.exit();
132 | }, 300);
133 | }
134 |
135 | // apply css inline because that usually renders better
136 | var css = '';
137 | for (var i in config.css) {
138 | css += fs.read(config.css[i]);
139 | }
140 | page.content = config.template.format(css);
--------------------------------------------------------------------------------
/gnucash_scripts/import_images_from_json.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python
2 |
3 | import json
4 | import mimetypes
5 | import os
6 | import sys
7 | from datetime import datetime
8 | from dateutil import parser as dateparser
9 | from decimal import Decimal
10 |
11 | from common import *
12 |
13 | # Add Django project directory (parent directory of current file) to path
14 | # This shouldn't be so hard...
15 | sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), os.path.pardir)))
16 |
17 | # Django setup
18 | # from http://www.b-list.org/weblog/2007/sep/22/standalone-django-scripts/
19 | # This script needs to be callable from the command line, but it also needs
20 | # to know about the Django project's database and other settings.
21 | from django.core.management import setup_environ
22 | import settings # only works due to path fuckery above
23 | setup_environ(settings)
24 |
25 | from django.core.files import uploadedfile
26 | from gnucash_data import models
27 | from utils import data_url
28 | from utils.AsciiDammit import asciiDammit
29 |
30 | # begin a session
31 | models.Lock.obtain()
32 |
33 | debug = False
34 |
35 | def debug_print(s):
36 | if debug:
37 | print s
38 |
39 |
40 | def get_transaction_string(t):
41 | memo = txinfo.get('memo', '')
42 | if memo:
43 | memo = ' / ' + memo
44 | return "'%s%s' on %s for %s" \
45 | % (t['description'], memo,
46 | t['date'].strftime('%Y-%m-%d'),
47 | t['amount'])
48 |
49 | try:
50 |
51 | for fn in sys.argv[1:]:
52 | if fn.upper() == 'DEBUG':
53 | debug = True
54 | continue
55 |
56 | f = open(fn, 'r')
57 | data = json.load(f)
58 | for bank in data:
59 | msg = bank.get('error', bank.get('status', ''))
60 | if msg:
61 | debug_print('Skipping bank %s: %s' % (
62 | bank['bank'], msg))
63 | continue
64 |
65 | debug_print('Processing bank %s' % bank['bank'])
66 |
67 | for acct_data in bank['data']:
68 | msg = acct_data.get('error', acct_data.get('status', ''))
69 | if msg:
70 | debug_print('Skipping account %s: %s' % (
71 | acct_data['account']['path'], msg))
72 | continue
73 |
74 | debug_print('Processing account %s' % acct_data['account']['path'])
75 |
76 | acct = models.Account.from_path(acct_data['account']['path'])
77 |
78 | for txinfo in acct_data['transactions']:
79 | txinfo['date'] = dateparser.parse(txinfo['date']).date() # treats as MM/DD/YYYY (good)
80 | txinfo['amount'] = Decimal(txinfo['amount'])
81 | txinfo['description'] = asciiDammit(txinfo.get('description', ''))
82 |
83 | if txinfo.has_key('memo'):
84 | txinfo['memo'] = asciiDammit(txinfo['memo'])
85 |
86 | if not txinfo.has_key('images'):
87 | continue
88 |
89 | imported_transactions = models.ImportedTransaction.objects.filter(source_tx_id=txinfo['sourceId'])
90 |
91 | if imported_transactions.count() == 0:
92 | debug_print('Transaction has not been imported yet: %s'
93 | % get_transaction_string(txinfo))
94 |
95 | else:
96 | for itrans in imported_transactions:
97 | try:
98 | trans = models.Transaction.objects.get(guid=itrans.tx_guid)
99 | except Exception as e:
100 | debug_print('Error getting transaction "%s": %s'
101 | % (get_transaction_string(txinfo), e))
102 | continue
103 |
104 | for (img_basename, img_data_url) in txinfo['images'].iteritems():
105 | img = data_url.parse(img_data_url)
106 | img_filename = img_basename + img.extension
107 |
108 | debug_print('Attaching image %s to transaction: %s'
109 | % (img_filename, trans))
110 |
111 | img_file = uploadedfile.SimpleUploadedFile(
112 | name=img_filename,
113 | content=img.data,
114 | content_type=img.mime_type
115 | )
116 | trans.attach_file(img_file)
117 | img_file.close()
118 |
119 | f.close()
120 |
121 | finally:
122 | debug_print('Unlocking GnuCash database')
123 | try:
124 | models.Lock.release()
125 | except Exception as e:
126 | print 'Error unlocking GnuCash database: %s' % e
127 | pass
128 |
129 | debug_print('Done importing images from JSON file(s)')
130 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | gnucash-django
2 | ==============
3 |
4 | A mobile-friendly Web frontend for GnuCash, primarily written using Django.
5 |
6 | Features
7 | --------
8 |
9 | - View transactions in a GnuCash account, along with their "opposing
10 | account"
11 |
12 | - Filter by opposing account, transaction description, or transaction post
13 | date
14 |
15 | - Change the opposing account of any transaction and create rules for future
16 | transactions
17 |
18 | - Attach images to transactions
19 |
20 | - Add new transactions from the web interface
21 |
22 | - Import JSON files produced by my
23 | [`banker`](https://github.com/nylen/node-banker) transaction downloader and
24 | automatically categorize transactions according to the saved rules
25 |
26 | - Similarly, import QIF files and categorize transactions
27 |
28 | Wishlist
29 | --------
30 |
31 | - Budgeting, etc.
32 |
33 | - Better management of rules and categorization
34 |
35 | Requirements
36 | ------------
37 |
38 | - A GnuCash file that uses a database backend (tested with MySQL; should work
39 | with Postgres or SQLite as well)
40 |
41 | - `pip` and `virtualenv` installed (in Debian/Ubuntu: `sudo apt-get install
42 | python-pip`, then `sudo pip install virtualenv`)
43 |
44 | - _(Optional)_ Python GnuCash API installed (currently this is only used in the
45 | code that imports QIF files)
46 |
47 | After you've followed the installation steps below, something like this
48 | should work to make the GnuCash API visible to this application's virtual
49 | environment:
50 |
51 | ln -s /usr/local/lib/python2.7/dist-packages/gnucash/ lib/python2.7/site-packages/
52 |
53 | Installation
54 | ------------
55 |
56 | - Download the application code into a folder:
57 |
58 | git clone git://github.com/nylen/gnucash-django.git
59 | cd gnucash-django
60 |
61 | - Initialize `virtualenv` and install dependencies:
62 |
63 | virtualenv .
64 | . bin/activate
65 | pip install -r requirements.txt
66 |
67 | - Copy `settings.example.py` to `settings.py` and fill in values for all
68 | places where the file contains three asterisks (`***`). At this point
69 | you'll need to set up a MySQL database and username, if you haven't done so
70 | already. Currently this must be done manually.
71 |
72 | You can use this command to generate a `SECRET_KEY` value:
73 |
74 | python -c 'import random; r=random.SystemRandom(); print "".join([r.choice("abcdefghijklmnopqrstuvwxyz0123456789!@#$%^&*(-_=+)") for i in range(50)])'
75 |
76 | - Create the database structure: `python manage.py syncdb`
77 |
78 | - In the previous step, you should have been prompted to create a Django
79 | superuser. If not, or you didn't create one, do that now by running
80 | `python manage.py createsuperuser`. This will be your login to the site.
81 |
82 | - There are two options for starting the application:
83 | 1. Django development server: `python manage.py runserver`
84 | 2. Configure Apache to host the application with mod\_wsgi. Here is an
85 | example:
86 |
87 | WSGIDaemonProcess site.com processes=2 threads=15
88 | WSGIProcessGroup site.com
89 |
90 | WSGIScriptAlias /gnucash-django /path/to/gnucash-django/apache/money.wsgi
91 |
92 | # This setup will allow everyone access to the application.
93 | # Even though visitors will have to log in, this is probably
94 | # still not a good idea and you should use Apache auth here.
95 | Order deny,allow
96 | Allow from all
97 |
98 |
99 | You may also want to set up a baseline environment for mod\_wsgi as
100 | described
101 | [here](http://code.google.com/p/modwsgi/wiki/VirtualEnvironments#Baseline_Environment).
102 |
103 | More information about configuring mod\_wsgi is on the
104 | [mod\_wsgi website](http://code.google.com/p/modwsgi/).
105 |
106 | - Browse to the site and log in as the superuser you created earlier. Example
107 | URLs:
108 | - Django development server: `http://localhost:8000/`
109 | - Apache and mod\_wsgi: `http://localhost/gnucash-django/`
110 |
111 | **NOTE**: Even though all views are set up to require authentication, this
112 | application has **NOT** been written with security in mind. Therefore, it is
113 | advisable to secure it using HTTPS and Apache authentication, or to disallow
114 | public access to the application.
115 |
--------------------------------------------------------------------------------
/money_views/api.py:
--------------------------------------------------------------------------------
1 | import json
2 |
3 | from django.core.urlresolvers import reverse, NoReverseMatch
4 | from django.http import HttpResponse, HttpResponseForbidden
5 |
6 | import filters
7 | import forms
8 |
9 | from gnucash_data.models import Split, Lock, Account, Transaction
10 | from utils import misc_functions
11 |
12 |
13 | class ApiFunctionUrls():
14 | def __init__(self):
15 | self.functions = []
16 | self._urls_dict = None
17 |
18 | def register_function(self, func):
19 | self.functions.append(func)
20 |
21 | @property
22 | def urls_dict(self):
23 | if self._urls_dict is None:
24 | self._urls_dict = {}
25 | for func in self.functions:
26 | self._urls_dict[func.__name__] = \
27 | reverse(__name__ + '.' + func.__name__)
28 | return self._urls_dict
29 |
30 | function_urls = ApiFunctionUrls()
31 |
32 |
33 | def json_api_function(func):
34 | function_urls.register_function(func)
35 | def helper(request, *args, **kwargs):
36 | try:
37 | if not request.user.is_authenticated():
38 | return HttpResponseForbidden(
39 | 'User is not authenticated. Refresh the page and try again.')
40 | data = json.dumps(func(request, *args, **kwargs))
41 | except Exception, e:
42 | data = json.dumps({'error': 'Error: ' + str(e)})
43 | return HttpResponse(data, mimetype='application/json')
44 | return helper
45 |
46 |
47 | @json_api_function
48 | def change_memo(request):
49 | split_guid = request.POST.get('split_guid', '')
50 | memo = request.POST.get('memo', '')
51 | split = Split.objects.get(guid=split_guid)
52 | Lock.obtain()
53 | try:
54 | split.memo = request.POST.get('memo', '')
55 | split.save()
56 | finally:
57 | Lock.release()
58 | return {
59 | 'split_guid': split_guid,
60 | 'memo': memo,
61 | }
62 |
63 |
64 | @json_api_function
65 | def change_account(request):
66 | split_guid = request.POST.get('split_guid', '')
67 | account_guid = request.POST.get('account_guid', '')
68 | split = Split.objects.get(guid=split_guid)
69 | Lock.obtain()
70 | try:
71 | split.account = Account.objects.get(guid=account_guid)
72 | split.save()
73 | finally:
74 | Lock.release()
75 | return {
76 | 'split_guid': split_guid,
77 | 'account_guid': account_guid,
78 | }
79 |
80 |
81 | @json_api_function
82 | def get_transactions(request):
83 | # This is not structured like the other account views (with a `key` parameter
84 | # in the URL) because the code above that builds _urls_dict cannot handle
85 | # views with parameters.
86 | key = request.GET.get('accounts')
87 |
88 | accounts = misc_functions.get_accounts_by_webapp_key(key)
89 | splits = filters.TransactionSplitFilter(accounts)
90 |
91 | choices = forms.AccountChoices(accounts)
92 |
93 | filter_form = forms.FilterForm(choices, request.GET)
94 | if filter_form.is_valid():
95 | splits.filter_splits(filter_form.cleaned_data)
96 |
97 | splits.order_filtered_splits()
98 |
99 | Transaction.cache_from_splits(splits.filtered_splits)
100 | data_splits = []
101 | data_transactions = []
102 | transactions_seen = {}
103 |
104 | for s in splits.filtered_splits:
105 | # Determine the best memo to show, if any
106 | # TODO logic duplicated with money_views.views.account_csv
107 | memo = ''
108 | if s.memo_is_id_or_blank:
109 | for memo_split in s.opposing_split_set:
110 | if not memo_split.memo_is_id_or_blank:
111 | memo = memo_split.memo
112 | break
113 | else:
114 | memo = s.memo
115 |
116 | tx = s.transaction
117 |
118 | if tx.guid not in transactions_seen:
119 | data_tx_splits = []
120 | for ts in tx.split_set.all():
121 | data_tx_splits.append({
122 | 'guid': ts.guid,
123 | 'account': {
124 | 'friendly_name': ts.account.description_or_name,
125 | 'path': ts.account.path,
126 | 'guid': ts.account.guid
127 | },
128 | 'memo': ts.memo,
129 | 'amount': str(ts.amount)
130 | })
131 | data_transactions.append({
132 | 'guid': tx.guid,
133 | 'description': tx.description,
134 | 'post_date': misc_functions.date_to_timestamp(tx.post_date),
135 | 'splits': data_tx_splits
136 | })
137 | transactions_seen[tx.guid] = True
138 |
139 | opposing_account = s.opposing_account
140 | data_splits.append({
141 | 'account': {
142 | 'friendly_name': s.account.description_or_name,
143 | 'path': s.account.path,
144 | 'guid': s.account.guid
145 | },
146 | 'opposing_account': {
147 | 'friendly_name': opposing_account.description_or_name,
148 | 'path': opposing_account.path,
149 | 'guid': opposing_account.guid
150 | },
151 | 'tx_guid': tx.guid,
152 | 'description': tx.description,
153 | 'memo': memo,
154 | 'post_date': misc_functions.date_to_timestamp(tx.post_date),
155 | 'amount': str(s.amount)
156 | })
157 |
158 | return {
159 | 'splits': data_splits,
160 | 'transactions': data_transactions
161 | }
162 |
--------------------------------------------------------------------------------
/money_templates/static/jquery.shiftcheckbox.js:
--------------------------------------------------------------------------------
1 | /* ShiftCheckbox jQuery plugin
2 | *
3 | * Copyright (C) 2011-2012 James Nylen
4 | *
5 | * Released under MIT license
6 | * For details see:
7 | * https://github.com/nylen/shiftcheckbox
8 | *
9 | * Requires jQuery v1.6 or higher.
10 | */
11 |
12 | (function($) {
13 | var ns = '.shiftcheckbox';
14 |
15 | $.fn.shiftcheckbox = function(opts) {
16 | opts = $.extend({
17 | checkboxSelector: null,
18 | selectAll: null
19 | }, opts);
20 |
21 | var $containers;
22 | var $checkboxes;
23 | var $containersSelectAll;
24 | var $checkboxesSelectAll;
25 | var $otherSelectAll;
26 | var $containersAll;
27 | var $checkboxesAll;
28 |
29 | if (opts.selectAll) {
30 | // We need to set up a "select all" control
31 | $containersSelectAll = $(opts.selectAll);
32 | if ($containersSelectAll && !$containersSelectAll.length) {
33 | $containersSelectAll = false;
34 | }
35 | }
36 |
37 | if ($containersSelectAll) {
38 | $checkboxesSelectAll = $containersSelectAll
39 | .filter(':checkbox')
40 | .add($containersSelectAll.find(':checkbox'));
41 |
42 | $containersSelectAll = $containersSelectAll.not(':checkbox');
43 | $otherSelectAll = $containersSelectAll.filter(function() {
44 | return !$(this).find($checkboxesSelectAll).length;
45 | });
46 | $containersSelectAll = $containersSelectAll.filter(function() {
47 | return !!$(this).find($checkboxesSelectAll).length;
48 | }).each(function() {
49 | $(this).data('childCheckbox', $(this).find($checkboxesSelectAll)[0]);
50 | });
51 | }
52 |
53 | if (opts.checkboxSelector) {
54 |
55 | // checkboxSelector means that the elements we need to attach handlers to
56 | // ($containers) are not actually checkboxes but contain them instead
57 |
58 | $containersAll = this.filter(function() {
59 | return !!$(this).find(opts.checkboxSelector).filter(':checkbox').length;
60 | }).each(function() {
61 | $(this).data('childCheckbox', $(this).find(opts.checkboxSelector).filter(':checkbox')[0]);
62 | }).add($containersSelectAll);
63 |
64 | $checkboxesAll = $containersAll.map(function() {
65 | return $(this).data('childCheckbox');
66 | });
67 |
68 | } else {
69 |
70 | $checkboxesAll = this.filter(':checkbox');
71 |
72 | }
73 |
74 | if ($checkboxesSelectAll && !$checkboxesSelectAll.length) {
75 | $checkboxesSelectAll = false;
76 | } else {
77 | $checkboxesAll = $checkboxesAll.add($checkboxesSelectAll);
78 | }
79 |
80 | if ($otherSelectAll && !$otherSelectAll.length) {
81 | $otherSelectAll = false;
82 | }
83 |
84 | if ($containersAll) {
85 | $containers = $containersAll.not($containersSelectAll);
86 | }
87 | $checkboxes = $checkboxesAll.not($checkboxesSelectAll);
88 |
89 | if (!$checkboxes.length) {
90 | return;
91 | }
92 |
93 | var lastIndex = -1;
94 |
95 | var checkboxClicked = function(e) {
96 | var checked = !!$(this).attr('checked');
97 |
98 | var curIndex = $checkboxes.index(this);
99 | if (curIndex < 0) {
100 | if ($checkboxesSelectAll.filter(this).length) {
101 | $checkboxesAll.attr('checked', checked);
102 | }
103 | return;
104 | }
105 |
106 | if (e.shiftKey && lastIndex != -1) {
107 | var di = (curIndex > lastIndex ? 1 : -1);
108 | for (var i = lastIndex; i != curIndex; i += di) {
109 | $checkboxes.eq(i).attr('checked', checked);
110 | }
111 | }
112 |
113 | if ($checkboxesSelectAll) {
114 | if (checked && !$checkboxes.not(':checked').length) {
115 | $checkboxesSelectAll.attr('checked', true);
116 | } else if (!checked) {
117 | $checkboxesSelectAll.attr('checked', false);
118 | }
119 | }
120 |
121 | lastIndex = curIndex;
122 | };
123 |
124 | if ($checkboxesSelectAll) {
125 | $checkboxesSelectAll
126 | .attr('checked', !$checkboxes.not(':checked').length)
127 | .filter(function() {
128 | return !$containersAll.find(this).length;
129 | }).bind('click' + ns, checkboxClicked);
130 | }
131 |
132 | if ($otherSelectAll) {
133 | $otherSelectAll.bind('click' + ns, function() {
134 | var checked;
135 | if ($checkboxesSelectAll) {
136 | checked = !!$checkboxesSelectAll.eq(0).attr('checked');
137 | } else {
138 | checked = !!$checkboxes.eq(0).attr('checked');
139 | }
140 | $checkboxesAll.attr('checked', !checked);
141 | });
142 | }
143 |
144 | if (opts.checkboxSelector) {
145 | $containersAll.bind('click' + ns, function(e) {
146 | var $checkbox = $($(this).data('childCheckbox'));
147 | $checkbox.not(e.target).attr('checked', function() {
148 | return !$checkbox.attr('checked');
149 | });
150 |
151 | $checkbox[0].focus();
152 | checkboxClicked.call($checkbox, e);
153 |
154 | // If the user clicked on a label inside the row that points to the
155 | // current row's checkbox, cancel the event.
156 | var $label = $(e.target).closest('label');
157 | var labelFor = $label.attr('for');
158 | if (labelFor && labelFor == $checkbox.attr('id')) {
159 | if ($label.find($checkbox).length) {
160 | // Special case: The label contains the checkbox.
161 | if ($checkbox[0] != e.target) {
162 | return false;
163 | }
164 | } else {
165 | return false;
166 | }
167 | }
168 | }).bind('mousedown' + ns, function(e) {
169 | if (e.shiftKey) {
170 | // Prevent selecting text by Shift+click
171 | return false;
172 | }
173 | });
174 | } else {
175 | $checkboxes.bind('click' + ns, checkboxClicked);
176 | }
177 |
178 | return this;
179 | };
180 | })(jQuery);
181 |
--------------------------------------------------------------------------------
/money_templates/static/style.css:
--------------------------------------------------------------------------------
1 | body {
2 | font-family: Verdana, Arial, sans-serif;
3 | }
4 | a {
5 | outline: none;
6 | text-decoration: none;
7 | color: blue;
8 | }
9 | a:hover {
10 | text-decoration: underline;
11 | }
12 | a.no-ul:hover {
13 | text-decoration: none;
14 | }
15 |
16 | .hidden {
17 | display: none;
18 | }
19 |
20 | body.copy-mode {
21 | cursor: copy;
22 | }
23 | body.copy-mode tr.hover {
24 | background: #ff6;
25 | }
26 |
27 | .block {
28 | padding-top: 8px;
29 | margin-top: 8px;
30 | border-top: 2px solid #ccc;
31 | }
32 | .block-first {
33 | padding-top: 0;
34 | margin-top: 0;
35 | border-top: none;
36 | }
37 |
38 | table#index-account-select td.middle select {
39 | /* display: table-cell; */
40 | width: 100%;
41 | }
42 |
43 | ul.links {
44 | padding: .75em 0 .75em 30px;
45 | margin: 0;
46 | }
47 |
48 | table.account-block th {
49 | font-weight: bold;
50 | padding-right: 4px;
51 | text-align: left;
52 | }
53 | .account-balance-info {
54 | cursor: pointer;
55 | }
56 |
57 | table.transactions .date {
58 | text-align: left;
59 | min-width: 4.5em;
60 | }
61 | table.transactions .description {
62 | padding: 8px 8px 0;
63 | font-weight: normal;
64 | font-size: 85%;
65 | color: #777;
66 | }
67 | table.transactions .account-index {
68 | color: #555;
69 | padding: 1px 3px;
70 | background: #eee;
71 | border: 1px solid #ccc;
72 | border-radius: 2px;
73 | -webkit-border-radius: 2px;
74 | -moz-border-radius: 2px;
75 | }
76 | table.transactions .memo {
77 | padding: 0 16px;
78 | font-size: 70%;
79 | color: #777;
80 | }
81 | .transaction-control {
82 | color: #ddd;
83 | font-size: 90%;
84 | padding: 4px;
85 | }
86 | .transaction-control:hover {
87 | color: #999;
88 | }
89 | table.transactions .edit-memo {
90 | cursor: pointer;
91 | }
92 | table.transactions .opposing-account {
93 | padding-right: 8px;
94 | text-align: center;
95 | }
96 | table.transactions td.opposing-account {
97 | font-size: 85%;
98 | }
99 | table.transactions .amount {
100 | text-align: right;
101 | }
102 | .credit {
103 | color: #3a3;
104 | }
105 | .debit {
106 | color: #900;
107 | }
108 |
109 | table.transactions .add-memo {
110 | cursor: pointer;
111 | display: none;
112 | }
113 | table.transactions .change-opposing-account {
114 | padding: 1px 3px;
115 | border: 1px solid #bbb;
116 | -webkit-border-radius: 2px;
117 | -moz-border-radius: 2px;
118 | border-radius: 2px;
119 | color: #aaa;
120 | position: relative;
121 | overflow: hidden;
122 | display: none;
123 | }
124 | .change-account {
125 | position: absolute;
126 | top: 0;
127 | left: -1px;
128 | opacity: 0;
129 | cursor: pointer;
130 | height: 1.2em;
131 | width: 2em;
132 | display: none;
133 | }
134 |
135 | .balance-match {
136 | color: #3a3;
137 | font-weight: bold;
138 | }
139 | .balance-mismatch {
140 | color: #900;
141 | font-weight: bold;
142 | }
143 | .balance-unknown {
144 | color: #cc0;
145 | font-weight: bold;
146 | font-size: 90%;
147 | position: relative;
148 | top: -.05em;
149 | left: .2em;
150 | }
151 |
152 | .note, .warning {
153 | max-width: 600px;
154 | }
155 | .warning {
156 | color: #900;
157 | }
158 |
159 | .clear {
160 | clear: both;
161 | }
162 |
163 | #form-links {
164 | display: none;
165 | }
166 |
167 | #form-links a {
168 | display: block;
169 | float: left;
170 | padding: 5px;
171 | margin-right: 5px;
172 | }
173 | #form-links a.active {
174 | background-color: #ccc;
175 | }
176 |
177 | table.form-table th {
178 | text-align: left;
179 | font-weight: normal;
180 | }
181 | table.form-table select {
182 | max-width: 12em;
183 | }
184 |
185 | #form-filters tr.field-opposing_account {
186 | display: none;
187 | }
188 | #form-filters tr.field-opposing_accounts ul, #select-accounts {
189 | list-style: none outside;
190 | padding: 0;
191 | margin: 0;
192 | max-height: 20em;
193 | overflow-y: scroll;
194 | }
195 | #select-accounts li:hover {
196 | background: #ddd;
197 | }
198 |
199 | #clear-filters {
200 | display: none;
201 | }
202 |
203 | #batch-modify-fields th {
204 | font-size: 85%;
205 | padding: .25em 0;
206 | }
207 | #batch-modify-fields .info {
208 | color: #777;
209 | }
210 | #batch-modify-fields .info .count {
211 | font-weight: bold;
212 | }
213 |
214 | .auto-resize {
215 | /* http://stackoverflow.com/a/3029434/106302 */
216 | max-width: 100%;
217 | max-height: 100%;
218 | }
219 | .close-window {
220 | position: absolute;
221 | top: 10px;
222 | right: 10px;
223 | color: #bbb;
224 | padding: 2px 7px 3px 6px;
225 | }
226 | .close-window:hover {
227 | color: #fff;
228 | background: #bbb;
229 | }
230 |
231 | .loading i {
232 | font-style : normal;
233 |
234 | -webkit-animation : blink 1.5s steps(1) infinite;
235 | -moz-animation : blink 1.5s steps(1) infinite;
236 | animation : blink 1.5s steps(1) infinite;
237 | }
238 | .loading i:nth-child(1) {
239 | -webkit-animation-delay : -0.5s;
240 | -moz-animation-delay : -0.5s;
241 | animation-delay : -0.5s;
242 | }
243 | .loading i:nth-child(2) {
244 | -webkit-animation-delay : -0.25s;
245 | -moz-animation-delay : -0.25s;
246 | animation-delay : -0.25s;
247 | }
248 | .loading i:nth-child(3) {
249 | -webkit-animation-delay : 0s;
250 | -moz-animation-delay : 0s;
251 | animation-delay : 0s;
252 | }
253 |
254 | @-webkit-keyframes blink {
255 | 0% { opacity : 1.0; }
256 | 50% { opacity : 0.0; }
257 | 100% { opacity : 1.0; }
258 | }
259 | @-moz-keyframes blink {
260 | 0% { opacity : 1.0; }
261 | 50% { opacity : 0.0; }
262 | 100% { opacity : 1.0; }
263 | }
264 | @keyframes blink {
265 | 0% { opacity : 1.0; }
266 | 50% { opacity : 0.0; }
267 | 100% { opacity : 1.0; }
268 | }
269 |
270 | .chart-block h3 {
271 | margin: 0 0 .3em 0;
272 | }
273 |
274 | #charts-container {
275 | display: none;
276 | }
277 | #chart-controls {
278 | display: none;
279 | }
280 | #expenses-reorder, #income-reorder {
281 | display: none;
282 | }
283 | .chart-period.active {
284 | cursor: default;
285 | color: black;
286 | text-decoration: none;
287 | }
288 |
--------------------------------------------------------------------------------
/money_views/forms.py:
--------------------------------------------------------------------------------
1 | import itertools
2 |
3 | from django import forms
4 | from django.db import connections
5 |
6 | from gnucash_data.models import Account
7 |
8 |
9 | DEFAULT_FILTER_ACCOUNT_CHOICES = [('all', '(all)')]
10 | DEFAULT_MODIFY_ACCOUNT_CHOICES = [('', '(no change)'), ('DELETE', '(DELETE)')]
11 | DEFAULT_NEW_TRANSCTION_ACCOUNT_CHOICES = [('', '(Imbalance-USD)')]
12 |
13 |
14 | class FilterForm(forms.Form):
15 | def __init__(self, choices, *args, **kwargs):
16 | super(FilterForm, self).__init__(*args, **kwargs)
17 |
18 | self.fields['opposing_accounts'] = forms.MultipleChoiceField(
19 | required=False, choices=choices.filter_account_choices,
20 | widget=forms.CheckboxSelectMultiple)
21 |
22 | self.fields['tx_desc'] = forms.CharField(
23 | required=False, initial='', label='Description')
24 | self.fields['min_date'] = forms.DateField(
25 | required=False, initial='')
26 | self.fields['max_date'] = forms.DateField(
27 | required=False, initial='')
28 | self.fields['min_amount'] = forms.DecimalField(
29 | required=False, initial='')
30 | self.fields['max_amount'] = forms.DecimalField(
31 | required=False, initial='')
32 |
33 |
34 | class ModifyForm(FilterForm):
35 | def __init__(self, choices, *args, **kwargs):
36 | super(ModifyForm, self).__init__(choices, *args, **kwargs)
37 |
38 | self.fields['opposing_accounts'].widget = forms.MultipleHiddenInput()
39 | self.fields['min_date'].widget = forms.HiddenInput()
40 | self.fields['max_date'].widget = forms.HiddenInput()
41 | for a in ['readonly', 'class']:
42 | for f in ['tx_desc', 'min_amount', 'max_amount']:
43 | self.fields[f].widget.attrs[a] = 'readonly'
44 |
45 | self.fields['change_opposing_account'] = forms.ChoiceField(
46 | required=False, initial='', choices=choices.modify_account_choices)
47 |
48 | self.fields['save_rule'] = forms.BooleanField(
49 | required=False, initial=True,
50 | label='Save rule for future transactions')
51 |
52 |
53 | class HiddenFilterForm(FilterForm):
54 | def __init__(self, choices, *args, **kwargs):
55 | super(HiddenFilterForm, self).__init__(choices, *args, **kwargs)
56 |
57 | self.fields['opposing_accounts'].widget = forms.MultipleHiddenInput()
58 | self.fields['opposing_accounts'].choices = choices.filter_all_account_choices
59 |
60 | self.fields['tx_desc'].widget = forms.HiddenInput()
61 | self.fields['min_date'].widget = forms.HiddenInput()
62 | self.fields['max_date'].widget = forms.HiddenInput()
63 | self.fields['min_amount'].widget = forms.HiddenInput()
64 | self.fields['max_amount'].widget = forms.HiddenInput()
65 |
66 |
67 | class BatchModifyForm(forms.Form):
68 | def __init__(self, choices, merchants, *args, **kwargs):
69 | super(BatchModifyForm, self).__init__(*args, **kwargs)
70 |
71 | for merchant in merchants:
72 | field = forms.ChoiceField(
73 | required=False, initial='', choices=choices.modify_account_choices)
74 | field.merchant_info = merchant
75 | self.fields[merchant['html_name']] = field
76 | self.fields[merchant['ref_html_name']] = forms.CharField(
77 | initial=merchant['description'], widget=forms.HiddenInput)
78 |
79 |
80 | class NewTransactionForm(forms.Form):
81 | def __init__(self, choices, *args, **kwargs):
82 | super(NewTransactionForm, self).__init__(*args, **kwargs)
83 |
84 | self.fields['tx_desc'] = forms.CharField(
85 | required=True, initial='', label='Description')
86 | self.fields['memo'] = forms.CharField(
87 | required=False, initial='', label='Memo')
88 | self.fields['post_date'] = forms.DateField(
89 | required=True, initial='')
90 | self.fields['opposing_account'] = forms.ChoiceField(
91 | required=False, choices=choices.new_transaction_account_choices)
92 | self.fields['amount'] = forms.DecimalField(
93 | required=True, initial='')
94 |
95 |
96 | class AccountChoices():
97 | def __init__(self, accounts, **kwargs):
98 | cursor = connections['gnucash'].cursor()
99 | sql = '''
100 | SELECT a.guid,
101 |
102 | CASE
103 | WHEN s.account_guid IS NULL THEN 0
104 | ELSE 1
105 | END AS is_present,
106 |
107 | a.placeholder
108 |
109 | FROM accounts a
110 |
111 | LEFT JOIN (
112 | SELECT s2.account_guid,
113 | MAX(t.post_date) post_date
114 |
115 | FROM splits s
116 |
117 | INNER JOIN transactions t
118 | ON s.tx_guid = t.guid
119 |
120 | INNER JOIN splits s2
121 | ON s2.tx_guid = t.guid
122 |
123 | WHERE s.account_guid IN (%s)
124 | AND s2.account_guid NOT IN (%s)
125 |
126 | GROUP BY s2.account_guid
127 | ) s
128 | ON s.account_guid = a.guid
129 |
130 | WHERE a.account_type <> 'ROOT'
131 | '''
132 |
133 | params = ', '.join('%s' for a in accounts)
134 | sql = sql % (params, params)
135 | account_guids = [a.guid for a in accounts]
136 | cursor.execute(sql, account_guids + account_guids)
137 |
138 | filter_all_account_choices = []
139 | filter_account_choices = []
140 | modify_account_choices = []
141 | new_transaction_account_choices = []
142 |
143 | exclude_guids = account_guids
144 | if 'exclude' in kwargs:
145 | exclude_guids.append(kwargs['exclude'].guid)
146 |
147 | for row in cursor.fetchall():
148 | guid = row[0]
149 | path = Account.get(guid).path
150 | is_present = row[1]
151 | placeholder = row[2]
152 | filter_all_account_choices.append((guid, path))
153 | if is_present:
154 | filter_account_choices.append((guid, path))
155 | if not placeholder:
156 | if guid not in exclude_guids:
157 | modify_account_choices.append((guid, path))
158 | new_transaction_account_choices.append((guid, path))
159 |
160 | get_account_path = lambda a: a[1]
161 | filter_account_choices.sort(key=get_account_path)
162 | modify_account_choices.sort(key=get_account_path)
163 | new_transaction_account_choices.sort(key=get_account_path)
164 |
165 | self.filter_all_account_choices = \
166 | DEFAULT_FILTER_ACCOUNT_CHOICES + filter_all_account_choices
167 | self.filter_account_choices = \
168 | DEFAULT_FILTER_ACCOUNT_CHOICES + filter_account_choices
169 | self.modify_account_choices = \
170 | DEFAULT_MODIFY_ACCOUNT_CHOICES + modify_account_choices
171 | self.new_transaction_account_choices = \
172 | DEFAULT_NEW_TRANSCTION_ACCOUNT_CHOICES + new_transaction_account_choices
173 |
--------------------------------------------------------------------------------
/gnucash_scripts/import_json_file.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python
2 |
3 | import json
4 | import os
5 | import sys
6 | from datetime import datetime
7 | from dateutil import parser as dateparser
8 | from decimal import Decimal
9 |
10 | from gnucash import Session, Transaction, Split, GncNumeric
11 |
12 | from common import *
13 |
14 | # Add Django project directory (parent directory of current file) to path
15 | # This shouldn't be so hard...
16 | sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), os.path.pardir)))
17 |
18 | # Django setup
19 | # from http://www.b-list.org/weblog/2007/sep/22/standalone-django-scripts/
20 | # This script needs to be callable from the command line, but it also needs
21 | # to know about the Django project's database and other settings.
22 | from django.core.management import setup_environ
23 | import settings # only works due to path fuckery above
24 | setup_environ(settings)
25 |
26 | from gnucash_data import models
27 | from utils.AsciiDammit import asciiDammit
28 |
29 | # make sure we can begin a session
30 | models.Lock.check_can_obtain()
31 |
32 | # begin GnuCash API session
33 | session = Session(settings.GNUCASH_CONN_STRING)
34 |
35 |
36 | debug = False
37 |
38 | def debug_print(s):
39 | if debug:
40 | print s
41 |
42 |
43 | def get_transaction_string(t):
44 | memo = txinfo.get('memo', '')
45 | if memo:
46 | memo = ' / ' + memo
47 | return "'%s%s' on %s for %s" \
48 | % (t['description'], memo,
49 | t['date'].strftime('%Y-%m-%d'),
50 | t['amount'])
51 |
52 | try:
53 |
54 | book = session.book
55 | USD = book.get_table().lookup('ISO4217', 'USD')
56 |
57 | root = book.get_root_account()
58 | imbalance = get_account_by_path(root, 'Imbalance-USD')
59 |
60 | for fn in sys.argv[1:]:
61 | if fn.upper() == 'DEBUG':
62 | debug = True
63 | continue
64 |
65 | f = open(fn, 'r')
66 | data = json.load(f)
67 | for bank in data:
68 | msg = bank.get('error', bank.get('status', ''))
69 | if msg:
70 | debug_print('Skipping bank %s: %s' % (
71 | bank['bank'], msg))
72 | continue
73 |
74 | debug_print('Processing bank %s' % bank['bank'])
75 |
76 | for acct_data in bank['data']:
77 | msg = acct_data.get('error', acct_data.get('status', ''))
78 | if msg:
79 | debug_print('Skipping account %s: %s' % (
80 | acct_data['account']['path'], msg))
81 | continue
82 |
83 | debug_print('Processing account %s' % acct_data['account']['path'])
84 |
85 | acct = get_account_by_path(root, acct_data['account']['path'])
86 | acct_guid = acct.GetGUID().to_string()
87 |
88 | rules = [ra.rule for ra in models.RuleAccount.objects
89 | .filter(account_guid=acct_guid).select_related().distinct('rule__id')]
90 |
91 | imported_transactions = []
92 |
93 | balance = acct_data['balances'].get('actual', None)
94 | if balance:
95 | balance = Decimal(balance)
96 |
97 | for txinfo in acct_data['transactions']:
98 | txinfo['date'] = dateparser.parse(txinfo['date']).date() # treats as MM/DD/YYYY (good)
99 | txinfo['amount'] = Decimal(txinfo['amount'])
100 | txinfo['description'] = asciiDammit(txinfo.get('description', ''))
101 |
102 | if txinfo.has_key('memo'):
103 | txinfo['memo'] = asciiDammit(txinfo['memo'])
104 |
105 | if models.ImportedTransaction.objects.filter(source_tx_id=txinfo['sourceId']).count():
106 | debug_print('Not adding duplicate transaction %s'
107 | % get_transaction_string(txinfo))
108 | else:
109 | opposing_acct = None
110 | opposing_acct_path = None
111 |
112 | ignore_this_transaction = False
113 | tx_guid = None
114 |
115 | for rule in rules:
116 | if rule.is_match(txinfo['description'], txinfo['amount']):
117 | if rule.opposing_account_guid is None:
118 | ignore_this_transaction = True
119 | else:
120 | opposing_acct = get_account_by_guid(root, rule.opposing_account_guid)
121 | opposing_acct_path = get_account_path(opposing_acct)
122 |
123 | debug_print('Transaction %s matches rule %i (%s)'
124 | % (get_transaction_string(txinfo), rule.id, opposing_acct_path))
125 |
126 | if ignore_this_transaction:
127 |
128 | debug_print('Ignoring transaction %s' % get_transaction_string(txinfo))
129 |
130 | else:
131 |
132 | debug_print('Adding transaction %s' % get_transaction_string(txinfo))
133 | gnc_amount = decimal_to_gnc_numeric(txinfo['amount'])
134 |
135 | # From example script 'test_imbalance_transaction.py'
136 | trans = Transaction(book)
137 | trans.BeginEdit()
138 | trans.SetCurrency(USD)
139 | trans.SetDescription(str(txinfo['description']))
140 | trans.SetDate(
141 | txinfo['date'].day,
142 | txinfo['date'].month,
143 | txinfo['date'].year)
144 |
145 | split1 = Split(book)
146 | split1.SetParent(trans)
147 | split1.SetAccount(acct)
148 | if txinfo.has_key('memo'):
149 | split1.SetMemo(str(txinfo['memo']))
150 | # The docs say both of these are needed:
151 | # http://svn.gnucash.org/docs/HEAD/group__Transaction.html
152 | split1.SetValue(gnc_amount)
153 | split1.SetAmount(gnc_amount)
154 | split1.SetReconcile('c')
155 |
156 | if opposing_acct != None:
157 | debug_print('Categorizing transaction %s as %s'
158 | % (get_transaction_string(txinfo), opposing_acct_path))
159 | split2 = Split(book)
160 | split2.SetParent(trans)
161 | split2.SetAccount(opposing_acct)
162 | split2.SetValue(gnc_amount.neg())
163 | split2.SetAmount(gnc_amount.neg())
164 | split2.SetReconcile('c')
165 |
166 | trans.CommitEdit()
167 | tx_guid = trans.GetGUID().to_string()
168 |
169 | tx = models.ImportedTransaction()
170 | tx.account_guid = acct_guid
171 | tx.tx_guid = tx_guid
172 | tx.source_tx_id = txinfo['sourceId']
173 | imported_transactions.append(tx)
174 |
175 | u = models.Update()
176 | u.account_guid = acct_guid
177 | u.updated = datetime.utcnow()
178 | u.balance = balance
179 | u.save()
180 |
181 | for tx in imported_transactions:
182 | tx.update = u
183 | tx.save()
184 |
185 | f.close()
186 |
187 | finally:
188 | debug_print('Ending GnuCash session')
189 | session.end()
190 | debug_print('Destroying GnuCash session')
191 | session.destroy()
192 | debug_print('Destroyed GnuCash session')
193 |
194 | debug_print('Done importing JSON file(s)')
195 |
--------------------------------------------------------------------------------
/money_templates/static/txactions_chart.js:
--------------------------------------------------------------------------------
1 | var charts = {
2 | expenses : {},
3 | income : {}
4 | },
5 | spans = {};
6 |
7 | spans.day = 24*60*60*1000;
8 | spans.week = 7 * spans.day;
9 |
10 | $(function() {
11 | $('#load-charts').on('click', function() {
12 | $('#load-charts-container').hide();
13 | $('#charts-container').show();
14 | loadCharts();
15 | });
16 | });
17 |
18 | function loadCharts() {
19 | var data = $.extend({
20 | accounts : currentAccountsKey
21 | }, queryParams);
22 |
23 | $.ajax({
24 | url : apiFunctionUrls['get_transactions'],
25 | type : 'GET',
26 | data : data,
27 | cache : false,
28 | success : function(d) {
29 | drawSplitsChart(charts.expenses, {
30 | splits : d.splits,
31 | selector : '#expenses-chart',
32 | reorderSelector : '#expenses-reorder',
33 | filter : function(amount) { return amount < 0; }
34 | });
35 | drawSplitsChart(charts.income, {
36 | splits : d.splits,
37 | selector : '#income-chart',
38 | reorderSelector : '#income-reorder',
39 | filter : function(amount) { return amount > 0; }
40 | });
41 | },
42 | error : function(xhr, status, e) {
43 | $('#expenses-chart, #income-chart').html(
44 | 'Error loading: '
45 | + (e.message || xhr.status)
46 | + ' (' + status + ')');
47 | },
48 | complete : function(xhr, status) {
49 | // nothing to do here I guess
50 | }
51 | });
52 |
53 | $('#chart-controls').on('click', '.chart-period', function() {
54 | var top = $(window).scrollTop();
55 | drawSplitsChart(charts.expenses, {
56 | period : $(this).data('period')
57 | });
58 | drawSplitsChart(charts.income, {
59 | period : $(this).data('period')
60 | });
61 | $(window).scrollTop(top);
62 | return false;
63 | });
64 | }
65 |
66 | function getPeriodForDate(chart, d) {
67 | var date = moment(d);
68 |
69 | switch (chart.period) {
70 | case 'weekly':
71 | return Math.floor((date - chart.firstPeriod) / spans.week);
72 | case 'biweekly':
73 | return Math.floor((date - chart.firstPeriod) / spans.week / 2);
74 | case 'monthly':
75 | return date.year() * 12 + date.month() - chart.firstPeriodYearMonth;
76 | }
77 | }
78 |
79 | function getDateForPeriod(chart, p) {
80 | switch (chart.period) {
81 | case 'weekly':
82 | return moment(chart.firstPeriod + p * spans.week);
83 | case 'biweekly':
84 | return moment(chart.firstPeriod + p * 2 * spans.week);
85 | case 'monthly':
86 | var yearMonth = chart.firstPeriodYearMonth + p;
87 | return moment({
88 | year : Math.floor((yearMonth - 1) / 12),
89 | month : (yearMonth - 1) % 12 + 1 - 1, // js starts from 0
90 | day : 1
91 | }).add(1, 'month').endOf('month'); // off by one somewhere...
92 | }
93 | }
94 |
95 | function drawSplitsChart(chart, config) {
96 | $.extend(chart, config || {});
97 |
98 | if (chart.c3) {
99 | chart.c3.destroy();
100 | chart.c3 = null;
101 | }
102 |
103 | chart.minDate = Infinity;
104 | chart.maxDate = -Infinity;
105 | chart.splits.forEach(function(s) {
106 | chart.minDate = Math.min(chart.minDate, s.post_date);
107 | chart.maxDate = Math.max(chart.maxDate, s.post_date);
108 | });
109 |
110 | if (!chart.period) {
111 | var begin = moment(chart.minDate);
112 | if (chart.maxDate - chart.minDate <= 8 * spans.week) {
113 | chart.period = 'weekly';
114 | begin = begin.startOf('week');
115 | } else if (chart.maxDate - chart.minDate <= 16 * spans.week) {
116 | chart.period = 'biweekly';
117 | begin = begin.startOf('week');
118 | if (begin.isoWeek() % 2 == 0) {
119 | begin = begin.isoWeek(begin.isoWeek() - 1);
120 | }
121 | } else {
122 | chart.period = 'monthly';
123 | begin = begin.startOf('month');
124 | chart.firstPeriodYearMonth = begin.year() * 12 + begin.month();
125 | }
126 | chart.firstPeriod = +begin;
127 | }
128 |
129 | d3.selectAll('#chart-controls .chart-period')
130 | .classed('active', function() {
131 | return $(this).data('period') == chart.period;
132 | });
133 |
134 | if (!chart.filter) {
135 | chart.filter = function(amount) { return amount > 0 };
136 | }
137 |
138 | chart.maxPeriodNum = getPeriodForDate(chart, chart.maxDate);
139 |
140 | chart.accounts = {};
141 | chart.data = [];
142 | chart.xValues = ['x'];
143 |
144 | var accountIndex = 0;
145 |
146 | function addAccount(account) {
147 | if (!chart.accounts[account.guid]) {
148 | account.order = accountIndex++;
149 | chart.accounts[account.guid] = account;
150 | chart.data.push([account.friendly_name].concat(
151 | Array.apply(null, new Array(chart.maxPeriodNum + 1))
152 | .map(function() {
153 | return 0;
154 | })
155 | ));
156 | }
157 | }
158 |
159 | for (var i = 0; i <= chart.maxPeriodNum; i++) {
160 | chart.xValues.push(getDateForPeriod(chart, i).format('YYYY-MM-DD'));
161 | }
162 |
163 | (chart.order || []).forEach(addAccount);
164 |
165 | chart.splits.forEach(function(s) {
166 | s.amount = Number(s.amount);
167 | if (chart.filter(s.amount)) {
168 | addAccount(s.opposing_account);
169 | var account = chart.accounts[s.opposing_account.guid];
170 | if (account) {
171 | var series = chart.data[account.order],
172 | period = getPeriodForDate(chart, s.post_date);
173 | series[period + 1] = (series[period + 1] || 0) + Math.abs(s.amount);
174 | }
175 | }
176 | });
177 |
178 | $(chart.reorderSelector).html(chart.data.map(function(series) {
179 | return series[0]; // account friendly name
180 | }).join(', '));
181 |
182 | $('#chart-controls').show();
183 |
184 | chart.c3 = c3.generate({
185 | bindto : chart.selector,
186 | data : {
187 | x : 'x',
188 | columns : [chart.xValues].concat(chart.data),
189 | type : 'area',
190 | groups : [chart.data.map(function(series) { return series[0]; })],
191 | order : null
192 | },
193 | axis : {
194 | x : {
195 | type : 'timeseries',
196 | tick : {
197 | format : '%Y-%m-%d'
198 | }
199 | }
200 | }
201 | });
202 | }
203 |
--------------------------------------------------------------------------------
/settings.example.py:
--------------------------------------------------------------------------------
1 | # Django settings for money project.
2 |
3 | # NOTE:
4 | # Copy settings.example.py to settings.py and change all lines containing ***
5 | # (they will cause Python syntax errors if not modified).
6 |
7 | import os
8 |
9 | ALWAYS_DEBUG = True # for contrib.staticfiles support
10 | RUNNING_WSGI = (os.environ.get('RUNNING_WSGI') == 'true')
11 |
12 | SHOW_DEBUG_TOOLBAR = RUNNING_WSGI
13 | SHOW_DEBUG_TOOLBAR = False
14 |
15 | DEBUG = (ALWAYS_DEBUG or not RUNNING_WSGI)
16 | TEMPLATE_DEBUG = DEBUG
17 |
18 | ADMINS = (
19 | # ('Your Name', 'your_email@domain.com'),
20 | )
21 |
22 | MANAGERS = ADMINS
23 |
24 | DATABASES = {
25 | # This is the gnucash_django database connection. It holds database tables that are NOT used by GnuCash.
26 | # (They can't be stored in the same database because GnuCash will delete unrecognized tables.)
27 | 'default': {
28 | 'ENGINE': 'django.db.backends.mysql', # Add 'postgresql_psycopg2', 'postgresql', 'mysql', 'sqlite3' or 'oracle'.
29 | 'NAME': 'gnucash_django', # Or path to database file if using sqlite3.
30 | 'USER': (***FILL THIS IN***), # Not used with sqlite3.
31 | 'PASSWORD': (***FILL THIS IN***), # Not used with sqlite3.
32 | 'HOST': '', # Set to empty string for localhost. Not used with sqlite3.
33 | 'PORT': '', # Set to empty string for default. Not used with sqlite3.
34 | },
35 |
36 | # This is the GnuCash database connection. It holds the database tables that are used by GnuCash. The
37 | # application will read the transactions and other data in these tables, and perform limited modifications.
38 | 'gnucash': {
39 | 'ENGINE': 'django.db.backends.mysql', # Add 'postgresql_psycopg2', 'postgresql', 'mysql', 'sqlite3' or 'oracle'.
40 | 'NAME': 'gnucash', # Or path to database file if using sqlite3.
41 | 'USER': (***FILL THIS IN***), # Not used with sqlite3.
42 | 'PASSWORD': (***FILL THIS IN***), # Not used with sqlite3.
43 | 'HOST': '', # Set to empty string for localhost. Not used with sqlite3.
44 | 'PORT': '', # Set to empty string for default. Not used with sqlite3.
45 | }
46 | }
47 |
48 | DATABASE_ROUTERS = ['gnucash_data.gnucash_db_router.GnucashDataRouter']
49 |
50 | GNUCASH_CONN_STRING = 'mysql://USER:PASSWORD@localhost/gnucash' (***CHANGE THIS***)
51 |
52 | # Local time zone for this installation. Choices can be found here:
53 | # http://en.wikipedia.org/wiki/List_of_tz_zones_by_name
54 | # although not all choices may be available on all operating systems.
55 | # On Unix systems, a value of None will cause Django to use the same
56 | # timezone as the operating system.
57 | # If running in a Windows environment this must be set to the same as your
58 | # system time zone.
59 | TIME_ZONE = 'America/New_York'
60 |
61 | # Language code for this installation. All choices can be found here:
62 | # http://www.i18nguy.com/unicode/language-identifiers.html
63 | LANGUAGE_CODE = 'en-us'
64 |
65 | SITE_ID = 1
66 |
67 | # If you set this to False, Django will make some optimizations so as not
68 | # to load the internationalization machinery.
69 | USE_I18N = True
70 |
71 | # If you set this to False, Django will not format dates, numbers and
72 | # calendars according to the current locale
73 | USE_L10N = True
74 |
75 | # Absolute path to the directory that holds media.
76 | # Example: "/home/media/media.lawrence.com/"
77 | MEDIA_ROOT = ''
78 |
79 | # URL that handles the media served from MEDIA_ROOT. Make sure to use a
80 | # trailing slash if there is a path component (optional in other cases).
81 | # Examples: "http://media.lawrence.com", "http://example.com/media/"
82 | MEDIA_URL = ''
83 |
84 |
85 | if RUNNING_WSGI:
86 | BASE_URL = os.environ['WSGI_SCRIPT_NAME'].rstrip('/')
87 | else:
88 | BASE_URL = ''
89 |
90 | STATIC_URL = BASE_URL + '/static/'
91 |
92 | # URL prefix for admin media -- CSS, JavaScript and images. Make sure to use a
93 | # trailing slash.
94 | # Examples: "http://foo.com/media/", "/media/".
95 | ADMIN_MEDIA_PREFIX = STATIC_URL + 'admin/'
96 |
97 | # Make this unique, and don't share it with anybody.
98 | SECRET_KEY = (***FILL THIS IN***)
99 |
100 | LOGIN_URL = BASE_URL + '/accounts/login/'
101 |
102 | # List of callables that know how to import templates from various sources.
103 | TEMPLATE_LOADERS = (
104 | 'django.template.loaders.filesystem.Loader',
105 | 'django.template.loaders.app_directories.Loader',
106 | # 'django.template.loaders.eggs.Loader',
107 | )
108 |
109 | MIDDLEWARE_CLASSES = (
110 | 'django.middleware.common.CommonMiddleware',
111 | 'django.contrib.sessions.middleware.SessionMiddleware',
112 | 'django.middleware.csrf.CsrfViewMiddleware',
113 | 'django.contrib.auth.middleware.AuthenticationMiddleware',
114 | 'django.contrib.messages.middleware.MessageMiddleware',
115 |
116 | 'middleware.middleware.ClearCachesMiddleware',
117 | )
118 |
119 | ROOT_URLCONF = 'urls'
120 |
121 | TEMPLATE_DIRS = (
122 | # Put strings here, like "/home/html/django_templates" or "C:/www/django/templates".
123 | # Always use forward slashes, even on Windows.
124 | # Don't forget to use absolute paths, not relative paths.
125 | )
126 |
127 | INSTALLED_APPS = (
128 | 'django.contrib.auth',
129 | 'django.contrib.contenttypes',
130 | 'django.contrib.sessions',
131 | 'django.contrib.sites',
132 | 'django.contrib.messages',
133 | # Uncomment the next line to enable the admin:
134 | 'django.contrib.admin',
135 | # Uncomment the next line to enable admin documentation:
136 | 'django.contrib.admindocs',
137 |
138 | 'django.contrib.staticfiles', # only available in Django 1.3+
139 |
140 | 'gnucash_data',
141 | 'gnucash_scripts',
142 | 'utils',
143 | 'money_templates',
144 | 'money_views',
145 | )
146 |
147 | TEMPLATE_CONTEXT_PROCESSORS = (
148 | 'django.contrib.auth.context_processors.auth',
149 | 'django.core.context_processors.debug',
150 | 'django.core.context_processors.i18n',
151 | 'django.core.context_processors.media',
152 | 'django.core.context_processors.static',
153 | 'django.contrib.messages.context_processors.messages',
154 |
155 | 'django.core.context_processors.request',
156 | )
157 |
158 | ACCOUNTS_LIST = [
159 | (***GNUCASH ACCOUNT PATH***),
160 | 'Assets:Current Assets:BANK ACCOUNT NAME',
161 | ]
162 |
163 | NUM_MERCHANTS_BATCH_CATEGORIZE = 50
164 | NUM_TRANSACTIONS_PER_PAGE = 50
165 |
166 | # This feature requires a little more setup. Namely, the GnuCash API must be
167 | # properly set up (which generally requires building GnuCash from source) and
168 | # it must be made available to the application's virtualenv. Also, the user
169 | # running the application must have write access to the gnucash_api_home/
170 | # directory.
171 | ENABLE_ADD_TRANSACTIONS = False
172 |
173 |
174 | if SHOW_DEBUG_TOOLBAR:
175 | MIDDLEWARE_CLASSES += (
176 | 'debug_toolbar.middleware.DebugToolbarMiddleware',
177 | )
178 | INSTALLED_APPS += (
179 | 'debug_toolbar',
180 | )
181 | INTERNAL_IPS = ('127.0.0.1')
182 |
--------------------------------------------------------------------------------
/gnucash_scripts/import_qif_file.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python
2 |
3 | import re
4 | import os
5 | import sys
6 | from datetime import datetime
7 | from dateutil import parser as dateparser
8 | from decimal import Decimal
9 |
10 | from gnucash import Session, Transaction, Split, GncNumeric
11 |
12 | from common import *
13 |
14 | # Add Django project directory (parent directory of current file) to path
15 | # This shouldn't be so hard...
16 | sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), os.path.pardir)))
17 |
18 | # Django setup
19 | # from http://www.b-list.org/weblog/2007/sep/22/standalone-django-scripts/
20 | # This script needs to be callable from the command line, but it also needs
21 | # to know about the Django project's database and other settings.
22 | from django.core.management import setup_environ
23 | import settings # only works due to path fuckery above
24 | setup_environ(settings)
25 |
26 | from gnucash_data import models
27 |
28 | # make sure we can begin a session
29 | models.Lock.check_can_obtain()
30 |
31 | # begin GnuCash API session
32 | session = Session(settings.GNUCASH_CONN_STRING)
33 |
34 |
35 | debug = False
36 |
37 | def debug_print(s):
38 | if debug:
39 | print s
40 |
41 |
42 | def get_id_string(s):
43 | if models.Transaction.is_id_string(s):
44 | return s
45 | else:
46 | return None
47 |
48 | def make_transaction_id(t):
49 | memo = txinfo.get('memo', '')
50 | id = get_id_string(memo)
51 | if id:
52 | return id
53 | id = get_id_string(t['description'])
54 | if id:
55 | return id
56 | return '%s|%s|%s|%s' % (
57 | t['date'].strftime('%Y-%m-%d'),
58 | t['description'],
59 | memo,
60 | t['amount'])
61 |
62 | def get_transaction_string(t):
63 | memo = txinfo.get('memo', '')
64 | if memo:
65 | memo = ' / ' + memo
66 | return "'%s%s' on %s for %s" \
67 | % (t['description'], memo,
68 | t['date'].strftime('%Y-%m-%d'),
69 | t['amount'])
70 |
71 | try:
72 |
73 | book = session.book
74 | USD = book.get_table().lookup('ISO4217', 'USD')
75 |
76 | root = book.get_root_account()
77 | acct = get_account_by_path(root, sys.argv[1])
78 | acct_guid = acct.GetGUID().to_string()
79 | imbalance = get_account_by_path(root, 'Imbalance-USD')
80 |
81 | rules = [ra.rule for ra in models.RuleAccount.objects
82 | .filter(account_guid=acct_guid).select_related().distinct('rule__id')]
83 |
84 | updated = False
85 | imported_transactions = []
86 |
87 | for fn in sys.argv[2:]:
88 | if fn.upper() == 'DEBUG':
89 | debug = True
90 | continue
91 |
92 | balance = None
93 | try:
94 | bal = open(fn + '.balance.txt', 'r')
95 | for line in bal:
96 | line = line.rstrip()
97 | if line:
98 | balance = Decimal(line)
99 | except:
100 | pass
101 |
102 | qif = open(fn, 'r')
103 | txinfo = {}
104 | for line in qif:
105 | line = line.rstrip() # remove newline and any other trailing whitespace
106 | if line <> '':
107 | marker = line[0]
108 | value = line[1:]
109 |
110 | if marker == 'D':
111 | txinfo['date'] = dateparser.parse(value).date() # treats as MM/DD/YYYY (good)
112 |
113 | elif marker == 'P':
114 | txinfo['description'] = value
115 |
116 | elif marker == 'T':
117 | txinfo['amount'] = Decimal(value.replace(',', ''))
118 |
119 | elif marker == 'M':
120 | txinfo['memo'] = value
121 |
122 | elif marker == '^' and txinfo <> {}:
123 | # End of transaction - add it if it's not a duplicate
124 | updated = True
125 |
126 | this_id = make_transaction_id(txinfo)
127 |
128 | if models.ImportedTransaction.objects.filter(source_tx_id=this_id).count():
129 | debug_print('Not adding duplicate transaction %s'
130 | % get_transaction_string(txinfo))
131 | else:
132 | opposing_acct = None
133 | opposing_acct_path = None
134 |
135 | ignore_this_transaction = False
136 | tx_guid = None
137 |
138 | for rule in rules:
139 | if rule.is_match(txinfo['description'], txinfo['amount']):
140 | if rule.opposing_account_guid is None:
141 | ignore_this_transaction = True
142 | else:
143 | opposing_acct = get_account_by_guid(root, rule.opposing_account_guid)
144 | opposing_acct_path = get_account_path(opposing_acct)
145 |
146 | debug_print('Transaction %s matches rule %i (%s)'
147 | % (get_transaction_string(txinfo), rule.id, opposing_acct_path))
148 |
149 | if ignore_this_transaction:
150 |
151 | debug_print('Ignoring transaction %s' % get_transaction_string(txinfo))
152 |
153 | else:
154 |
155 | debug_print('Adding transaction %s' % get_transaction_string(txinfo))
156 | gnc_amount = decimal_to_gnc_numeric(txinfo['amount'])
157 |
158 | # From example script 'test_imbalance_transaction.py'
159 | trans = Transaction(book)
160 | trans.BeginEdit()
161 | trans.SetCurrency(USD)
162 | trans.SetDescription(txinfo['description'])
163 | trans.SetDate(
164 | txinfo['date'].day,
165 | txinfo['date'].month,
166 | txinfo['date'].year)
167 |
168 | split1 = Split(book)
169 | split1.SetParent(trans)
170 | split1.SetAccount(acct)
171 | if txinfo.has_key('memo'):
172 | split1.SetMemo(txinfo['memo'])
173 | # The docs say both of these are needed:
174 | # http://svn.gnucash.org/docs/HEAD/group__Transaction.html
175 | split1.SetValue(gnc_amount)
176 | split1.SetAmount(gnc_amount)
177 | split1.SetReconcile('c')
178 |
179 | if opposing_acct != None:
180 | debug_print('Categorizing transaction %s as %s'
181 | % (get_transaction_string(txinfo), opposing_acct_path))
182 | split2 = Split(book)
183 | split2.SetParent(trans)
184 | split2.SetAccount(opposing_acct)
185 | split2.SetValue(gnc_amount.neg())
186 | split2.SetAmount(gnc_amount.neg())
187 | split2.SetReconcile('c')
188 |
189 | trans.CommitEdit()
190 | tx_guid = trans.GetGUID().to_string()
191 |
192 | txinfo = {}
193 |
194 | tx = models.ImportedTransaction()
195 | tx.account_guid = acct_guid
196 | tx.tx_guid = tx_guid
197 | tx.source_tx_id = this_id
198 | imported_transactions.append(tx)
199 |
200 | qif.close()
201 |
202 | if updated:
203 | u = models.Update()
204 | u.account_guid = acct_guid
205 | u.updated = datetime.utcnow()
206 | u.balance = balance
207 | u.save()
208 |
209 | for tx in imported_transactions:
210 | tx.update = u
211 | tx.save()
212 |
213 | finally:
214 | debug_print('Ending GnuCash session')
215 | session.end()
216 | debug_print('Destroying GnuCash session')
217 | session.destroy()
218 | debug_print('Destroyed GnuCash session')
219 |
220 | debug_print('Done importing QIF(s)')
221 |
--------------------------------------------------------------------------------
/money_templates/templates/page_account_details.html:
--------------------------------------------------------------------------------
1 | {% extends "page_base.html" %}
2 |
3 | {% load template_extras %}
4 | {% load query_string %}
5 |
6 | {% block title %}
7 | Account details - {% if accounts|length > 1 %}multiple accounts{% else %}{{ account.description_or_name }}{% endif %}
8 | {% endblock %}
9 |
10 | {% block scripts %}
11 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 | {% endblock %}
31 |
32 | {% block body %}
33 | {# To avoid duplication of lots of HTML, this element is copied to all transactions #}
34 | {# using JavaScript, then the original is removed from the page. #}
35 |
40 |