├── .ackrc ├── .gitignore ├── README.md ├── __init__.py ├── apache └── money.wsgi ├── gnucash_api_home └── .gitkeep ├── gnucash_data ├── __init__.py ├── admin.py ├── gnucash_db_router.py ├── models.py ├── static │ └── upload │ │ └── .gitkeep └── util.py ├── gnucash_scripts ├── __init__.py ├── common.py ├── import_images_from_json.py ├── import_json_file.py ├── import_qif_file.py ├── mark_as_tax_related.py ├── set_first_checking_trans.py └── swap_memo_and_description.py ├── manage.py ├── middleware ├── __init__.py └── middleware.py ├── money_templates ├── __init__.py ├── static │ ├── account_block.js │ ├── font-awesome.css │ ├── font │ │ ├── FontAwesome.otf │ │ ├── fontawesome-webfont.eot │ │ ├── fontawesome-webfont.svg │ │ ├── fontawesome-webfont.ttf │ │ └── fontawesome-webfont.woff │ ├── jquery.cookie.js │ ├── jquery.hiddendimensions.js │ ├── jquery.js │ ├── jquery.selectfocus.js │ ├── jquery.shiftcheckbox.js │ ├── page_account_details.js │ ├── page_index.js │ ├── page_txaction_files.js │ ├── search_icon.png │ ├── select2-spinner.gif │ ├── select2.css │ ├── select2.js │ ├── select2.png │ ├── select2x2.png │ ├── style.css │ ├── txactions_chart.js │ ├── use-select2.js │ └── vendor │ │ ├── c3-0.4.10 │ │ ├── LICENSE │ │ ├── README.md │ │ ├── c3.css │ │ ├── c3.js │ │ ├── c3.min.css │ │ ├── c3.min.js │ │ └── extensions │ │ │ ├── exporter │ │ │ ├── config.json │ │ │ ├── phantom-exporter.js │ │ │ └── test.png │ │ │ └── js │ │ │ └── c3ext.js │ │ ├── d3 │ │ ├── LICENSE │ │ ├── d3.js │ │ └── d3.min.js │ │ └── moment.min.js └── templates │ ├── account_block.html │ ├── filter_form.html │ ├── hidden_filter_form.html │ ├── login.html │ ├── modify_form.html │ ├── new_transaction_form.html │ ├── page_account_details.html │ ├── page_apply_categorize.html │ ├── page_base.html │ ├── page_batch_categorize.html │ ├── page_index.html │ ├── page_modify.html │ ├── page_txaction_files.html │ ├── txactions_chart.html │ └── txactions_page_block.html ├── money_views ├── __init__.py ├── api.py ├── filters.py ├── forms.py └── views.py ├── requirements.txt ├── settings.example.py ├── urls.py ├── utils ├── AsciiDammit.py ├── __init__.py ├── data_url.py ├── misc_functions.py └── templatetags │ ├── __init__.py │ ├── query_string.py │ └── template_extras.py └── watch.sh /.ackrc: -------------------------------------------------------------------------------- 1 | --ignore-dir=bin 2 | --ignore-dir=include 3 | --ignore-dir=lib 4 | --ignore-dir=local 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | settings.py 3 | TODO 4 | 5 | # Virtualenv stuff 6 | /bin 7 | /include 8 | /lib 9 | /local 10 | 11 | # Files attached to transactions 12 | gnucash_data/static/upload/* 13 | !gnucash_data/static/upload/.gitkeep 14 | 15 | # GnuCash API home directory 16 | gnucash_api_home/* 17 | !gnucash_api_home/.gitkeep 18 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nylen/gnucash-django/2ff14e68cd0236a5c675d202909d8d5fff5abc72/__init__.py -------------------------------------------------------------------------------- /apache/money.wsgi: -------------------------------------------------------------------------------- 1 | # vim: ft=python 2 | 3 | import os 4 | import sys 5 | 6 | import site 7 | 8 | path = os.path 9 | 10 | # Remember original sys.path 11 | 12 | prev_sys_path = list(sys.path) 13 | 14 | # Add new directories 15 | 16 | dir = path.realpath(path.join(path.dirname(__file__), path.pardir)) 17 | if dir not in sys.path: 18 | sys.path.append(dir) 19 | 20 | site.addsitedir(path.realpath(path.join(dir, 'lib/python2.7/site-packages'))) 21 | 22 | # Reorder sys.path so that new directories are at the front 23 | # This logic from: http://code.google.com/p/modwsgi/wiki/VirtualEnvironments 24 | 25 | new_sys_path = [] 26 | for item in list(sys.path): 27 | if item not in prev_sys_path: 28 | new_sys_path.append(item) 29 | sys.path.remove(item) 30 | 31 | sys.path[:0] = new_sys_path 32 | 33 | 34 | os.environ['DJANGO_SETTINGS_MODULE'] = 'settings' 35 | os.environ['RUNNING_WSGI'] = 'true' 36 | 37 | from django.core.handlers.wsgi import WSGIHandler 38 | app_real = WSGIHandler() 39 | 40 | def application(environ, start_response): 41 | os.environ['WSGI_SCRIPT_NAME'] = environ['SCRIPT_NAME'] 42 | return app_real(environ, start_response) 43 | -------------------------------------------------------------------------------- /gnucash_api_home/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nylen/gnucash-django/2ff14e68cd0236a5c675d202909d8d5fff5abc72/gnucash_api_home/.gitkeep -------------------------------------------------------------------------------- /gnucash_data/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nylen/gnucash-django/2ff14e68cd0236a5c675d202909d8d5fff5abc72/gnucash_data/__init__.py -------------------------------------------------------------------------------- /gnucash_data/admin.py: -------------------------------------------------------------------------------- 1 | import inspect 2 | 3 | from django.contrib import admin 4 | from django.db.models import Model 5 | 6 | import models 7 | 8 | 9 | for n, c in inspect.getmembers(models): 10 | if inspect.isclass(c) and issubclass(c, Model): 11 | admin.site.register(c) 12 | -------------------------------------------------------------------------------- /gnucash_data/gnucash_db_router.py: -------------------------------------------------------------------------------- 1 | def db_name(model): 2 | if hasattr(model, 'from_gnucash_api'): 3 | return 'gnucash' 4 | else: 5 | return 'default' 6 | 7 | class GnucashDataRouter(object): 8 | def db_for_read(self, model, **hints): 9 | return db_name(model) 10 | 11 | def db_for_write(self, model, **hints): 12 | return db_name(model) 13 | 14 | def allow_syncdb(self, db, model): 15 | return (db == db_name(model)) 16 | -------------------------------------------------------------------------------- /gnucash_data/models.py: -------------------------------------------------------------------------------- 1 | import errno 2 | import hashlib 3 | import io 4 | import os 5 | import psutil 6 | import re 7 | import shutil 8 | import socket 9 | 10 | from decimal import Decimal 11 | from PIL import Image 12 | 13 | from django.core.files import uploadedfile 14 | from django.db import connections, models 15 | from django.db.models import Max 16 | 17 | import settings 18 | 19 | 20 | class Book(models.Model): 21 | from_gnucash_api = True 22 | 23 | guid = models.CharField(max_length=32, primary_key=True) 24 | root_account = models.ForeignKey('Account', db_column='root_account_guid') 25 | 26 | class Meta: 27 | db_table = 'books' 28 | 29 | def __unicode__(self): 30 | return 'Root account: %s' % self.root_account 31 | 32 | 33 | class Account(models.Model): 34 | from_gnucash_api = True 35 | 36 | guid = models.CharField(max_length=32, primary_key=True) 37 | name = models.CharField(max_length=2048) 38 | parent_guid = models.CharField(max_length=32, null=True) 39 | type = models.CharField(max_length=2048, db_column='account_type') 40 | description = models.CharField(max_length=2048) 41 | placeholder = models.BooleanField() 42 | 43 | _balances = {} 44 | _root = None 45 | _all_accounts = None 46 | _order = None 47 | 48 | class Meta: 49 | db_table = 'accounts' 50 | 51 | def __unicode__(self): 52 | return self.path 53 | 54 | @staticmethod 55 | def from_path(path): 56 | parts = path.split(':') 57 | a = Account.get_root() 58 | if len(parts) > 0: 59 | for p in parts: 60 | found = False 61 | for c in a.children: 62 | if c.name == p: 63 | found = True 64 | a = c 65 | break 66 | if not found: 67 | raise ValueError("Invalid account path '%s'" % path) 68 | return a 69 | 70 | @staticmethod 71 | def get_root(): 72 | Account._ensure_cached() 73 | return Account._root 74 | 75 | @staticmethod 76 | def get(guid): 77 | Account._ensure_cached() 78 | return Account._all_accounts[guid]['account'] 79 | 80 | @staticmethod 81 | def get_all(): 82 | Account._ensure_cached() 83 | return [obj['account'] \ 84 | for obj in Account._all_accounts.itervalues()] 85 | 86 | @staticmethod 87 | def _ensure_cached(): 88 | if Account._root is None: 89 | Account._root = Book.objects.get().root_account 90 | 91 | if Account._all_accounts is None: 92 | def _path(account): 93 | if account.parent_guid is None: 94 | return account.name 95 | parts = [] 96 | a = account 97 | while not a.is_root: 98 | parts.append(a.name) 99 | a = Account.get(a.parent_guid) 100 | parts.reverse() 101 | return ':'.join(parts) 102 | 103 | Account._all_accounts = {} 104 | accounts = list(Account.objects.all()) 105 | 106 | for a in accounts: 107 | Account._all_accounts[a.guid] = { 108 | 'account': a, 109 | 'path': '', 110 | 'children': [], 111 | } 112 | for a in accounts: 113 | Account._all_accounts[a.guid]['path'] = _path(a) 114 | if a.parent_guid is not None: 115 | Account._all_accounts[a.parent_guid]['children'].append(a) 116 | for a in accounts: 117 | Account._all_accounts[a.guid]['children'] \ 118 | .sort(key=lambda a: a.name.lower()) 119 | 120 | if Account._order is None: 121 | def _build_order(account): 122 | Account._order.append(account.guid) 123 | for a in account.children: 124 | _build_order(a) 125 | Account._order = [] 126 | _build_order(Account.get_root()) 127 | 128 | @staticmethod 129 | def clear_caches(): 130 | Account._balances = {} 131 | Account._root = None 132 | Account._all_accounts = None 133 | Account._order = None 134 | 135 | @property 136 | def description_or_name(self): 137 | if self.description: 138 | return self.description 139 | else: 140 | return self.name 141 | 142 | @property 143 | def balance(self): 144 | if self.guid not in Account._balances: 145 | #return sum(s.amount() for s in self.split_set.all()) # SLOW 146 | cursor = connections['gnucash'].cursor() 147 | cursor.execute(''' 148 | SELECT value_denom, SUM(value_num) 149 | FROM splits 150 | WHERE account_guid = %s 151 | GROUP BY value_denom 152 | ''', [self.guid]) 153 | amount = Decimal(0) 154 | for row in cursor.fetchall(): 155 | amount += row[1] / row[0] 156 | Account._balances[self.guid] = amount 157 | return Account._balances[self.guid] 158 | 159 | @property 160 | def last_transaction_date(self): 161 | s = self.split_set.select_related(depth=1) 162 | utc = s.aggregate(max_date=Max('transaction__enter_date'))['max_date'] 163 | return utc 164 | 165 | @property 166 | def has_updates(self): 167 | return (Update.objects.filter(account_guid=self.guid).count() > 0) 168 | 169 | @property 170 | def last_update(self): 171 | updates = Update.objects.filter(account_guid=self.guid) 172 | try: 173 | max_updated = updates.aggregate(max_updated=Max('updated'))['max_updated'] 174 | return updates.filter(updated=max_updated).get() 175 | except: 176 | return None 177 | 178 | @property 179 | def children(self): 180 | Account._ensure_cached() 181 | return list(Account._all_accounts[self.guid]['children']) 182 | 183 | @property 184 | def is_root(self): 185 | return self.guid == Account.get_root().guid 186 | 187 | @property 188 | def path(self): 189 | Account._ensure_cached() 190 | return Account._all_accounts[self.guid]['path'] 191 | 192 | @property 193 | def webapp_key(self): 194 | try: 195 | return unicode(settings.ACCOUNTS_LIST.index(self.path)) 196 | except ValueError: 197 | return self.guid 198 | 199 | 200 | class Update(models.Model): 201 | account_guid = models.CharField(max_length=32) 202 | updated = models.DateTimeField() 203 | balance = models.DecimalField(max_digits=30, decimal_places=5, null=True) 204 | 205 | class Meta: 206 | db_table = 'account_updates' 207 | 208 | def __unicode__(self): 209 | return "Account '%s' updated at %s (balance: %s)" % ( 210 | Account.get(self.account_guid), 211 | self.updated, 212 | '?' if self.balance is None else '%0.2f' % self.balance) 213 | 214 | 215 | class ImportedTransaction(models.Model): 216 | account_guid = models.CharField(max_length=32) 217 | tx_guid = models.CharField(max_length=32, null=True) 218 | source_tx_id = models.CharField(max_length=2048) 219 | update = models.ForeignKey(Update) 220 | 221 | class Meta: 222 | db_table = 'imported_transactions' 223 | 224 | def __unicode__(self): 225 | return "Account '%s', transaction '%s', source ID '%s'" % ( 226 | Account.get(self.account_guid), 227 | Transaction.objects.get(guid=self.tx_guid), 228 | self.source_tx_id); 229 | 230 | 231 | class Transaction(models.Model): 232 | from_gnucash_api = True 233 | 234 | guid = models.CharField(max_length=32, primary_key=True) 235 | post_date = models.DateField() 236 | enter_date = models.DateTimeField() 237 | description = models.CharField(max_length=2048) 238 | 239 | _cached_transactions = {} 240 | 241 | class Meta: 242 | db_table = 'transactions' 243 | 244 | def __unicode__(self): 245 | return '%s | %s' % (self.post_date, self.description) 246 | 247 | def attach_file(self, f): 248 | return File._new_with_transaction(f, self) 249 | 250 | @property 251 | def any_split_has_memo(self): 252 | for split in self.splits: 253 | if not split.memo_is_id_or_blank: 254 | return True 255 | return False 256 | 257 | @staticmethod 258 | def is_id_string(s): 259 | return bool(re.search('id:|ref:|t(x|rans(action)?) *id', s, re.I)) 260 | 261 | @property 262 | def splits(self): 263 | if self.guid in Transaction._cached_transactions: 264 | return Transaction._cached_transactions[self.guid]['splits'] 265 | else: 266 | return self.split_set.all() 267 | 268 | @staticmethod 269 | def cache_from_splits(splits): 270 | transactions = Transaction.objects \ 271 | .filter(guid__in=(s.transaction.guid for s in splits)) 272 | splits = Split.objects \ 273 | .select_related(depth=2) \ 274 | .filter(transaction__guid__in=(t.guid for t in transactions)) 275 | for tx in transactions: 276 | Transaction._cached_transactions[tx.guid] = { 277 | 'transaction': tx, 278 | 'splits': [], 279 | } 280 | for s in splits: 281 | Transaction._cached_transactions[s.transaction.guid]['splits'].append(s) 282 | 283 | @staticmethod 284 | def clear_caches(): 285 | Transaction._cached_transactions = {} 286 | 287 | 288 | class Split(models.Model): 289 | from_gnucash_api = True 290 | 291 | guid = models.CharField(max_length=32, primary_key=True) 292 | account = models.ForeignKey(Account, db_column='account_guid') 293 | transaction = models.ForeignKey(Transaction, db_column='tx_guid') 294 | memo = models.CharField(max_length=2048) 295 | value_num = models.IntegerField() 296 | value_denom = models.IntegerField() 297 | 298 | class Meta: 299 | db_table = 'splits' 300 | 301 | def __unicode__(self): 302 | return '%s | %s | %0.2f' % ( 303 | self.account, 304 | self.transaction, 305 | self.amount()) 306 | 307 | @property 308 | def amount(self): 309 | return Decimal(self.value_num) / Decimal(self.value_denom) 310 | 311 | @property 312 | def is_credit(self): 313 | return self.amount > 0 314 | 315 | @property 316 | def memo_is_id_or_blank(self): 317 | return (not self.memo or Transaction.is_id_string(self.memo)) 318 | 319 | @property 320 | def opposing_split_set(self): 321 | return [s for s in self.transaction.splits if s.account != self.account] 322 | 323 | @property 324 | def opposing_split(self): 325 | try: 326 | return self.opposing_split_set[0] 327 | except: 328 | return None 329 | 330 | @property 331 | def opposing_account(self): 332 | return self.opposing_split.account 333 | 334 | 335 | class Lock(models.Model): 336 | from_gnucash_api = True 337 | 338 | hostname = models.CharField(max_length=255, db_column='Hostname') 339 | process_id = models.IntegerField(db_column='PID', primary_key=True) 340 | 341 | class Meta: 342 | db_table = 'gnclock' 343 | 344 | def __unicode__(self): 345 | try: 346 | name = psutil.Process(int(self.process_id)).name 347 | except: 348 | name = 'unknown process' 349 | return '%s:%i (%s)' % (self.hostname, self.process_id, name) 350 | 351 | @staticmethod 352 | def can_obtain(): 353 | return (Lock.objects.count() == 0) 354 | 355 | @staticmethod 356 | def check_can_obtain(): 357 | if not Lock.can_obtain(): 358 | lock = Lock.objects.all()[0] 359 | raise IOError('Cannot lock gnucash DB tables - locked by %s' % lock) 360 | 361 | @staticmethod 362 | def obtain(): 363 | Lock.check_can_obtain() 364 | # TODO: How to prevent a race condition here? 365 | lock = Lock() 366 | lock.hostname = Lock._fake_hostname() 367 | lock.process_id = os.getpid() 368 | lock.save() 369 | return lock 370 | 371 | @staticmethod 372 | def _fake_hostname(): 373 | try: 374 | import psutil 375 | return '%s@%s' % (psutil.Process(os.getpid()).name, socket.gethostname()) 376 | except ImportError: 377 | return socket.gethostname() 378 | 379 | @staticmethod 380 | def release(): 381 | lock = Lock.objects \ 382 | .filter(hostname=Lock._fake_hostname()) \ 383 | .filter(process_id=os.getpid()) 384 | n = lock.count() 385 | if n != 1: 386 | raise IOError('Expected 1 lock; found %i' % n) 387 | lock.delete() 388 | 389 | 390 | class File(models.Model): 391 | hash = models.CharField(max_length=64) 392 | filename = models.CharField(max_length=255) 393 | transaction = models.ForeignKey(Transaction, db_column='tx_guid') 394 | 395 | _path = os.path.abspath(os.path.join( 396 | os.path.dirname(__file__), 397 | 'static', 398 | 'upload' 399 | )) 400 | 401 | class Meta: 402 | db_table = 'files' 403 | ordering = ['transaction', 'filename'] 404 | 405 | def delete(self, *args, **kwargs): 406 | if File.objects.filter(hash=self.hash).count() == 1: 407 | # This is the only place this file is used; we can delete it 408 | # TODO: This logic does not seem to be working 409 | shutil.rmtree(os.path.dirname(self.abs_path)) 410 | super(File, self).delete(*args, **kwargs) 411 | 412 | @property 413 | def extension(self): 414 | return '.' + self.filename.split('.')[-1].lower() 415 | 416 | @property 417 | def web_path(self): 418 | return 'upload/%s/%s' % (self.hash, self.filename) 419 | 420 | @property 421 | def abs_path(self): 422 | return os.path.join(File._path, self.hash, self.filename) 423 | 424 | @staticmethod 425 | def _new_with_transaction(f, transaction): 426 | # f is a Django UploadedFile 427 | 428 | image_type = 'image/' 429 | if f.content_type[:len(image_type)] == image_type: 430 | try: 431 | img = Image.open(f) 432 | w, h = img.size 433 | max_size = 1600 434 | if max(w, h) > max_size: 435 | img.thumbnail((max_size, max_size)) 436 | tmp = io.BytesIO() 437 | img.save(tmp, img.format) 438 | tmp.seek(0) 439 | f = uploadedfile.SimpleUploadedFile( 440 | name=f.name, 441 | content=tmp.read(), 442 | content_type=f.content_type 443 | ) 444 | tmp.close() 445 | except: 446 | pass 447 | finally: 448 | if img: 449 | img.close() 450 | 451 | hasher = hashlib.sha256() 452 | for chunk in f.chunks(): 453 | hasher.update(chunk) 454 | h = hasher.hexdigest() 455 | 456 | test1 = File.objects.filter(hash=h) 457 | test2 = test1.filter(transaction=transaction) 458 | 459 | if test2.count() > 0: 460 | # This transaction already has the given file attached. 461 | return test2.get() 462 | 463 | if test1.count() > 0: 464 | # Another transaction already has the given file attached. 465 | other_file = test1[0] 466 | this_file = File( 467 | hash=other_file.hash, 468 | filename=other_file.filename, 469 | transaction=transaction 470 | ) 471 | this_file.save() 472 | return this_file 473 | 474 | # Save this file to the filesystem and the database. 475 | 476 | this_file = File( 477 | hash=h, 478 | filename=f.name, 479 | transaction=transaction 480 | ) 481 | 482 | try: 483 | os.makedirs(os.path.dirname(this_file.abs_path)) 484 | except OSError as e: 485 | if e.errno != errno.EEXIST: 486 | raise 487 | 488 | with open(this_file.abs_path, 'wb') as w: 489 | for chunk in f.chunks(): 490 | w.write(chunk) 491 | 492 | this_file.save() 493 | return this_file 494 | 495 | 496 | class Rule(models.Model): 497 | opposing_account_guid = models.CharField(max_length=32, null=True) 498 | match_tx_desc = models.CharField(max_length=2048) 499 | is_regex = models.BooleanField() 500 | min_amount = models.DecimalField(max_digits=30, decimal_places=5, null=True) 501 | max_amount = models.DecimalField(max_digits=30, decimal_places=5, null=True) 502 | 503 | class Meta: 504 | db_table = 'rules' 505 | ordering = ['id'] 506 | 507 | def __unicode__(self): 508 | return "Match '%s'%s -> account '%s'" % ( 509 | self.match_tx_desc, 510 | ' (regex)' if self.is_regex else '', 511 | Account.get(self.opposing_account_guid)) 512 | 513 | def is_match(self, tx_desc, amount): 514 | if self.is_regex: 515 | if not re.search(self.match_tx_desc, tx_desc, re.I): 516 | return False 517 | else: 518 | if not self.match_tx_desc.lower() in tx_desc.lower(): 519 | return False 520 | 521 | if self.min_amount and self.max_amount: 522 | if not (self.min_amount <= abs(amount) and abs(amount) <= self.max_amount): 523 | return False 524 | elif self.min_amount: 525 | if not (self.min_amount <= abs(amount)): 526 | return False 527 | elif self.max_amount: 528 | if not (abs(amount) <= self.max_amount): 529 | return False 530 | 531 | return True 532 | 533 | 534 | class RuleAccount(models.Model): 535 | rule = models.ForeignKey('Rule') 536 | account_guid = models.CharField(max_length=32) 537 | 538 | class Meta: 539 | db_table = 'rule_accounts' 540 | 541 | def __unicode__(self): 542 | return "Rule '%s' for account '%s'" % ( 543 | self.rule, 544 | Account.get(self.account_guid)) 545 | -------------------------------------------------------------------------------- /gnucash_data/static/upload/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nylen/gnucash-django/2ff14e68cd0236a5c675d202909d8d5fff5abc72/gnucash_data/static/upload/.gitkeep -------------------------------------------------------------------------------- /gnucash_data/util.py: -------------------------------------------------------------------------------- 1 | from dateutil import tz 2 | 3 | def utc_to_local(utc): 4 | return utc.replace(tzinfo=tz.tzutc()).astimezone(tz.tzlocal()) 5 | -------------------------------------------------------------------------------- /gnucash_scripts/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nylen/gnucash-django/2ff14e68cd0236a5c675d202909d8d5fff5abc72/gnucash_scripts/__init__.py -------------------------------------------------------------------------------- /gnucash_scripts/common.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from decimal import Decimal 3 | 4 | from gnucash import Account, GncNumeric 5 | 6 | def get_account_by_path(acct, path): 7 | for name in str(path).split(':'): 8 | acct = acct.lookup_by_name(name) 9 | return acct 10 | 11 | def get_account_by_guid(acct, guid): 12 | for a in acct.get_descendants(): 13 | if not isinstance(a, Account): 14 | # Older versions of GnuCash just used a pointer to an Account here. 15 | a = Account(instance=a) 16 | if a.GetGUID().to_string() == guid: 17 | return a 18 | return None 19 | 20 | def get_account_path(acct): 21 | path = [] 22 | while acct.get_full_name() <> '': # while not root account 23 | path.append(acct.name) 24 | acct = acct.get_parent() 25 | path.reverse() 26 | return ':'.join(path) 27 | 28 | def gnc_numeric_to_decimal(n): 29 | return Decimal(n.denom()) / Decimal(n.num()) 30 | 31 | def decimal_to_gnc_numeric(d): 32 | denom = 100 33 | d = d * denom 34 | while d % 1: 35 | denom *= 10 36 | d *= 10 37 | return GncNumeric(int(d), denom) 38 | 39 | def is_same_account(acct1, acct2): 40 | return acct1.GetGUID().to_string() == acct2.GetGUID().to_string() 41 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /gnucash_scripts/mark_as_tax_related.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | from gnucash import Session, Account 4 | 5 | TARGET_ACCOUNT_CODE = '1234' 6 | 7 | def mark_account_with_code_as_tax_related(account, target_code): 8 | """Looks at account to see if it has the target_account_code, if so 9 | returns True. 10 | If not, recursively tries to do the same to all children accounts 11 | of account. 12 | Returns False when recursion fails to find it. 13 | """ 14 | if account.GetCode() == target_code: 15 | account.SetTaxRelated(True) 16 | return True 17 | else: 18 | for child in account.get_children(): 19 | child = Account(instance=child) 20 | if mark_account_with_code_as_tax_related(child, target_code): 21 | return True 22 | return False 23 | 24 | 25 | gnucash_session = Session("file:/home/mark/python-bindings-help/test.xac") 26 | 27 | mark_account_with_code_as_tax_related( 28 | gnucash_session.book.get_root_account(), 29 | TARGET_ACCOUNT_CODE) 30 | 31 | gnucash_session.save() 32 | gnucash_session.end() 33 | -------------------------------------------------------------------------------- /gnucash_scripts/set_first_checking_trans.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | from sys import argv 4 | 5 | from gnucash import Session 6 | 7 | from common import * 8 | 9 | session = Session(argv[1]) 10 | 11 | try: 12 | 13 | root = session.book.get_root_account() 14 | checking = get_account_by_path(root, 'Assets:Current Assets:Trust FCU Checking') 15 | s = checking.GetSplitList()[0] 16 | t = s.parent 17 | 18 | s = t.GetSplitList()[0] 19 | print '%s :: %s' % (t.GetDescription(), 20 | ' // '.join(get_account_path(s.GetAccount()) for s in t.GetSplitList())) 21 | if len(argv) > 2: 22 | s.SetAccount(get_account_by_path(root, argv[2])) 23 | session.save() 24 | 25 | finally: 26 | session.end() 27 | session.destroy() 28 | -------------------------------------------------------------------------------- /gnucash_scripts/swap_memo_and_description.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | from sys import argv 4 | 5 | from gnucash import Session 6 | 7 | from common import get_account_by_path 8 | 9 | session = Session(argv[1]) 10 | root = session.book.get_root_account() 11 | 12 | acct = get_account_by_path(root, argv[2]) 13 | 14 | for s in acct.GetSplitList(): 15 | desc = s.parent.GetDescription() 16 | memo = s.GetMemo() 17 | swapping = "not swapping" 18 | if memo <> "": 19 | s.parent.SetDescription(memo) 20 | s.SetMemo(desc) 21 | swapping = "swapping " 22 | print '%s - desc="%s" :: memo="%s"' % (swapping, desc, memo) 23 | 24 | session.save() 25 | session.end() 26 | -------------------------------------------------------------------------------- /manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | from django.core.management import execute_manager 3 | try: 4 | import settings # Assumed to be in the same directory. 5 | except ImportError: 6 | import sys 7 | sys.stderr.write("Error: Can't find the file 'settings.py' in the directory containing %r. It appears you've customized things.\nYou'll have to run django-admin.py, passing it your settings module.\n(If the file settings.py does indeed exist, it's causing an ImportError somehow.)\n" % __file__) 8 | sys.exit(1) 9 | 10 | if __name__ == "__main__": 11 | execute_manager(settings) 12 | -------------------------------------------------------------------------------- /middleware/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nylen/gnucash-django/2ff14e68cd0236a5c675d202909d8d5fff5abc72/middleware/__init__.py -------------------------------------------------------------------------------- /middleware/middleware.py: -------------------------------------------------------------------------------- 1 | from gnucash_data.models import Account, Transaction 2 | 3 | class ClearCachesMiddleware(): 4 | def process_request(self, request): 5 | Account.clear_caches() 6 | Transaction.clear_caches() 7 | return None 8 | -------------------------------------------------------------------------------- /money_templates/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nylen/gnucash-django/2ff14e68cd0236a5c675d202909d8d5fff5abc72/money_templates/__init__.py -------------------------------------------------------------------------------- /money_templates/static/account_block.js: -------------------------------------------------------------------------------- 1 | $(function() { 2 | $('body').on('click', '.account-balance-info', function(e) { 3 | alert($(this).find('.balance-title').attr('title')); 4 | return false; 5 | }); 6 | }); 7 | -------------------------------------------------------------------------------- /money_templates/static/font/FontAwesome.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nylen/gnucash-django/2ff14e68cd0236a5c675d202909d8d5fff5abc72/money_templates/static/font/FontAwesome.otf -------------------------------------------------------------------------------- /money_templates/static/font/fontawesome-webfont.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nylen/gnucash-django/2ff14e68cd0236a5c675d202909d8d5fff5abc72/money_templates/static/font/fontawesome-webfont.eot -------------------------------------------------------------------------------- /money_templates/static/font/fontawesome-webfont.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nylen/gnucash-django/2ff14e68cd0236a5c675d202909d8d5fff5abc72/money_templates/static/font/fontawesome-webfont.ttf -------------------------------------------------------------------------------- /money_templates/static/font/fontawesome-webfont.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nylen/gnucash-django/2ff14e68cd0236a5c675d202909d8d5fff5abc72/money_templates/static/font/fontawesome-webfont.woff -------------------------------------------------------------------------------- /money_templates/static/jquery.cookie.js: -------------------------------------------------------------------------------- 1 | /*! 2 | * jQuery Cookie Plugin 3 | * https://github.com/carhartl/jquery-cookie 4 | * 5 | * Copyright 2011, Klaus Hartl 6 | * Dual licensed under the MIT or GPL Version 2 licenses. 7 | * http://www.opensource.org/licenses/mit-license.php 8 | * http://www.opensource.org/licenses/GPL-2.0 9 | */ 10 | (function($) { 11 | $.cookie = function(key, value, options) { 12 | 13 | // key and at least value given, set cookie... 14 | if (arguments.length > 1 && (!/Object/.test(Object.prototype.toString.call(value)) || value === null || value === undefined)) { 15 | options = $.extend({}, options); 16 | 17 | if (value === null || value === undefined) { 18 | options.expires = -1; 19 | } 20 | 21 | if (typeof options.expires === 'number') { 22 | var days = options.expires, t = options.expires = new Date(); 23 | t.setDate(t.getDate() + days); 24 | } 25 | 26 | value = String(value); 27 | 28 | return (document.cookie = [ 29 | encodeURIComponent(key), '=', options.raw ? value : encodeURIComponent(value), 30 | options.expires ? '; expires=' + options.expires.toUTCString() : '', // use expires attribute, max-age is not supported by IE 31 | options.path ? '; path=' + options.path : '', 32 | options.domain ? '; domain=' + options.domain : '', 33 | options.secure ? '; secure' : '' 34 | ].join('')); 35 | } 36 | 37 | // key and possibly options given, get cookie... 38 | options = value || {}; 39 | var decode = options.raw ? function(s) { return s; } : decodeURIComponent; 40 | 41 | var pairs = document.cookie.split('; '); 42 | for (var i = 0, pair; pair = pairs[i] && pairs[i].split('='); i++) { 43 | if (decode(pair[0]) === key) return decode(pair[1] || ''); // IE saves cookies with empty string as "c; ", e.g. without "=" as opposed to EOMB, thus pair[1] may be undefined 44 | } 45 | return null; 46 | }; 47 | })(jQuery); 48 | -------------------------------------------------------------------------------- /money_templates/static/jquery.hiddendimensions.js: -------------------------------------------------------------------------------- 1 | // from http://www.foliotek.com/devblog/getting-the-width-of-a-hidden-element-with-jquery-using-width/ 2 | (function($) { 3 | $.fn.hiddenDimensions = function(includeMargin) { 4 | // Optional parameter includeMargin is used when calculating outer dimensions 5 | var $item = this; 6 | var props = { 7 | position: 'absolute', 8 | visibility: 'hidden', 9 | display: 'block' 10 | }; 11 | var dim = { 12 | width:0, 13 | height:0, 14 | innerWidth: 0, 15 | innerHeight: 0, 16 | outerWidth: 0, 17 | outerHeight: 0 18 | }; 19 | var $hiddenParents = $item.parents().andSelf().not(':visible'); 20 | var includeMargin = (includeMargin == null ? false : includeMargin); 21 | 22 | var oldProps = []; 23 | $hiddenParents.each(function() { 24 | var old = {}; 25 | for (var name in props) { 26 | old[name] = this.style[name]; 27 | this.style[name] = props[name]; 28 | } 29 | oldProps.push(old); 30 | }); 31 | 32 | dim.width = $item.width(); 33 | dim.outerWidth = $item.outerWidth(includeMargin); 34 | dim.innerWidth = $item.innerWidth(); 35 | dim.height = $item.height(); 36 | dim.innerHeight = $item.innerHeight(); 37 | dim.outerHeight = $item.outerHeight(includeMargin); 38 | 39 | $hiddenParents.each(function(i) { 40 | var old = oldProps[i]; 41 | for (var name in props) { 42 | this.style[name] = old[name]; 43 | } 44 | }); 45 | 46 | return dim; 47 | } 48 | }(jQuery)); 49 | -------------------------------------------------------------------------------- /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/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/page_account_details.js: -------------------------------------------------------------------------------- 1 | $(function() { 2 | var currentForm = null, 3 | copyMode = false; 4 | 5 | $.fn.focusselect = function(delay) { 6 | var o = this; 7 | var fn = function() { 8 | o.each(function() { 9 | this.focus(); 10 | this.select(); 11 | }); 12 | }; 13 | if (delay) { 14 | window.setTimeout(fn, delay); 15 | } else { 16 | fn(); 17 | } 18 | }; 19 | 20 | $('#form-links').show(); 21 | $('#forms .form').hide().removeClass('block block-first'); 22 | $('#form-filters tr.field-opposing_account').show(); 23 | $('#form-filters tr.field-opposing_accounts').each(function() { 24 | $(this).find('ul').hide(); 25 | $(this).hide(); 26 | }); 27 | 28 | var delayFilterFocus = true; 29 | 30 | function showFilterForm(slow) { 31 | if (copyMode) { 32 | return; 33 | } 34 | if (!$('#form-filters').is(':visible')) { 35 | if (!slow) { 36 | $('#form-filters').show(); 37 | } 38 | delayFilterFocus = false; 39 | $('#toggle-filters').trigger('click'); 40 | delayFilterFocus = true; 41 | } 42 | } 43 | 44 | $('#forms a.toggle-form').click(function() { 45 | var form = $(this).data('form'); 46 | var $form = $('#form-' + form); 47 | if (form == currentForm) { 48 | $form.slideUp(); 49 | currentForm = null; 50 | } else { 51 | $form.insertAfter('#before-forms').slideDown(); 52 | $('#forms .form').not($form).slideUp(); 53 | currentForm = form; 54 | } 55 | if (currentForm == form) { 56 | $('#form-links a').not(this).removeClass('active'); 57 | $(this).addClass('active'); 58 | } else { 59 | $(this).removeClass('active'); 60 | } 61 | return false; 62 | }); 63 | 64 | $('#toggle-filters').click(function() { 65 | $('#id_tx_desc').focusselect(delayFilterFocus ? 250 : 0); 66 | }); 67 | 68 | if (filteringAny) { 69 | showFilterForm(); 70 | } 71 | 72 | var $checkboxes = $('#form-filters tr.field-opposing_accounts :checkbox'); 73 | 74 | $('#form-filters form').submit(function(e) { 75 | if ($('#id_opposing_accounts_0').is(':checked')) { 76 | $checkboxes.attr('checked', false); 77 | } 78 | return true; 79 | }); 80 | 81 | $('#form-filters tr.field-opposing_accounts li').shiftcheckbox({ 82 | checkboxSelector: ':checkbox', 83 | selectAll: '#id_opposing_accounts_0' 84 | }); 85 | 86 | $('#forms :text').attr('autocomplete', 'off').selectfocus(); 87 | 88 | 89 | $('#filter-multi-accounts').click(function() { 90 | $('#single-opposing-account').unbind('change').remove(); 91 | $('#form-filters tr.field-opposing_account').hide(); 92 | $('#form-filters tr.field-opposing_accounts').each(function() { 93 | $(this).show(); 94 | $('ul', this).slideDown(); 95 | }); 96 | return false; 97 | }); 98 | 99 | $checkboxes.each(function() { 100 | $('#single-opposing-account').append( 101 | '