├── .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 | '' + $(this).closest('label').text() + ''); 102 | }); 103 | 104 | $('#single-opposing-account').change(function() { 105 | var value = $(this).val(); 106 | $checkboxes.attr('checked', function() { 107 | return (value == 'all' || value == $(this).val()); 108 | }); 109 | }); 110 | 111 | 112 | var numChecked = $checkboxes.filter(':checked').length; 113 | 114 | if (numChecked == 1) { 115 | $('#single-opposing-account').val($checkboxes.filter(':checked').val()); 116 | } else if (numChecked == 0 || numChecked == $checkboxes.length) { 117 | $('#single-opposing-account').val('all'); 118 | $checkboxes.attr('checked', true); 119 | } else { 120 | $('#form-filters tr.field-opposing_accounts ul').show(); 121 | $('#filter-multi-accounts').trigger('click'); 122 | } 123 | 124 | // from http://simonwillison.net/2006/Jan/20/escape/ 125 | RegExp.escape = function(text) { 126 | //return text.replace(/[-[\]{}()*+?.,\\^$|#\s]/g, "\\$&"); 127 | return text.replace(/[-[\]{}()*+?.,\\^$|#]/g, "\\$&"); 128 | } 129 | 130 | $('table.transactions td.description').click(function() { 131 | if (copyMode) { 132 | return; 133 | } 134 | showFilterForm(true); 135 | 136 | var thisValue = $(this).data('value'); 137 | var txDesc = $('#id_tx_desc').val(); 138 | 139 | for (i in regexChars) { 140 | var c = regexChars.charAt(i); 141 | if (thisValue.indexOf(c) >= 0) { 142 | thisValue = RegExp.escape(thisValue); 143 | break; 144 | } 145 | } 146 | 147 | $('#id_tx_desc').val(thisValue == txDesc ? '' : thisValue) 148 | .css('visibility', 'visible') // Android ICS browser hack 149 | .focusselect(); 150 | }); 151 | 152 | $('table.transactions .no-events').click(function(e) { 153 | e.stopPropagation(); 154 | return true; 155 | }); 156 | 157 | function filterRangeFromValue(value, minSel, maxSel, parse, format) { 158 | if (!format) { 159 | format = function(val) { return val; } 160 | } 161 | function formatInternal(val) { 162 | return (isNaN(val) ? '' : format(val)); 163 | } 164 | 165 | var thisVal = parse(value), 166 | minVal = parse($(minSel).val()), 167 | maxVal = parse($(maxSel).val()), 168 | toFocus = minSel; 169 | 170 | if (thisVal < minVal) { 171 | // Decrease the bottom of the filtered range 172 | minVal = thisVal; 173 | } else if (thisVal > maxVal) { 174 | // Increase the top of the filtered range 175 | toFocus = maxSel; 176 | maxVal = thisVal; 177 | } else if (thisVal - minVal == 0 && thisVal - maxVal == 0) { 178 | // Clear the filtered range (do the comparison this way because you can't 179 | // use == to compare dates) 180 | minVal = NaN; 181 | maxVal = NaN; 182 | } else { 183 | minVal = thisVal; 184 | maxVal = thisVal; 185 | } 186 | $(minSel).val(formatInternal(minVal)); 187 | $(maxSel).val(formatInternal(maxVal)); 188 | // Hack to make the Android ICS browser actually display the new value 189 | $([minSel, maxSel].join(', ')).css('visibility', 'visible'); 190 | 191 | $(toFocus).focusselect(); 192 | } 193 | 194 | $('table.transactions td.date').click(function() { 195 | if (copyMode) { 196 | return; 197 | } 198 | showFilterForm(true); 199 | filterRangeFromValue( 200 | $(this).data('value'), 201 | '#id_min_date', '#id_max_date', 202 | function(val) { 203 | return new Date(Date.parse(val)); 204 | }, 205 | function(date) { 206 | var m = '00' + (date.getMonth() + 1); 207 | var d = '00' + date.getDate(); 208 | var y = '00' + date.getFullYear(); 209 | return (m.substring(m.length - 2) 210 | + '/' + d.substring(d.length - 2) 211 | + '/' + y.substring(y.length - 2)); 212 | }); 213 | }); 214 | 215 | $('table.transactions td.amount').click(function() { 216 | if (copyMode) { 217 | return; 218 | } 219 | showFilterForm(true); 220 | filterRangeFromValue( 221 | Math.abs($(this).data('value')), 222 | '#id_min_amount', '#id_max_amount', 223 | parseFloat); 224 | }); 225 | 226 | $('#form-modify form').submit(function(e) { 227 | if (!$('#modify_id_change_opposing_account').val()) { 228 | alert('Select an opposing account first.'); 229 | return false; 230 | } 231 | if (numTransactions > 100) { 232 | return confirm('This action may affect more than 100 ' 233 | + 'transactions. Are you sure you want to continue?'); 234 | } 235 | }); 236 | 237 | 238 | $('td.memo').each(function() { 239 | $(this).html('') 240 | .find('.edit-memo').text($(this).data('value')); 241 | }); 242 | 243 | $('.add-memo').show().click(function() { 244 | if (copyMode) { 245 | return; 246 | } 247 | var $memoRow = $(this).closest('tr').next('tr'); 248 | var $memoCell; 249 | while (($memoCell = $memoRow.find('.memo')).length) { 250 | var memo = $memoCell.data('value'); 251 | if (!memo) { 252 | $memoRow.removeClass('hidden'); 253 | $memoCell.find('.edit-memo').trigger('click'); 254 | return false; 255 | } 256 | $memoRow = $memoRow.next('tr'); 257 | } 258 | alert("Cannot add a memo to any of this transaction's splits."); 259 | return false; 260 | }); 261 | 262 | $('.edit-memo').click(function() { 263 | if (copyMode) { 264 | return; 265 | } 266 | var $a = $(this); 267 | if ($a.hasClass('loading')) { 268 | return false; 269 | } 270 | var account = accounts[$a.closest('tr').data('account')]; 271 | var oldMemo = $a.closest('.memo').data('value'); 272 | $a.addClass('editing').text('Editing...'); 273 | memo = prompt("Enter memo (associated with account '" + account.path + "'):", oldMemo); 274 | $a.removeClass('editing'); 275 | if (memo == null) { 276 | $a.text(oldMemo); 277 | } else { 278 | $a.addClass('loading').text('Setting memo...'); 279 | $a.closest('.memo').data('value', memo); 280 | $.ajax({ 281 | url: apiFunctionUrls['change_memo'], 282 | type: 'POST', 283 | headers: { 284 | 'X-CSRFToken': $.cookie('csrftoken') 285 | }, 286 | data: { 287 | 'split_guid': $a.closest('tr').data('split'), 288 | 'memo': memo 289 | }, 290 | cache: false, 291 | success: function(d) { 292 | $a.text(d.memo || d.error || 'Unknown error'); 293 | }, 294 | error: function(xhr, status, e) { 295 | $a.text('Error: ' + e); 296 | }, 297 | complete: function(xhr, status) { 298 | $a.removeClass('loading'); 299 | } 300 | }); 301 | } 302 | return false; 303 | }); 304 | 305 | var $select = $('.change-account'); 306 | 307 | $('.change-opposing-account').each(function() { 308 | $select.clone().prependTo(this).val($(this).data('value')); 309 | }).show() 310 | .find('.change-account').show() 311 | .change(function() { 312 | var $a = $(this).closest('.change-opposing-account'); 313 | var $name = $a.prev('.opposing-account-name'); 314 | var oldAccountKey = $(this).closest('.change-opposing-account').data('value'); 315 | var newAccountKey = $(this).val(); 316 | if (newAccountKey != oldAccountKey) { 317 | $name.addClass('loading').text('Setting account...'); 318 | $a.data('value', newAccountKey); 319 | $.ajax({ 320 | url: apiFunctionUrls['change_account'], 321 | type: 'POST', 322 | headers: { 323 | 'X-CSRFToken': $.cookie('csrftoken') 324 | }, data: { 325 | 'split_guid': $a.closest('tr').data('opposing-split'), 326 | 'account_guid': newAccountKey 327 | }, 328 | cache: false, 329 | success: function(d) { 330 | var account = accounts[d.account_guid]; 331 | $name.text((account && account.name) || d.error || 'Unknown error'); 332 | }, error: function(xhr, status, e) { 333 | $a.text('Error: ' + e); 334 | }, 335 | complete: function(xhr, status) { 336 | $a.removeClass('loading'); 337 | } 338 | }); 339 | } 340 | }); 341 | 342 | $select.remove(); 343 | 344 | 345 | var $copyFrom = $('#copy-from-transaction'), 346 | $txRows = $('table.transactions tr'); 347 | 348 | function setCopyMode(newCopyMode) { 349 | copyMode = newCopyMode; 350 | $copyFrom.text(copyMode 351 | ? '(click a transaction or click to cancel)' 352 | : 'copy from transaction'); 353 | $('body')[copyMode ? 'addClass' : 'removeClass']('copy-mode'); 354 | $txRows.removeClass('hover'); 355 | } 356 | 357 | $copyFrom.removeClass('hidden').click(function() { 358 | setCopyMode(!copyMode); 359 | return false; 360 | }); 361 | 362 | function hoverTransaction(tr, hoverOn) { 363 | if (!copyMode) { 364 | return; 365 | } 366 | var guid = $(tr).data('tx') || 'none'; 367 | $txRows.each(function() { 368 | if (hoverOn && $(this).data('tx') == guid) { 369 | $(this).addClass('hover'); 370 | } else { 371 | $(this).removeClass('hover'); 372 | } 373 | }); 374 | } 375 | 376 | $txRows.hover(function() { 377 | hoverTransaction(this, true); 378 | }, function() { 379 | hoverTransaction(this, false); 380 | }).click(function(e) { 381 | if (!copyMode) { 382 | return true; 383 | } 384 | 385 | var guid = $(this).data('tx'); 386 | if (guid) { 387 | $rows = $txRows.filter(function() { 388 | return ($(this).data('tx') == guid && !$(this).hasClass('hidden')); 389 | }); 390 | $('#id_new_trans_tx_desc').val($rows.find('td.description').data('value')); 391 | $('#id_new_trans_memo').val($rows.find('td.memo').data('value')); 392 | $('#id_new_trans_post_date').val($rows.find('td.date').data('value')); 393 | $('#id_new_trans_opposing_account').val($rows.find('.change-opposing-account').data('value')); 394 | $('#id_new_trans_amount').val($rows.find('td.amount').data('value')); 395 | } 396 | 397 | setCopyMode(false); 398 | e.stopPropagation(); 399 | return false; 400 | }); 401 | 402 | 403 | // The Android ICS browser doesn't seem to give disabled/readonly input 404 | // fields any special styling. Apply some manually. 405 | if (navigator.userAgent.toLowerCase().indexOf('android') >= 0) { 406 | $('input.disabled, input.readonly').css('backgroundColor', '#ddd'); 407 | } 408 | 409 | // Resize textboxes and select elements up to a maximum width. 410 | var windowWidth = $(window).width(); 411 | if (windowWidth >= 800) { 412 | var maxWidths = { 413 | 'input': 450, 414 | 'select': 250 415 | }; 416 | $('table.form-table').each(function() { 417 | var dims = $(this).hiddenDimensions(); 418 | var widthUncapped = windowWidth - dims.width - 20; 419 | $('input:text, select', this).each(function() { 420 | var width = Math.min( 421 | widthUncapped, 422 | maxWidths[this.nodeName.toLowerCase()]); 423 | $(this).css({ 424 | 'width': width, 425 | 'maxWidth': width 426 | }); 427 | }); 428 | }); 429 | } 430 | }); 431 | -------------------------------------------------------------------------------- /money_templates/static/page_index.js: -------------------------------------------------------------------------------- 1 | $(function() { 2 | $('#select-accounts li').shiftcheckbox({ 3 | checkboxSelector: ':checkbox', 4 | selectAll: '#accounts-all' 5 | }); 6 | $('#select-accounts li a').click(function() { 7 | location.href = this.href; 8 | return true; 9 | }); 10 | }); 11 | -------------------------------------------------------------------------------- /money_templates/static/page_txaction_files.js: -------------------------------------------------------------------------------- 1 | $(function() { 2 | $('.close-window').click(function() { 3 | window.close(); 4 | }); 5 | 6 | $('.delete-attachment').click(function() { 7 | return confirm('Are you sure you want to delete this attachment?'); 8 | }); 9 | }); 10 | -------------------------------------------------------------------------------- /money_templates/static/search_icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nylen/gnucash-django/2ff14e68cd0236a5c675d202909d8d5fff5abc72/money_templates/static/search_icon.png -------------------------------------------------------------------------------- /money_templates/static/select2-spinner.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nylen/gnucash-django/2ff14e68cd0236a5c675d202909d8d5fff5abc72/money_templates/static/select2-spinner.gif -------------------------------------------------------------------------------- /money_templates/static/select2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nylen/gnucash-django/2ff14e68cd0236a5c675d202909d8d5fff5abc72/money_templates/static/select2.png -------------------------------------------------------------------------------- /money_templates/static/select2x2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nylen/gnucash-django/2ff14e68cd0236a5c675d202909d8d5fff5abc72/money_templates/static/select2x2.png -------------------------------------------------------------------------------- /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_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 | -------------------------------------------------------------------------------- /money_templates/static/use-select2.js: -------------------------------------------------------------------------------- 1 | function matchesInitials(initials, strings) { 2 | if (initials == '') { 3 | return true; 4 | } 5 | if (!strings.length) { 6 | return false; 7 | } 8 | for (var i = 1; ; i++) { 9 | if (strings[0].substring(0, i) == initials.substring(0, i)) { 10 | if (matchesInitials(initials.substring(i), strings.slice(1))) { 11 | return true; 12 | } 13 | } else { 14 | break; 15 | } 16 | } 17 | return false; 18 | } 19 | 20 | $(function() { 21 | $('select.change-account, .change-account-container select').select2({ 22 | matcher: function(term, text) { 23 | term = term.toLowerCase(); 24 | text = text.toLowerCase(); 25 | if (text.indexOf(term) >= 0) { 26 | return true; 27 | } 28 | if (matchesInitials(term, text.split(':'))) { 29 | return true; 30 | } 31 | return false; 32 | }, 33 | width: '400px' 34 | }); 35 | }); 36 | -------------------------------------------------------------------------------- /money_templates/static/vendor/c3-0.4.10/LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2013 Masayuki Tanaka 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of 6 | this software and associated documentation files (the "Software"), to deal in 7 | the Software without restriction, including without limitation the rights to 8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 9 | the Software, and to permit persons to whom the Software is furnished to do so, 10 | subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 17 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 18 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 19 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 20 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/c3.min.css: -------------------------------------------------------------------------------- 1 | .c3 svg{font:10px sans-serif}.c3 line,.c3 path{fill:none;stroke:#000}.c3 text{-webkit-user-select:none;-moz-user-select:none;user-select:none}.c3-bars path,.c3-event-rect,.c3-legend-item-tile,.c3-xgrid-focus,.c3-ygrid{shape-rendering:crispEdges}.c3-chart-arc path{stroke:#fff}.c3-chart-arc text{fill:#fff;font-size:13px}.c3-grid line{stroke:#aaa}.c3-grid text{fill:#aaa}.c3-xgrid,.c3-ygrid{stroke-dasharray:3 3}.c3-text.c3-empty{fill:gray;font-size:2em}.c3-line{stroke-width:1px}.c3-circle._expanded_{stroke-width:1px;stroke:#fff}.c3-selected-circle{fill:#fff;stroke-width:2px}.c3-bar{stroke-width:0}.c3-bar._expanded_{fill-opacity:.75}.c3-target.c3-focused{opacity:1}.c3-target.c3-focused path.c3-line,.c3-target.c3-focused path.c3-step{stroke-width:2px}.c3-target.c3-defocused{opacity:.3!important}.c3-region{fill:#4682b4;fill-opacity:.1}.c3-brush .extent{fill-opacity:.1}.c3-legend-item{font-size:12px}.c3-legend-item-hidden{opacity:.15}.c3-legend-background{opacity:.75;fill:#fff;stroke:#d3d3d3;stroke-width:1}.c3-tooltip-container{z-index:10}.c3-tooltip{border-collapse:collapse;border-spacing:0;background-color:#fff;empty-cells:show;-webkit-box-shadow:7px 7px 12px -9px #777;-moz-box-shadow:7px 7px 12px -9px #777;box-shadow:7px 7px 12px -9px #777;opacity:.9}.c3-tooltip tr{border:1px solid #CCC}.c3-tooltip th{background-color:#aaa;font-size:14px;padding:2px 5px;text-align:left;color:#FFF}.c3-tooltip td{font-size:13px;padding:3px 6px;background-color:#fff;border-left:1px dotted #999}.c3-tooltip td>span{display:inline-block;width:10px;height:10px;margin-right:6px}.c3-tooltip td.value{text-align:right}.c3-area{stroke-width:0;opacity:.2}.c3-chart-arcs-title{dominant-baseline:middle;font-size:1.3em}.c3-chart-arcs .c3-chart-arcs-background{fill:#e0e0e0;stroke:none}.c3-chart-arcs .c3-chart-arcs-gauge-unit{fill:#000;font-size:16px}.c3-chart-arcs .c3-chart-arcs-gauge-max,.c3-chart-arcs .c3-chart-arcs-gauge-min{fill:#777}.c3-chart-arc .c3-gauge-value{fill:#000} -------------------------------------------------------------------------------- /money_templates/static/vendor/c3-0.4.10/extensions/exporter/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "js": [ 3 | "../../bower_components/d3/d3.min.js", 4 | "../../c3.min.js" 5 | ], 6 | "css": [ 7 | "../../c3.css" 8 | ], 9 | 10 | "template": "" 11 | } -------------------------------------------------------------------------------- /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); -------------------------------------------------------------------------------- /money_templates/static/vendor/c3-0.4.10/extensions/exporter/test.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nylen/gnucash-django/2ff14e68cd0236a5c675d202909d8d5fff5abc72/money_templates/static/vendor/c3-0.4.10/extensions/exporter/test.png -------------------------------------------------------------------------------- /money_templates/static/vendor/c3-0.4.10/extensions/js/c3ext.js: -------------------------------------------------------------------------------- 1 | var c3ext = {}; 2 | c3ext.generate = function (options) { 3 | 4 | if (options.zoom2 != null) { 5 | zoom2_reducers = options.zoom2.reducers || {}; 6 | zoom2_enabled = options.zoom2.enabled; 7 | _zoom2_factor = options.zoom2.factor || 1; 8 | _zoom2_maxItems = options.zoom2.maxItems; 9 | } 10 | 11 | if (!zoom2_enabled) { 12 | return c3.generate(options); 13 | } 14 | 15 | 16 | var originalData = Q.copy(options.data); 17 | var zoom2_reducers; 18 | var zoom2_enabled; 19 | var _zoom2_maxItems; 20 | 21 | if (_zoom2_maxItems == null) { 22 | var el = d3.select(options.bindto)[0][0]; 23 | if (el != null) { 24 | var availWidth = el.clientWidth; 25 | 26 | var pointSize = 20; 27 | _zoom2_maxItems = Math.ceil(availWidth / pointSize); 28 | } 29 | if (_zoom2_maxItems == null || _zoom2_maxItems < 10) { 30 | _zoom2_maxItems = 10; 31 | } 32 | } 33 | 34 | function onZoomChanged(e) { 35 | refresh(); 36 | } 37 | 38 | var zoom2 = c3ext.ZoomBehavior({ changed: onZoomChanged, bindto: options.bindto }); 39 | 40 | zoom2.enhance = function () { 41 | _zoom2_maxItems *= 2; 42 | var totalItems = zoom2.getZoom().totalItems; 43 | if (_zoom2_maxItems > totalItems) 44 | _zoom2_maxItems = totalItems; 45 | refresh(); 46 | } 47 | zoom2.dehance = function () { 48 | _zoom2_maxItems = Math.ceil(_zoom2_maxItems / 2) + 1; 49 | refresh(); 50 | } 51 | 52 | zoom2.maxItems = function () { return _zoom2_maxItems; }; 53 | function zoomAndReduceData(list, zoomRange, func, maxItems) { 54 | //var maxItems = 10;//Math.ceil(10 * zoomFactor); 55 | var list2 = list.slice(zoomRange[0], zoomRange[1]); 56 | var chunkSize = 1; 57 | var list3 = list2; 58 | if (list3.length > maxItems) { 59 | var chunkSize = Math.ceil(list2.length / maxItems); 60 | list3 = list3.splitIntoChunksOf(chunkSize).map(func); 61 | } 62 | //console.log("x" + getCurrentZoomLevel() + ", maxItems=" + maxItems + " chunkSize=" + chunkSize + " totalBefore=" + list2.length + ", totalAfter=" + list3.length); 63 | return list3; 64 | } 65 | 66 | function first(t) { return t[0]; } 67 | 68 | var getDataForZoom = function (data) { 69 | if (data.columns == null || data.columns.length == 0) 70 | return; 71 | 72 | var zoomInfo = zoom2.getZoom(); 73 | if (zoomInfo.totalItems != data.columns[0].length - 1) { 74 | zoom2.setOptions({ totalItems: data.columns[0].length - 1 }); 75 | zoomInfo = zoom2.getZoom(); 76 | } 77 | data.columns = originalData.columns.map(function (column) { 78 | var name = column[0]; 79 | var reducer = zoom2_reducers[name] || first; //by default take the first 80 | 81 | var values = column.slice(1); 82 | var newValues = zoomAndReduceData(values, zoomInfo.currentZoom, reducer, _zoom2_maxItems); 83 | return [name].concat(newValues); 84 | }); 85 | return data; 86 | }; 87 | 88 | getDataForZoom(options.data); 89 | var chart = c3.generate(options); 90 | var _chart_load_org = chart.load.bind(chart); 91 | chart.zoom2 = zoom2; 92 | chart.load = function (data) { 93 | if (data.unload) { 94 | unload(data.unload); 95 | delete data.unload; 96 | } 97 | Q.copy(data, originalData); 98 | refresh(); 99 | } 100 | chart.unload = function (names) { 101 | unload(names); 102 | refresh(); 103 | } 104 | 105 | function unload(names) { 106 | originalData.columns.removeAll(function (t) { names.contains(t); }); 107 | } 108 | 109 | 110 | function refresh() { 111 | var data = Q.copy(originalData) 112 | getDataForZoom(data); 113 | _chart_load_org(data); 114 | }; 115 | 116 | 117 | return chart; 118 | } 119 | 120 | c3ext.ZoomBehavior = function (options) { 121 | var zoom = { __type: "ZoomBehavior" }; 122 | 123 | var _zoom2_factor; 124 | var _left; 125 | var totalItems; 126 | var currentZoom; 127 | var bindto = options.bindto; 128 | var _zoomChanged = options.changed || function () { }; 129 | var element; 130 | var mousewheelTimer; 131 | var deltaY = 0; 132 | var leftRatio = 0; 133 | 134 | 135 | zoom.setOptions = function (options) { 136 | if (options == null) 137 | options = {}; 138 | _zoom2_factor = options.factor || 1; 139 | _left = 0; 140 | totalItems = options.totalItems || 0; 141 | currentZoom = [0, totalItems]; 142 | _zoomChanged = options.changed || _zoomChanged; 143 | } 144 | 145 | zoom.setOptions(options); 146 | 147 | 148 | function verifyZoom(newZoom) { 149 | //newZoom.sort(); 150 | if (newZoom[1] > totalItems) { 151 | var diff = newZoom[1] - totalItems; 152 | newZoom[0] -= diff; 153 | newZoom[1] -= diff; 154 | } 155 | if (newZoom[0] < 0) { 156 | var diff = newZoom[0] * -1; 157 | newZoom[0] += diff; 158 | newZoom[1] += diff; 159 | } 160 | if (newZoom[1] > totalItems) 161 | newZoom[1] = totalItems; 162 | if (newZoom[0] < 0) 163 | newZoom[0] = 0; 164 | } 165 | 166 | function zoomAndPan(zoomFactor, left) { 167 | var itemsToShow = Math.ceil(totalItems / zoomFactor); 168 | var newZoom = [left, left + itemsToShow]; 169 | verifyZoom(newZoom); 170 | currentZoom = newZoom; 171 | onZoomChanged(); 172 | } 173 | 174 | function onZoomChanged() { 175 | if (_zoomChanged != null) 176 | _zoomChanged(zoom.getZoom()); 177 | } 178 | function applyZoomAndPan() { 179 | zoomAndPan(_zoom2_factor, _left); 180 | } 181 | function getItemsToShow() { 182 | var itemsToShow = Math.ceil(totalItems / _zoom2_factor); 183 | return itemsToShow; 184 | } 185 | 186 | 187 | zoom.getZoom = function () { 188 | return { totalItems: totalItems, currentZoom: currentZoom.slice() }; 189 | } 190 | 191 | zoom.factor = function (factor, skipDraw) { 192 | if (arguments.length == 0) 193 | return _zoom2_factor; 194 | _zoom2_factor = factor; 195 | if (_zoom2_factor < 1) 196 | _zoom2_factor = 1; 197 | if (skipDraw) 198 | return; 199 | applyZoomAndPan(); 200 | } 201 | zoom.left = function (left, skipDraw) { 202 | if (arguments.length == 0) 203 | return _left; 204 | _left = left; 205 | if (_left < 0) 206 | _left = 0; 207 | var pageSize = getItemsToShow(); 208 | //_left += pageSize; 209 | if (_left + pageSize > totalItems) 210 | _left = totalItems - pageSize; 211 | console.log({ left: _left, pageSize: pageSize }); 212 | if (skipDraw) 213 | return; 214 | applyZoomAndPan(); 215 | } 216 | 217 | zoom.zoomAndPanByRatio = function (zoomRatio, panRatio) { 218 | 219 | var pageSize = getItemsToShow(); 220 | var leftOffset = Math.round(pageSize * panRatio); 221 | var mouseLeft = _left + leftOffset; 222 | zoom.factor(zoom.factor() * zoomRatio, true); 223 | 224 | var finalLeft = mouseLeft; 225 | if (zoomRatio != 1) { 226 | var pageSize2 = getItemsToShow(); 227 | var leftOffset2 = Math.round(pageSize2 * panRatio); 228 | finalLeft = mouseLeft - leftOffset2; 229 | } 230 | zoom.left(finalLeft, true); 231 | applyZoomAndPan(); 232 | } 233 | 234 | zoom.zoomIn = function () { 235 | zoom.zoomAndPanByRatio(2, 0); 236 | } 237 | 238 | zoom.zoomOut = function () { 239 | zoom.zoomAndPanByRatio(0.5, 0); 240 | } 241 | 242 | zoom.panLeft = function () { 243 | zoom.zoomAndPanByRatio(1, -1); 244 | } 245 | zoom.panRight = function () { 246 | zoom.zoomAndPanByRatio(1, 1); 247 | } 248 | 249 | zoom.reset = function () { 250 | _left = 0; 251 | _zoom2_factor = 1; 252 | applyZoomAndPan(); 253 | } 254 | 255 | function doZoom() { 256 | if (deltaY != 0) { 257 | var maxDelta = 10; 258 | var multiply = (maxDelta + deltaY) / maxDelta; 259 | //var factor = chart.zoom2.factor()*multiply; 260 | //factor= Math.ceil(factor*100) / 100; 261 | console.log({ deltaY: deltaY, multiply: multiply }); 262 | zoom.zoomAndPanByRatio(multiply, leftRatio);//0.5);//leftRatio); 263 | deltaY = 0; 264 | } 265 | } 266 | 267 | function element_mousewheel(e) { 268 | deltaY += e.deltaY; 269 | leftRatio = (e.offsetX - 70) / (e.currentTarget.offsetWidth - 70); 270 | //console.log({ "e.offsetX": e.offsetX, "e.currentTarget.offsetWidth": e.currentTarget.offsetWidth, leftRatio: leftRatio }); 271 | mousewheelTimer.set(150); 272 | e.preventDefault(); 273 | } 274 | 275 | if (bindto != null) { 276 | element = $(options.bindto); 277 | if (element.mousewheel) { 278 | mousewheelTimer = new Timer(doZoom); 279 | element.mousewheel(element_mousewheel); 280 | } 281 | } 282 | 283 | return zoom; 284 | 285 | } 286 | 287 | if (typeof (Q) == "undefined") { 288 | var Q = function () { 289 | }; 290 | 291 | Q.copy = function (src, target, options, depth) { 292 | ///Copies an object into a target object, recursively cloning any object or array in the way, overwrite=true will overwrite a primitive field value even if exists 293 | /// 294 | /// 295 | ///{ overwrite:false } 296 | ///The copied object 297 | if (depth == null) 298 | depth = 0; 299 | if (depth == 100) { 300 | console.warn("Q.copy is in depth of 100 - possible circular reference") 301 | } 302 | options = options || { overwrite: false }; 303 | if (src == target || src == null) 304 | return target; 305 | if (typeof (src) != "object") { 306 | if (options.overwrite || target == null) 307 | return src; 308 | return target; 309 | } 310 | if (typeof (src.clone) == "function") { 311 | if (options.overwrite || target == null) 312 | return src.clone(); 313 | return target; 314 | } 315 | if (target == null) { 316 | if (src instanceof Array) 317 | target = []; 318 | else 319 | target = {}; 320 | } 321 | 322 | if (src instanceof Array) { 323 | for (var i = 0; i < src.length; i++) { 324 | var item = src[i]; 325 | var item2 = target[i]; 326 | item2 = Q.copy(item, item2, options, depth + 1); 327 | target[i] = item2; 328 | } 329 | target.splice(src.length, target.length - src.length); 330 | return target; 331 | } 332 | for (var p in src) { 333 | var value = src[p]; 334 | var value2 = target[p]; 335 | value2 = Q.copy(value, value2, options, depth + 1); 336 | target[p] = value2; 337 | } 338 | return target; 339 | } 340 | } 341 | if (typeof (Timer) == "undefined") { 342 | var Timer = function (action, ms) { 343 | this.action = action; 344 | if (ms != null) 345 | this.set(ms); 346 | } 347 | 348 | Timer.prototype.set = function (ms) { 349 | if (ms == null) 350 | ms = this._ms; 351 | else 352 | this._ms = ms; 353 | this.clear(); 354 | if (ms == null) 355 | return; 356 | this.timeout = window.setTimeout(this.onTick.bind(this), ms); 357 | } 358 | 359 | Timer.prototype.onTick = function () { 360 | this.clear(); 361 | this.action(); 362 | } 363 | 364 | Timer.prototype.clear = function (ms) { 365 | if (this.timeout == null) 366 | return; 367 | window.clearTimeout(this.timeout); 368 | this.timeout = null; 369 | } 370 | } 371 | if (typeof(Array.prototype.splitIntoChunksOf)=="undefined") { 372 | Array.prototype.splitIntoChunksOf = function (countInEachChunk) { 373 | var chunks = Math.ceil(this.length / countInEachChunk); 374 | var list = []; 375 | for (var i = 0; i < this.length; i += countInEachChunk) { 376 | list.push(this.slice(i, i + countInEachChunk)); 377 | } 378 | return list; 379 | } 380 | } -------------------------------------------------------------------------------- /money_templates/static/vendor/d3/LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2010-2015, Michael Bostock 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without 5 | modification, are permitted provided that the following conditions are met: 6 | 7 | * Redistributions of source code must retain the above copyright notice, this 8 | list of conditions and the following disclaimer. 9 | 10 | * Redistributions in binary form must reproduce the above copyright notice, 11 | this list of conditions and the following disclaimer in the documentation 12 | and/or other materials provided with the distribution. 13 | 14 | * The name Michael Bostock may not be used to endorse or promote products 15 | derived from this software without specific prior written permission. 16 | 17 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 18 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 19 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 20 | DISCLAIMED. IN NO EVENT SHALL MICHAEL BOSTOCK BE LIABLE FOR ANY DIRECT, 21 | INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, 22 | BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, 23 | DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY 24 | OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING 25 | NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, 26 | EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 27 | -------------------------------------------------------------------------------- /money_templates/templates/account_block.html: -------------------------------------------------------------------------------- 1 | {% load template_extras %} 2 | 3 | 4 | 5 | Account{% if accounts|length > 1 %} {{ forloop.counter }}{% endif %}: 6 | 7 | {% if showing_index or accounts|length > 1 %} 8 | {{ account.description_or_name }} 9 | {% else %} 10 | {{ account.description_or_name }} 11 | {% endif %} 12 | 13 | 14 | 15 | Type: 16 | {{ account.type.title }} 17 | 18 | 19 | Balance: 20 | 21 | 22 | {{ account.balance|format_dollar_amount_neg }} 23 | {% if account.balance == account.last_update.balance %} 24 | 25 | {% else %} 26 | {% if account.has_updates %} 27 | {% if account.last_update.balance == None %} 28 | ? 29 | {% else %} 30 | 32 | {% endif %} 33 | {% endif %} 34 | {% endif %} 35 | 36 | 37 | 38 | {% if account.has_updates %} 39 | 40 | As of: 41 | {{ account.last_update.updated|utc_to_local|format_date_time }} 42 | 43 | {% endif %} 44 | 45 | Changed: 46 | 47 | {% if account.last_transaction_date %} 48 | {{ account.last_transaction_date|utc_to_local|format_date_time }} 49 | {% else %} 50 | (no transactions) 51 | {% endif %} 52 | 53 | 54 | 55 | 56 | 57 | -------------------------------------------------------------------------------- /money_templates/templates/filter_form.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | {% for field in filter_form.visible_fields %} 4 | {% if field.html_name = 'opposing_accounts' %} 5 | 6 | 7 | Opposing account: 8 | 9 | 10 | 11 | select multiple 12 | 13 | 14 | {% endif %} 15 | 16 | {{ field.label_tag }}: 17 | 18 | {{ field.errors }} 19 | {{ field }} 20 | 21 | 22 | {% endfor %} 23 | 24 | {% for field in filter_form.hidden_fields %} 25 | {{ field }} 26 | {% endfor %} 27 | 28 | clear filters 29 | 30 | -------------------------------------------------------------------------------- /money_templates/templates/hidden_filter_form.html: -------------------------------------------------------------------------------- 1 | 2 | {{ hidden_filter_form.as_p }} 3 | 4 | 5 | NOTE: Clicking the button above will show all of the transactions 6 | that were just modified, but the list may also include some additional 7 | transactions that already met all of the criteria for the current filter. 8 | 9 | 10 | -------------------------------------------------------------------------------- /money_templates/templates/login.html: -------------------------------------------------------------------------------- 1 | {# adapted from https://docs.djangoproject.com/en/1.2/topics/auth/#django.contrib.auth.views.login #} 2 | 3 | {# {% extends "base.html" %} #} 4 | {# {% load url from future %} #} 5 | 6 | {# {% block content %} #} 7 | 8 | {% if form.errors %} 9 | Your username and password didn't match. Please try again. 10 | {% endif %} 11 | 12 | 13 | {% csrf_token %} 14 | 15 | 16 | {{ form.username.label_tag }} 17 | {{ form.username }} 18 | 19 | 20 | {{ form.password.label_tag }} 21 | {{ form.password }} 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | {# {% endblock %} #} 30 | -------------------------------------------------------------------------------- /money_templates/templates/modify_form.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | {{ modify_form.as_table }} 4 | 5 | {% csrf_token %} 6 | 7 | WARNING: This will change ALL of the currently visible 8 | transactions on ALL pages! (If an amount range is provided, 9 | only transactions within that range will be modified). 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /money_templates/templates/new_transaction_form.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | {% for field in new_transaction_form.visible_fields %} 4 | 5 | {{ field.label_tag }}: 6 | 7 | {{ field.errors }} 8 | {{ field }} 9 | 10 | 11 | {% endfor %} 12 | 13 | {% for field in new_transaction_form.hidden_fields %} 14 | {{ field }} 15 | {% endfor %} 16 | {% csrf_token %} 17 | 18 | copy from transaction 19 | 20 | -------------------------------------------------------------------------------- /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 | 36 | {% for account in all_accounts %} 37 | {{ account.path }} 38 | {% endfor %} 39 | 40 | 41 | Back to index 42 | 43 | {% for account in accounts %} 44 | {% include "account_block.html" %} 45 | {% endfor %} 46 | {% if accounts|length > 1 %} 47 | 48 | Total balance: 49 | {{ total_balance|format_dollar_amount_neg }} 50 | 51 | {% endif %} 52 | {% include "txactions_chart.html" %} 53 | {% include "txactions_page_block.html" %} 54 | 55 | 56 | Filters 57 | Modify 58 | Other 59 | 60 | 61 | 62 | {% include "filter_form.html" %} 63 | 64 | 65 | {% include "modify_form.html" %} 66 | 67 | 68 | 69 | 70 | 71 | Batch categorize transactions 72 | 73 | 74 | 75 | 76 | Download data 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | Date 86 | Opposing account 87 | Amount 88 | 89 | 90 | {% for split in page.object_list %} 91 | 92 | 93 | {% if accounts|length > 1 %} 94 | {{ split.account|index1_in:accounts }} 95 | {% endif %} 96 | {{ split.transaction.description }} 97 | 99 | 100 | 101 | 102 | 104 | 105 | {{ split.transaction.file_set.count }} 106 | 107 | 108 | {% if not split.transaction.any_split_has_memo %} 109 | 110 | {% endif %} 111 | 112 | 113 | 114 | {% for memo_split in split.transaction.splits %} 115 | 118 | 119 | 121 | 122 | {{ memo_split.memo }} 123 | 124 | 125 | {% endfor %} 126 | 127 | 129 | 130 | 131 | {{ split.transaction.post_date|format_date }} 132 | 133 | 134 | {% if not one_opposing_account_filter_applied %} 135 | 137 | 138 | 139 | 140 | {% endif %} 141 | {% if split.opposing_account %} 142 | 144 | 145 | {{ split.opposing_account.description_or_name }} 146 | 147 | 149 | {% else %} 150 | No opposing account 151 | {% endif %} 152 | 153 | 155 | 156 | {{ split.amount|format_decimal }} 157 | 158 | 159 | {% endfor %} 160 | 161 | 162 | 163 | {% include "txactions_page_block.html" %} 164 | {% if can_add_transactions %} 165 | 166 | 167 | Add new transaction 168 | 169 | {% include "new_transaction_form.html" %} 170 | 171 | {% endif %} 172 | {% endblock %} 173 | -------------------------------------------------------------------------------- /money_templates/templates/page_apply_categorize.html: -------------------------------------------------------------------------------- 1 | {% extends "page_base.html" %} 2 | 3 | {% block title %}Categorize multiple transactions{% endblock %} 4 | 5 | {% block body %} 6 | 7 | 8 | Modified {{ modified_tx_count }} transaction{% if modified_tx_count != 1 %}s{% endif %} 9 | and created {{ rule_count }} rule{% if rule_count != 1 %}s{% endif %}. 10 | 11 | 12 | 13 | 14 | 15 | 16 | Back to account 17 | 18 | 19 | 20 | 21 | Categorize more transactions 22 | 23 | 24 | 25 | 26 | {% endblock %} 27 | -------------------------------------------------------------------------------- /money_templates/templates/page_base.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | {% block title %}Account balances{% endblock %} 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | {% block scripts %} 16 | {% endblock %} 17 | 18 | 19 | {% block body %} 20 | {% endblock %} 21 | 22 | 23 | -------------------------------------------------------------------------------- /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 | 22 | 23 | 24 | Back to account 25 | 26 | 27 | {% if no_merchants %} 28 | No uncategorized transactions. 29 | {% else %} 30 | 31 | {% for f in batch_modify_form.visible_fields %} 32 | 33 | 34 | 35 | {{ f.field.merchant_info.index }}. 36 | 37 | {{ f.field.merchant_info.description }} 38 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | {{ f.field.merchant_info.count }} 47 | 48 | transaction{% if f.field.merchant_info.count != 1 %}s{% endif %} 49 | | 50 | 51 | {{ f.field.merchant_info.amount|format_dollar_amount }} 52 | 53 | 54 | 55 | 56 | {{ f.errors }} 57 | {{ f }} 58 | 59 | 60 | {% endfor %} 61 | 62 | {% endif %} 63 | {% csrf_token %} 64 | 65 | 66 | {% for f in batch_modify_form.hidden_fields %} 67 | {{ f }} 68 | {% endfor %} 69 | 70 | Back to account 71 | 72 | 73 | {% endblock body %} 74 | -------------------------------------------------------------------------------- /money_templates/templates/page_index.html: -------------------------------------------------------------------------------- 1 | {% extends "page_base.html" %} 2 | 3 | {% block title %}Account balances{% endblock %} 4 | 5 | {% block scripts %} 6 | 7 | 8 | {% endblock %} 9 | 10 | {% block body %} 11 | {% for account in accounts %} 12 | {% include "account_block.html" %} 13 | {% endfor %} 14 | 15 | Other account: 16 | 17 | 18 | 19 | 20 | 21 | (all) 22 | 23 | 24 | {% for account in all_accounts %} 25 | {% if not account.placeholder %} 26 | 27 | 28 | 29 | {{ account.path }} 30 | 31 | 32 | {% endif %} 33 | {% endfor %} 34 | 35 | 36 | 37 | 38 | {% endblock %} 39 | -------------------------------------------------------------------------------- /money_templates/templates/page_modify.html: -------------------------------------------------------------------------------- 1 | {% extends "page_base.html" %} 2 | 3 | {% block title %}Modify account{% endblock %} 4 | 5 | {% block body %} 6 | {% if errors %} 7 | 8 | 9 | Error(s) occurred: 10 | {{ errors }} 11 | 12 | 13 | {% endif %} 14 | 15 | {% if modified_tx_count %} 16 | 17 | Modified {{ modified_tx_count }} transaction{% if modified_tx_count != 1 %}s{% endif %} 18 | in account {{ account.path }} - set opposing account to 19 | {{ opposing_account.path }}. 20 | 21 | {% else %} 22 | 23 | No transactions were modified. 24 | 25 | {% endif %} 26 | 27 | 28 | {% include "hidden_filter_form.html" %} 29 | 30 | {% endblock %} 31 | -------------------------------------------------------------------------------- /money_templates/templates/page_txaction_files.html: -------------------------------------------------------------------------------- 1 | {% extends "page_base.html" %} 2 | 3 | {% load template_extras %} 4 | 5 | {% block title %} 6 | Transaction attachments - {{ transaction }} 7 | {% endblock %} 8 | 9 | {% block scripts %} 10 | 11 | {% endblock %} 12 | 13 | {% block body %} 14 | 15 | {{ transaction.post_date|format_date }} {{ transaction.description }} for {{ transaction.splits.0.amount|format_decimal }} 16 | This transaction has {{ transaction.file_set.count }} 17 | attachment{{ transaction.file_set.count|pluralize }}. 18 | 19 | 20 | 21 | 22 | {% for file in transaction.file_set.all %} 23 | 24 | {% if file.extension == '.pdf' %} 25 | {{ file.filename }} 26 | {% else %} 27 | {{ file.filename }} 28 | {% endif %} 29 | | 30 | delete 32 | 33 | {% if file.extension != '.pdf' %} 34 | 35 | {% endif %} 36 | 37 | {% endfor %} 38 | 39 | 40 | Upload image or PDF: 41 | {# http://stackoverflow.com/a/12673480/106302 #} 42 | 43 | 44 | {% csrf_token %} 45 | 46 | 47 | {% endblock %} 48 | -------------------------------------------------------------------------------- /money_templates/templates/txactions_chart.html: -------------------------------------------------------------------------------- 1 | 2 | Load "Expenses" and "Income" charts 3 | 4 | 5 | Chart: Expenses 6 | 7 | Loading expenses chart... 8 | 9 | 10 | Chart: Income 11 | 12 | Loading income chart... 13 | 14 | 15 | Chart controls 16 | 17 | 18 | Period: 19 | weekly | 20 | biweekly | 21 | monthly 22 | 23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /money_templates/templates/txactions_page_block.html: -------------------------------------------------------------------------------- 1 | {% load query_string %} 2 | 3 | Transactions {{ page.start_index }}-{{ page.end_index }} of {{ page.paginator.count }} 4 | (page {{ page.number }} of {{ page.paginator.num_pages }}) 5 | {% if page.has_previous %} 6 | | first 7 | | previous 8 | {% endif %} 9 | {% if page.has_next %} 10 | | next 11 | | last 12 | {% endif %} 13 | 14 | -------------------------------------------------------------------------------- /money_views/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nylen/gnucash-django/2ff14e68cd0236a5c675d202909d8d5fff5abc72/money_views/__init__.py -------------------------------------------------------------------------------- /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_views/filters.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import itertools 3 | import operator 4 | import re 5 | from decimal import Decimal 6 | 7 | from django.db.models import F, Q, Count, Sum 8 | 9 | import settings 10 | from gnucash_data.models import Split, Lock, Rule, RuleAccount, Transaction 11 | 12 | 13 | class TransactionSplitFilter(): 14 | REGEX_CHARS = '^$()[]?*+|\\' 15 | 16 | def __init__(self, accounts): 17 | self.accounts = accounts 18 | self.splits = reduce(operator.or_, 19 | (a.split_set.select_related(depth=3) for a in accounts)) 20 | if len(self.accounts) > 1: 21 | Transaction.cache_from_splits(self.splits) 22 | exclude_split_guids = [] 23 | account_guids = [a.guid for a in self.accounts] 24 | for s in self.splits: 25 | if any(os != s and os.account in self.accounts for os in s.transaction.splits): 26 | exclude_split_guids.append(s.guid) 27 | self.splits = self.splits.exclude(guid__in=exclude_split_guids) 28 | self.filtered_splits = self.splits 29 | self.any_filters_applied = False 30 | self.one_opposing_account_filter_applied = False 31 | 32 | def filter_splits(self, data): 33 | self.opposing_account_guids = data['opposing_accounts'] 34 | if self.opposing_account_guids and 'all' not in self.opposing_account_guids: 35 | if any(a.guid in self.opposing_account_guids for a in self.accounts): 36 | raise ValueError('Tried to filter transactions on account = opposing_account') 37 | self.any_filters_applied = True 38 | if len(self.opposing_account_guids) == 1: 39 | self.one_opposing_account_filter_applied = True 40 | self.filtered_splits = \ 41 | self.filtered_splits.filter(transaction__split__account__guid__in=self.opposing_account_guids) 42 | 43 | self.tx_desc = data['tx_desc'] 44 | if self.tx_desc: 45 | self.any_filters_applied = True 46 | self.tx_desc_is_regex = TransactionSplitFilter.tx_desc_is_regex(self.tx_desc) 47 | if self.tx_desc_is_regex: 48 | self.filtered_splits = \ 49 | self.filtered_splits.filter(transaction__description__iregex=self.tx_desc) 50 | else: 51 | self.filtered_splits = \ 52 | self.filtered_splits.filter(transaction__description__icontains=self.tx_desc) 53 | 54 | self.min_date = data['min_date'] 55 | if self.min_date: 56 | self.any_filters_applied = True 57 | self.filtered_splits = \ 58 | self.filtered_splits.filter(transaction__post_date__gte=self.min_date) 59 | 60 | self.max_date = data['max_date'] 61 | if self.max_date: 62 | self.any_filters_applied = True 63 | # Yes, this is weird. No, it doesn't work otherwise. 64 | self.filtered_splits = \ 65 | self.filtered_splits.filter(transaction__post_date__lt=self.max_date + datetime.timedelta(days=1)) 66 | 67 | self.min_amount = data['min_amount'] 68 | self.max_amount = data['max_amount'] 69 | # ugh 70 | if self.min_amount: 71 | self.any_filters_applied = True 72 | self.min_amount -= Decimal('1e-8') 73 | if self.max_amount: 74 | self.any_filters_applied = True 75 | self.max_amount += Decimal('1e-8') 76 | 77 | if self.min_amount and self.max_amount: 78 | self.filtered_splits = self.filtered_splits.filter( 79 | (Q(value_num__gte=F('value_denom') * self.min_amount) & Q(value_num__lte=F('value_denom') * self.max_amount)) | 80 | (Q(value_num__lte=F('value_denom') * -self.min_amount) & Q(value_num__gte=F('value_denom') * -self.max_amount))) 81 | elif self.min_amount: 82 | self.filtered_splits = self.filtered_splits.filter( 83 | (Q(value_num__gte=F('value_denom') * self.min_amount)) | 84 | (Q(value_num__lte=F('value_denom') * -self.min_amount))) 85 | elif self.max_amount: 86 | self.filtered_splits = self.filtered_splits.filter( 87 | (Q(value_num__lte=F('value_denom') * self.max_amount)) | 88 | (Q(value_num__gte=F('value_denom') * -self.max_amount))) 89 | 90 | @staticmethod 91 | def _ordered_splits(splits): 92 | return splits.order_by( 93 | 'transaction__post_date', 94 | 'transaction__enter_date', 95 | 'guid').reverse() 96 | 97 | def order_splits(self): 98 | self.splits = TransactionSplitFilter._ordered_splits(self.splits) 99 | 100 | def order_filtered_splits(self): 101 | self.filtered_splits = TransactionSplitFilter._ordered_splits(self.filtered_splits) 102 | 103 | @staticmethod 104 | def tx_desc_is_regex(tx_desc): 105 | for c in TransactionSplitFilter.REGEX_CHARS: 106 | if c in tx_desc: 107 | return True 108 | return False 109 | 110 | def get_merchants_info(self, opposing_account): 111 | opposing_splits = self.splits \ 112 | .filter(transaction__split__account=opposing_account) \ 113 | .select_related(depth=3) 114 | groups = opposing_splits.values('transaction__description', 'value_denom') \ 115 | .annotate(count=Count('guid'), value_num=Sum('value_num')) \ 116 | .order_by('-count', 'value_denom', 'value_num', 'transaction__description') 117 | 118 | merchants = [] 119 | merchant = {'description': None} 120 | 121 | i = 0 122 | any_transactions = False 123 | for g in groups: 124 | any_transactions = True 125 | if merchant['description'] != g['transaction__description']: 126 | if merchant['description'] != None: 127 | i += 1 128 | if i >= settings.NUM_MERCHANTS_BATCH_CATEGORIZE: 129 | break 130 | merchants.append(merchant) 131 | tx_desc = g['transaction__description'] 132 | if TransactionSplitFilter.tx_desc_is_regex(tx_desc): 133 | tx_desc = re.escape(tx_desc) 134 | merchant = { 135 | 'description': g['transaction__description'], 136 | 'tx_desc': tx_desc, 137 | 'count': 0, 138 | 'amount': Decimal(0), 139 | 'html_name': 'merchant_' + str(i), 140 | 'ref_html_name': 'merchant_name_' + str(i), 141 | 'index': i + 1, 142 | } 143 | merchant['count'] += g['count'] 144 | merchant['amount'] += Decimal(g['value_num']) / Decimal(g['value_denom']) 145 | 146 | if any_transactions: 147 | merchants.append(merchant) 148 | 149 | return merchants 150 | 151 | 152 | class RuleHelper(): 153 | @staticmethod 154 | def apply(**kwargs): 155 | splits = kwargs['splits'] 156 | accounts = splits.accounts 157 | tx_desc = kwargs.get('tx_desc', None) 158 | is_regex = kwargs.get('is_regex', False) 159 | opposing_account = kwargs['opposing_account'] 160 | min_amount = kwargs.get('min_amount', None) 161 | max_amount = kwargs.get('max_amount', None) 162 | save_rule = kwargs['save_rule'] 163 | 164 | if min_amount is None: 165 | min_amount = 0 166 | elif min_amount < 0: 167 | raise ValueError('min_amount (%s) < 0' % min_amount) 168 | if max_amount is None: 169 | max_amount = 0 170 | elif max_amount < 0: 171 | raise ValueError('max_amount (%s) < 0' % max_amount) 172 | if min_amount and max_amount and min_amount > max_amount: 173 | raise ValueError('min_amount (%s) > max_amount (%s)' % (min_amount, max_amount)) 174 | 175 | filtered_splits = splits.filtered_splits 176 | 177 | if tx_desc: 178 | # Need to do tx_desc filter ourselves 179 | if is_regex: 180 | filtered_splits = \ 181 | filtered_splits.filter(transaction__description__iregex=tx_desc) 182 | else: 183 | filtered_splits = \ 184 | filtered_splits.filter(transaction__description__icontains=tx_desc) 185 | else: 186 | # Any tx_desc filtering has already been done for us 187 | tx_desc = splits.tx_desc 188 | is_regex = splits.tx_desc_is_regex 189 | 190 | tx_guids = filtered_splits.distinct().values('transaction__guid') 191 | splits_real = Split.objects \ 192 | .filter(transaction__guid__in=tx_guids) \ 193 | .exclude(account__guid__in=[a.guid for a in accounts]) 194 | 195 | split_guids = list(splits_real.distinct().values_list('guid', flat=True)) 196 | tx_count = len(split_guids) 197 | modified_tx_count = 0 198 | 199 | if tx_count > 0: 200 | Lock.obtain() 201 | try: 202 | splits = Split.objects.filter(guid__in=split_guids) 203 | if opposing_account is None: 204 | tx_guids = list(splits.values_list('transaction__guid', flat=True)) 205 | Transaction.objects.filter(guid__in=tx_guids).delete() 206 | Split.objects.filter(guid__in=split_guids).delete() 207 | else: 208 | splits.update(account=opposing_account) 209 | modified_tx_count = tx_count 210 | finally: 211 | Lock.release() 212 | 213 | if save_rule and tx_desc: 214 | rule = Rule() 215 | if opposing_account: 216 | rule.opposing_account_guid = opposing_account.guid 217 | else: 218 | rule.opposing_account_guid = None 219 | rule.match_tx_desc = tx_desc 220 | rule.is_regex = is_regex 221 | if min_amount: rule.min_amount = min_amount 222 | if max_amount: rule.max_amount = max_amount 223 | rule.save() 224 | 225 | for a in accounts: 226 | rule_account = RuleAccount() 227 | rule_account.rule = rule 228 | rule_account.account_guid = a.guid 229 | rule_account.save() 230 | 231 | return modified_tx_count 232 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /money_views/views.py: -------------------------------------------------------------------------------- 1 | from dateutil import parser as dateparser 2 | from decimal import Decimal 3 | import json 4 | import os 5 | 6 | from django.contrib.auth.decorators import login_required 7 | from django.core.paginator import Paginator, EmptyPage, PageNotAnInteger 8 | from django.core.urlresolvers import reverse 9 | from django.http import HttpResponse 10 | from django.shortcuts import redirect 11 | from django.template import RequestContext, loader 12 | 13 | import api 14 | import filters 15 | import forms 16 | import settings 17 | 18 | from utils import misc_functions 19 | from gnucash_data.models import Account, Lock, Transaction 20 | 21 | 22 | set_home_for_gnucash_api = False 23 | 24 | 25 | @login_required 26 | def index(request): 27 | template = loader.get_template('page_index.html') 28 | accounts = [Account.from_path(path) for path in settings.ACCOUNTS_LIST] 29 | 30 | all_accounts = Account.get_all() 31 | all_accounts.sort(key=lambda a: a.path) 32 | 33 | c = RequestContext(request, { 34 | 'accounts': accounts, 35 | 'all_accounts': all_accounts, 36 | 'showing_index': True, 37 | }) 38 | return HttpResponse(template.render(c)) 39 | 40 | 41 | @login_required 42 | def any_account(request): 43 | key = request.GET.getlist('accounts') 44 | if len(key): 45 | return redirect(reverse( 46 | 'money_views.views.account', 47 | kwargs={'key': '+'.join(key)})) 48 | else: 49 | return redirect('money_views.views.index') 50 | 51 | 52 | @login_required 53 | def account(request, key): 54 | template = loader.get_template('page_account_details.html') 55 | 56 | accounts = misc_functions.get_accounts_by_webapp_key(key) 57 | splits = filters.TransactionSplitFilter(accounts) 58 | 59 | all_accounts = Account.get_all() 60 | all_accounts.sort(key=lambda a: a.path) 61 | all_accounts_dict = {} 62 | for a in all_accounts: 63 | all_accounts_dict[a.guid] = { 64 | 'path': a.path, 65 | 'name': a.name 66 | } 67 | 68 | choices = forms.AccountChoices(accounts) 69 | 70 | filter_form = forms.FilterForm(choices, request.GET) 71 | if filter_form.is_valid(): 72 | splits.filter_splits(filter_form.cleaned_data) 73 | 74 | splits.order_filtered_splits() 75 | 76 | modify_form_data = request.GET.copy() 77 | modify_form_data['save_rule'] = True 78 | modify_form = forms.ModifyForm(choices, modify_form_data, auto_id="modify_id_%s") 79 | 80 | try: 81 | can_add_transactions = settings.ENABLE_ADD_TRANSACTIONS 82 | except AttributeError: 83 | can_add_transactions = False 84 | 85 | can_add_transactions = (can_add_transactions and len(accounts) == 1) 86 | 87 | if can_add_transactions: 88 | new_transaction_form = forms.NewTransactionForm(choices, auto_id='id_new_trans_%s') 89 | else: 90 | new_transaction_form = None 91 | 92 | try: 93 | page_num = int(request.GET.get('page')) 94 | except: 95 | page_num = 1 96 | 97 | pages = Paginator(splits.filtered_splits, settings.NUM_TRANSACTIONS_PER_PAGE) 98 | 99 | try: 100 | page = pages.page(page_num) 101 | except PageNotAnInteger: 102 | page = pages.page(1) 103 | except EmptyPage: 104 | page = pages.page(pages.num_pages) 105 | 106 | Transaction.cache_from_splits(page.object_list) 107 | 108 | current_accounts_key = misc_functions.accounts_webapp_key(accounts) 109 | 110 | c = RequestContext(request, { 111 | 'any_filters_applied': splits.any_filters_applied, 112 | 'one_opposing_account_filter_applied': splits.one_opposing_account_filter_applied, 113 | 'query_params_js': json.dumps(request.GET), 114 | 'regex_chars_js': json.dumps(filters.TransactionSplitFilter.REGEX_CHARS), 115 | 'all_accounts': all_accounts, 116 | 'accounts_js': json.dumps(all_accounts_dict), 117 | 'current_accounts_js': json.dumps([a.guid for a in accounts]), 118 | 'num_transactions_js': json.dumps(page.paginator.count), 119 | 'api_functions_js': json.dumps(api.function_urls.urls_dict), 120 | 'accounts': accounts, 121 | 'current_accounts_key': current_accounts_key, 122 | 'current_accounts_key_js': json.dumps(current_accounts_key), 123 | 'can_add_transactions': can_add_transactions, 124 | 'account': accounts[0], 125 | 'page': page, 126 | 'filter_form': filter_form, 127 | 'modify_form': modify_form, 128 | 'new_transaction_form': new_transaction_form, 129 | 'total_balance': sum(a.balance for a in accounts), 130 | }) 131 | return HttpResponse(template.render(c)) 132 | 133 | 134 | @login_required 135 | def account_csv(request, key): 136 | accounts = misc_functions.get_accounts_by_webapp_key(key) 137 | splits = filters.TransactionSplitFilter(accounts) 138 | 139 | choices = forms.AccountChoices(accounts) 140 | 141 | filter_form = forms.FilterForm(choices, request.GET) 142 | if filter_form.is_valid(): 143 | splits.filter_splits(filter_form.cleaned_data) 144 | 145 | splits.order_filtered_splits() 146 | 147 | if 'inline' in request.GET: 148 | res = HttpResponse(content_type='text/plain') 149 | else: 150 | res = HttpResponse(content_type='text/csv') 151 | res['Content-Disposition'] = 'attachment; filename=accounts.csv' 152 | 153 | res.write('Account,OpposingAccount,Date,Description,Memo,Amount\n') 154 | 155 | for s in splits.filtered_splits.all(): 156 | # Determine the best memo to show, if any 157 | # TODO logic duplicated with money_views.api.get_transactions 158 | memo = '' 159 | if s.memo_is_id_or_blank: 160 | for memo_split in s.opposing_split_set: 161 | if not memo_split.memo_is_id_or_blank: 162 | memo = memo_split.memo 163 | break 164 | else: 165 | memo = s.memo 166 | # Send CSV row 167 | res.write(','.join(f.replace(',', ';').replace('"', '').replace('\n', ' ') for f in [ 168 | s.account.description_or_name, 169 | s.opposing_account.description_or_name, 170 | s.transaction.post_date.strftime('%m/%d/%Y'), 171 | s.transaction.description, 172 | memo, 173 | str(s.amount) 174 | ]) + '\n') 175 | 176 | return res 177 | 178 | 179 | @login_required 180 | def modify(request, key): 181 | template = loader.get_template('page_modify.html') 182 | 183 | accounts = misc_functions.get_accounts_by_webapp_key(key) 184 | splits = filters.TransactionSplitFilter(accounts) 185 | 186 | errors = False 187 | 188 | choices = forms.AccountChoices(accounts) 189 | 190 | opposing_account_guid = request.POST['change_opposing_account'] 191 | opposing_account = None 192 | try: 193 | if opposing_account_guid != 'DELETE': 194 | opposing_account = Account.get(opposing_account_guid) 195 | except Account.DoesNotExist: 196 | errors = "Account '%s' not found." % opposing_account_guid 197 | 198 | form_data = request.POST.copy() 199 | 200 | modified_tx_count = 0 201 | 202 | if not errors: 203 | modify_form = forms.ModifyForm(choices, request.POST) 204 | if modify_form.is_valid(): 205 | splits.filter_splits(modify_form.cleaned_data) 206 | 207 | save_rule = modify_form.cleaned_data['save_rule'] 208 | if save_rule and not splits.tx_desc: 209 | errors = 'Cannot save rule with no description filter.' 210 | save_rule = False 211 | 212 | modified_tx_count = filters.RuleHelper.apply( 213 | splits=splits, 214 | opposing_account=opposing_account, 215 | min_amount=modify_form.cleaned_data['min_amount'], 216 | max_amount=modify_form.cleaned_data['max_amount'], 217 | save_rule=save_rule) 218 | 219 | if modified_tx_count: 220 | form_data['opposing_accounts'] = opposing_account_guid 221 | 222 | else: 223 | # modify_form is not valid 224 | errors = str(modify_form.errors) 225 | 226 | hidden_filter_form = forms.HiddenFilterForm(choices, form_data) 227 | 228 | c = RequestContext(request, { 229 | 'accounts': accounts, 230 | 'current_accounts_key': misc_functions.accounts_webapp_key(accounts), 231 | 'opposing_account': opposing_account, 232 | 'hidden_filter_form': hidden_filter_form, 233 | 'errors': errors, 234 | 'modified_tx_count': modified_tx_count, 235 | }) 236 | return HttpResponse(template.render(c)) 237 | 238 | 239 | @login_required 240 | def batch_categorize(request, key): 241 | template = loader.get_template('page_batch_categorize.html') 242 | 243 | accounts = misc_functions.get_accounts_by_webapp_key(key) 244 | splits = filters.TransactionSplitFilter(accounts) 245 | 246 | imbalance = Account.from_path('Imbalance-USD') 247 | choices = forms.AccountChoices(accounts, exclude=imbalance) 248 | 249 | merchants = splits.get_merchants_info(imbalance) 250 | no_merchants = (len(merchants) == 0) 251 | batch_modify_form = forms.BatchModifyForm(choices, merchants) 252 | 253 | c = RequestContext(request, { 254 | 'accounts': accounts, 255 | 'current_accounts_key': misc_functions.accounts_webapp_key(accounts), 256 | 'batch_modify_form': batch_modify_form, 257 | 'no_merchants': no_merchants, 258 | 'imbalance': imbalance, 259 | }) 260 | return HttpResponse(template.render(c)) 261 | 262 | 263 | @login_required 264 | def apply_categorize(request, key): 265 | template = loader.get_template('page_apply_categorize.html') 266 | 267 | accounts = misc_functions.get_accounts_by_webapp_key(key) 268 | splits = filters.TransactionSplitFilter(accounts) 269 | 270 | imbalance = Account.from_path('Imbalance-USD') 271 | choices = forms.AccountChoices(accounts, exclude=imbalance) 272 | 273 | merchants = splits.get_merchants_info(imbalance) 274 | batch_modify_form = forms.BatchModifyForm(choices, merchants, request.POST) 275 | 276 | if not batch_modify_form.is_valid(): 277 | raise ValueError(batch_modify_form.errors) 278 | 279 | modified_tx_count = 0 280 | rule_count = 0 281 | 282 | for i in range(settings.NUM_MERCHANTS_BATCH_CATEGORIZE): 283 | if 'merchant_' + str(i) in batch_modify_form.cleaned_data: 284 | tx_desc = batch_modify_form.cleaned_data['merchant_name_' + str(i)] 285 | opposing_account_guid = batch_modify_form.cleaned_data['merchant_' + str(i)] 286 | if opposing_account_guid: 287 | rule_count += 1 288 | opposing_account = None 289 | if opposing_account_guid != 'DELETE': 290 | opposing_account = Account.get(opposing_account_guid) 291 | modified_tx_count += filters.RuleHelper.apply( 292 | splits=splits, 293 | tx_desc=tx_desc, 294 | opposing_account=opposing_account, 295 | save_rule=True) 296 | 297 | c = RequestContext(request, { 298 | 'accounts': accounts, 299 | 'current_accounts_key': misc_functions.accounts_webapp_key(accounts), 300 | 'modified_tx_count': modified_tx_count, 301 | 'rule_count': rule_count, 302 | }) 303 | return HttpResponse(template.render(c)) 304 | 305 | 306 | @login_required 307 | def new_transaction(request, key): 308 | accounts = misc_functions.get_accounts_by_webapp_key(key) 309 | if len(accounts) != 1: 310 | raise ValueError('Can only create transactions for 1 account at a time.') 311 | src_account = accounts[0] 312 | 313 | choices = forms.AccountChoices(accounts) 314 | 315 | new_tx_form = forms.NewTransactionForm(choices, request.POST) 316 | 317 | if not new_tx_form.is_valid(): 318 | raise ValueError(new_tx_form.errors) 319 | 320 | txinfo = new_tx_form.cleaned_data 321 | 322 | txinfo['amount'] = Decimal(txinfo['amount']) 323 | 324 | global set_home_for_gnucash_api 325 | if not set_home_for_gnucash_api: 326 | # Bad gnucash depends on $HOME (this dir needs to be writable by the webserver) 327 | os.environ['HOME'] = os.path.abspath(os.path.join( 328 | os.path.dirname(os.path.dirname(__file__)), 'gnucash_api_home')) 329 | set_home_for_gnucash_api = True 330 | 331 | import gnucash 332 | from gnucash_scripts import common 333 | 334 | # make sure we can begin a session 335 | Lock.check_can_obtain() 336 | 337 | # begin GnuCash API session 338 | session = gnucash.Session(settings.GNUCASH_CONN_STRING) 339 | 340 | try: 341 | book = session.book 342 | USD = book.get_table().lookup('ISO4217', 'USD') 343 | 344 | root = book.get_root_account() 345 | imbalance = common.get_account_by_path(root, 'Imbalance-USD') 346 | 347 | acct = common.get_account_by_guid(root, src_account.guid) 348 | opposing_acct = common.get_account_by_guid(root, txinfo['opposing_account']) 349 | gnc_amount = common.decimal_to_gnc_numeric(Decimal(txinfo['amount'])) 350 | 351 | # From example script 'test_imbalance_transaction.py' 352 | trans = gnucash.Transaction(book) 353 | trans.BeginEdit() 354 | trans.SetCurrency(USD) 355 | trans.SetDescription(str(txinfo['tx_desc'])) 356 | trans.SetDate( 357 | txinfo['post_date'].day, 358 | txinfo['post_date'].month, 359 | txinfo['post_date'].year) 360 | 361 | split1 = gnucash.Split(book) 362 | split1.SetParent(trans) 363 | split1.SetAccount(acct) 364 | if txinfo.has_key('memo'): 365 | split1.SetMemo(str(txinfo['memo'])) 366 | # The docs say both of these are needed: 367 | # http://svn.gnucash.org/docs/HEAD/group__Transaction.html 368 | split1.SetValue(gnc_amount) 369 | split1.SetAmount(gnc_amount) 370 | split1.SetReconcile('c') 371 | 372 | if opposing_acct != None: 373 | split2 = gnucash.Split(book) 374 | split2.SetParent(trans) 375 | split2.SetAccount(opposing_acct) 376 | split2.SetValue(gnc_amount.neg()) 377 | split2.SetAmount(gnc_amount.neg()) 378 | split2.SetReconcile('c') 379 | 380 | trans.CommitEdit() 381 | 382 | finally: 383 | session.end() 384 | session.destroy() 385 | 386 | dest_url = request.META.get('HTTP_REFERER') 387 | if not dest_url: 388 | dest_url = reverse('money_views.views.account', kwargs={'key': key}) 389 | 390 | return redirect(dest_url) 391 | 392 | 393 | @login_required 394 | def transaction_files(request, guid): 395 | template = loader.get_template('page_txaction_files.html') 396 | transaction = Transaction.objects.get(guid=guid) 397 | 398 | c = RequestContext(request, { 399 | 'transaction': transaction 400 | }) 401 | return HttpResponse(template.render(c)) 402 | 403 | 404 | @login_required 405 | def transaction_upload_file(request, guid): 406 | f = request.FILES.get('file') 407 | if f: 408 | Transaction.objects.get(guid=guid).attach_file(f) 409 | return redirect(reverse( 410 | 'money_views.views.transaction_files', 411 | kwargs={'guid': guid})) 412 | 413 | 414 | @login_required 415 | def transaction_delete_file(request, guid, hash): 416 | Transaction.objects.get(guid=guid).file_set.filter(hash=hash).delete() 417 | return redirect(reverse( 418 | 'money_views.views.transaction_files', 419 | kwargs={'guid': guid})) 420 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | Django==1.3 2 | MySQL-python==1.2.5 3 | argparse==1.2.1 4 | django-debug-toolbar==0.9.4 5 | psutil==0.6.1 6 | python-dateutil==2.1 7 | six==1.2.0 8 | wsgiref==0.1.2 9 | Pillow==2.6.1 10 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /urls.py: -------------------------------------------------------------------------------- 1 | from django.conf.urls.defaults import * 2 | 3 | # Uncomment the next two lines to enable the admin: 4 | from django.contrib import admin 5 | admin.autodiscover() 6 | 7 | urlpatterns = patterns('', 8 | (r'^$', 'money_views.views.index'), 9 | (r'^any_account$', 'money_views.views.any_account'), 10 | (r'^accounts/(?P[0-9a-f+]+)$', 'money_views.views.account'), 11 | (r'^accounts/(?P[0-9a-f+]+)/csv$', 'money_views.views.account_csv'), 12 | (r'^accounts/(?P[0-9a-f+]+)/modify$', 'money_views.views.modify'), 13 | (r'^accounts/(?P[0-9a-f+]+)/categorize$', 'money_views.views.batch_categorize'), 14 | (r'^accounts/(?P[0-9a-f+]+)/categorize/apply$', 'money_views.views.apply_categorize'), 15 | 16 | (r'^accounts/(?P[0-9a-f]+)/transactions/new$', 'money_views.views.new_transaction'), 17 | 18 | (r'^transaction/(?P[0-9a-f]+)/files$', 'money_views.views.transaction_files'), 19 | (r'^transaction/(?P[0-9a-f]+)/files/upload$', 'money_views.views.transaction_upload_file'), 20 | (r'^transaction/(?P[0-9a-f]+)/files/delete/(?P[0-9a-f]+)$', 'money_views.views.transaction_delete_file'), 21 | 22 | (r'^api/change_memo$', 'money_views.api.change_memo'), 23 | (r'^api/change_account$', 'money_views.api.change_account'), 24 | (r'^api/transactions$', 'money_views.api.get_transactions'), 25 | 26 | (r'^static/(?P.*)$', 'django.contrib.staticfiles.views.serve'), 27 | 28 | # Uncomment the admin/doc line below to enable admin documentation: 29 | (r'^admin/doc/', include('django.contrib.admindocs.urls')), 30 | 31 | # Uncomment the next line to enable the admin: 32 | (r'^admin/', include(admin.site.urls)), 33 | 34 | # Login 35 | (r'^accounts/login/$', 'django.contrib.auth.views.login', {'template_name': 'login.html'}), 36 | ) 37 | -------------------------------------------------------------------------------- /utils/AsciiDammit.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python2 2 | #coding=utf-8 3 | """ASCII, Dammit 4 | 5 | Stupid library to turn MS chars (like smart quotes) and ISO-Latin 6 | chars into ASCII, dammit. Will do plain text approximations, or more 7 | accurate HTML representations. Can also be jiggered to just fix the 8 | smart quotes and leave the rest of ISO-Latin alone. Now fixed to 9 | handle unicode strings correctly 10 | 11 | Sources: 12 | http://www.cs.tut.fi/~jkorpela/latin1/all.html 13 | http://www.webreference.com/html/reference/character/isolat1.html 14 | 15 | 1.0 Initial Release (2004-11-28) 16 | 1.1 Fixed handling of unicode strings (2012-01-15) - Tom Najdek 17 | 18 | The author hereby irrevocably places this work in the public domain. 19 | To the extent that this statement does not divest the copyright, 20 | the copyright holder hereby grants irrevocably to every recipient 21 | all rights in this work otherwise reserved under copyright. 22 | """ 23 | 24 | __author__ = "Leonard Richardson (leonardr@segfault.org)" 25 | __version__ = "$Revision: 1.3 $" 26 | __date__ = "$Date: 2009/04/28 10:45:03 $" 27 | __license__ = "Public domain" 28 | 29 | import re 30 | import string 31 | import types 32 | 33 | CHARS = { '\x80' : ('EUR', 'euro'), 34 | '\x81' : ' ', 35 | '\x82' : (',', 'sbquo'), 36 | '\x83' : ('f', 'fnof'), 37 | '\x84' : (',,', 'bdquo'), 38 | '\x85' : ('...', 'hellip'), 39 | '\x86' : ('+', 'dagger'), 40 | '\x87' : ('++', 'Dagger'), 41 | '\x88' : ('^', 'caret'), 42 | '\x89' : '%', 43 | '\x8A' : ('S', 'Scaron'), 44 | '\x8B' : ('<', 'lt;'), 45 | '\x8C' : ('OE', 'OElig'), 46 | '\x8D' : '?', 47 | '\x8E' : 'Z', 48 | '\x8F' : '?', 49 | '\x90' : '?', 50 | '\x91' : ("'", 'lsquo'), 51 | '\x92' : ("'", 'rsquo'), 52 | '\x93' : ('"', 'ldquo'), 53 | '\x94' : ('"', 'rdquo'), 54 | '\x95' : ('*', 'bull'), 55 | '\x96' : ('-', 'ndash'), 56 | '\x97' : ('--', 'mdash'), 57 | '\x98' : ('~', 'tilde'), 58 | '\x99' : ('(TM)', 'trade'), 59 | '\x9a' : ('s', 'scaron'), 60 | '\x9b' : ('>', 'gt'), 61 | '\x9c' : ('oe', 'oelig'), 62 | '\x9d' : '?', 63 | '\x9e' : 'z', 64 | '\x9f' : ('Y', 'Yuml'), 65 | '\xa0' : (' ', 'nbsp'), 66 | '\xa1' : ('!', 'iexcl'), 67 | '\xa2' : ('c', 'cent'), 68 | '\xa3' : ('GBP', 'pound'), 69 | '\xa4' : ('$', 'curren'), #This approximation is especially lame. 70 | '\xa5' : ('YEN', 'yen'), 71 | '\xa6' : ('|', 'brvbar'), 72 | '\xa7' : ('S', 'sect'), 73 | '\xa8' : ('..', 'uml'), 74 | '\xa9' : ('', 'copy'), 75 | '\xaa' : ('(th)', 'ordf'), 76 | '\xab' : ('<<', 'laquo'), 77 | '\xac' : ('!', 'not'), 78 | '\xad' : (' ', 'shy'), 79 | '\xae' : ('(R)', 'reg'), 80 | '\xaf' : ('-', 'macr'), 81 | '\xb0' : ('o', 'deg'), 82 | '\xb1' : ('+-', 'plusmm'), 83 | '\xb2' : ('2', 'sup2'), 84 | '\xb3' : ('3', 'sup3'), 85 | '\xb4' : ("'", 'acute'), 86 | '\xb5' : ('u', 'micro'), 87 | '\xb6' : ('P', 'para'), 88 | '\xb7' : ('*', 'middot'), 89 | '\xb8' : (',', 'cedil'), 90 | '\xb9' : ('1', 'sup1'), 91 | '\xba' : ('(th)', 'ordm'), 92 | '\xbb' : ('>>', 'raquo'), 93 | '\xbc' : ('1/4', 'frac14'), 94 | '\xbd' : ('1/2', 'frac12'), 95 | '\xbe' : ('3/4', 'frac34'), 96 | '\xbf' : ('?', 'iquest'), 97 | '\xc0' : ('A', "Agrave"), 98 | '\xc1' : ('A', "Aacute"), 99 | '\xc2' : ('A', "Acirc"), 100 | '\xc3' : ('A', "Atilde"), 101 | '\xc4' : ('A', "Auml"), 102 | '\xc5' : ('A', "Aring"), 103 | '\xc6' : ('AE', "Aelig"), 104 | '\xc7' : ('C', "Ccedil"), 105 | '\xc8' : ('E', "Egrave"), 106 | '\xc9' : ('E', "Eacute"), 107 | '\xca' : ('E', "Ecirc"), 108 | '\xcb' : ('E', "Euml"), 109 | '\xcc' : ('I', "Igrave"), 110 | '\xcd' : ('I', "Iacute"), 111 | '\xce' : ('I', "Icirc"), 112 | '\xcf' : ('I', "Iuml"), 113 | '\xd0' : ('D', "Eth"), 114 | '\xd1' : ('N', "Ntilde"), 115 | '\xd2' : ('O', "Ograve"), 116 | '\xd3' : ('O', "Oacute"), 117 | '\xd4' : ('O', "Ocirc"), 118 | '\xd5' : ('O', "Otilde"), 119 | '\xd6' : ('O', "Ouml"), 120 | '\xd7' : ('*', "times"), 121 | '\xd8' : ('O', "Oslash"), 122 | '\xd9' : ('U', "Ugrave"), 123 | '\xda' : ('U', "Uacute"), 124 | '\xdb' : ('U', "Ucirc"), 125 | '\xdc' : ('U', "Uuml"), 126 | '\xdd' : ('Y', "Yacute"), 127 | '\xde' : ('b', "Thorn"), 128 | '\xdf' : ('B', "szlig"), 129 | '\xe0' : ('a', "agrave"), 130 | '\xe1' : ('a', "aacute"), 131 | '\xe2' : ('a', "acirc"), 132 | '\xe3' : ('a', "atilde"), 133 | '\xe4' : ('a', "auml"), 134 | '\xe5' : ('a', "aring"), 135 | '\xe6' : ('ae', "aelig"), 136 | '\xe7' : ('c', "ccedil"), 137 | '\xe8' : ('e', "egrave"), 138 | '\xe9' : ('e', "eacute"), 139 | '\xea' : ('e', "ecirc"), 140 | '\xeb' : ('e', "euml"), 141 | '\xec' : ('i', "igrave"), 142 | '\xed' : ('i', "iacute"), 143 | '\xee' : ('i', "icirc"), 144 | '\xef' : ('i', "iuml"), 145 | '\xf0' : ('o', "eth"), 146 | '\xf1' : ('n', "ntilde"), 147 | '\xf2' : ('o', "ograve"), 148 | '\xf3' : ('o', "oacute"), 149 | '\xf4' : ('o', "ocirc"), 150 | '\xf5' : ('o', "otilde"), 151 | '\xf6' : ('o', "ouml"), 152 | '\xf7' : ('/', "divide"), 153 | '\xf8' : ('o', "oslash"), 154 | '\xf9' : ('u', "ugrave"), 155 | '\xfa' : ('u', "uacute"), 156 | '\xfb' : ('u', "ucirc"), 157 | '\xfc' : ('u', "uuml"), 158 | '\xfd' : ('y', "yacute"), 159 | '\xfe' : ('b', "thorn"), 160 | '\xff' : ('y', "yuml"), 161 | } 162 | 163 | def _makeRE(limit): 164 | """Returns a regular expression object that will match special characters 165 | up to the given limit.""" 166 | return re.compile("([\x80-\\x%s])" % limit, re.M) 167 | ALL = _makeRE('ff') 168 | ONLY_WINDOWS = _makeRE('9f') 169 | 170 | def _replHTML(match): 171 | "Replace the matched character with its HTML equivalent." 172 | return _repl(match, 1) 173 | 174 | def _repl(match, html=0): 175 | "Replace the matched character with its HTML or ASCII equivalent." 176 | g = match.group(0) 177 | a = CHARS.get(g,g) 178 | if type(a) == types.TupleType: 179 | a = a[html] 180 | if html: 181 | a = '&' + a + ';' 182 | return a 183 | 184 | def _dammit(t, html=0, fixWindowsOnly=0): 185 | "Turns ISO-Latin-1 into an ASCII representation, dammit." 186 | if(type(t) == unicode): 187 | t = t.encode('raw_unicode_escape') 188 | r = ALL 189 | if fixWindowsOnly: 190 | r = ONLY_WINDOWS 191 | m = _repl 192 | if html: 193 | m = _replHTML 194 | 195 | return re.sub(r, m, t) 196 | 197 | def asciiDammit(t, fixWindowsOnly=0): 198 | "Turns ISO-Latin-1 into a plain ASCII approximation, dammit." 199 | return _dammit(t, 0, fixWindowsOnly) 200 | 201 | def htmlDammit(t, fixWindowsOnly=0): 202 | "Turns ISO-Latin-1 into plain ASCII with HTML codes, dammit." 203 | return _dammit(t, 1, fixWindowsOnly=fixWindowsOnly) 204 | 205 | def demoronise(t): 206 | """Helper method named in honor of the original smart quotes 207 | remover, The Demoroniser: 208 | 209 | http://www.fourmilab.ch/webtools/demoroniser/""" 210 | return asciiDammit(t, 1) 211 | 212 | if __name__ == '__main__': 213 | french = '\x93Sacr\xe9 bleu!\x93' 214 | print "\nFirst we mangle some French." 215 | print asciiDammit(french) 216 | print htmlDammit(french) 217 | 218 | print "\nAnd now we fix the MS-quotes but leave the French alone." 219 | print demoronise(french) 220 | print htmlDammit(french, 1) 221 | 222 | print "\nLet's try some french in unicode" 223 | frenchu = u'sacré bleu' 224 | print asciiDammit(frenchu) 225 | 226 | dejavu = u'Déjà Vu' 227 | print "\nIt's usually a glitch in the Matrix. It happens when they change something." 228 | print asciiDammit(dejavu) 229 | -------------------------------------------------------------------------------- /utils/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nylen/gnucash-django/2ff14e68cd0236a5c675d202909d8d5fff5abc72/utils/__init__.py -------------------------------------------------------------------------------- /utils/data_url.py: -------------------------------------------------------------------------------- 1 | import base64 2 | import mimetypes 3 | import urllib 4 | 5 | # based on https://gist.github.com/panzi/4004353 6 | 7 | def parse(url): 8 | return DataUrl(url) 9 | 10 | class DataUrl(object): 11 | def __init__(self, url): 12 | self.url = url 13 | scheme, data = url.split(':', 1) 14 | assert scheme == 'data', 'Unsupported URL scheme: ' + scheme 15 | 16 | media_type, data = data.split(',', 1) 17 | media_type = media_type.split(';') 18 | self.data = urllib.unquote(data) 19 | self.is_base64 = False 20 | 21 | self.mime_type = media_type[0] 22 | for t in media_type[1:]: 23 | if t == 'base64': 24 | self.is_base64 = True 25 | # TODO: Handle charset= parameter? 26 | 27 | if self.is_base64: 28 | self.data = base64.b64decode(self.data) 29 | 30 | self.extension = mimetypes.guess_extension(self.mime_type) 31 | -------------------------------------------------------------------------------- /utils/misc_functions.py: -------------------------------------------------------------------------------- 1 | import calendar 2 | from dateutil import tz 3 | 4 | import settings 5 | 6 | from gnucash_data.models import Account 7 | 8 | def utc_to_local(utc): 9 | return utc.replace(tzinfo=tz.tzutc()).astimezone(tz.tzlocal()) 10 | 11 | def format_thousands(value, sep=','): 12 | s = str(value) 13 | if len(s) <= 3: return s 14 | return format_thousands(s[:-3], sep) + sep + s[-3:] 15 | 16 | def format_decimal(value): 17 | value = abs(value) 18 | cents = ('%.2f' % (value - int(value)))[1:] 19 | return '%s%s' % (format_thousands(int(value)), cents) 20 | 21 | def format_dollar_amount(value, allow_negative=False): 22 | if allow_negative and value < 0: 23 | sign = '-' 24 | else: 25 | sign = '' 26 | return sign + '$' + format_decimal(value) 27 | 28 | def format_date(date): 29 | return date.strftime('%m/%d/%y') 30 | 31 | def format_date_time(date): 32 | return date.strftime('%b %d, %Y %I:%M:%S %p') 33 | 34 | def index1_in(value, coll): 35 | return coll.index(value) + 1 36 | 37 | def get_accounts_by_webapp_key(key): 38 | return [get_account_by_webapp_key(k) for k in key.split('+')] 39 | 40 | def get_account_by_webapp_key(key): 41 | try: 42 | path = settings.ACCOUNTS_LIST[int(key)] 43 | return Account.from_path(path) 44 | except ValueError: 45 | return Account.get(key) 46 | 47 | def accounts_webapp_key(accounts): 48 | return '+'.join(a.webapp_key for a in accounts) 49 | 50 | def date_to_timestamp(d): 51 | return calendar.timegm(d.timetuple()) * 1000 + d.microsecond / 1000; 52 | -------------------------------------------------------------------------------- /utils/templatetags/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nylen/gnucash-django/2ff14e68cd0236a5c675d202909d8d5fff5abc72/utils/templatetags/__init__.py -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /utils/templatetags/template_extras.py: -------------------------------------------------------------------------------- 1 | from django import template 2 | 3 | from utils import misc_functions 4 | 5 | register = template.Library() 6 | register.filter('format_decimal' , misc_functions.format_decimal) 7 | register.filter('format_dollar_amount', misc_functions.format_dollar_amount) 8 | register.filter('format_date' , misc_functions.format_date) 9 | register.filter('format_date_time' , misc_functions.format_date_time) 10 | register.filter('index1_in' , misc_functions.index1_in) 11 | register.filter('utc_to_local' , misc_functions.utc_to_local) 12 | 13 | register.filter('format_dollar_amount_neg', 14 | lambda x: misc_functions.format_dollar_amount(x, True)) 15 | -------------------------------------------------------------------------------- /watch.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | cd "$(dirname "$0")" 4 | 5 | something_changed() { 6 | touch apache/money.wsgi 7 | echo "Updated `date`" 8 | } 9 | 10 | something_changed 11 | 12 | while true; do 13 | if inotifywait -r -e create,modify,delete,move --exclude '^\./(lib|\.git)/' . ; then 14 | something_changed 15 | fi 16 | done 17 | --------------------------------------------------------------------------------
Your username and password didn't match. Please try again.
167 | Add new transaction 168 |