From 5046ac67f1de9a93f420988ff1f2c40c39fb1e11 Mon Sep 17 00:00:00 2001 From: Aaron Guise Date: Thu, 8 May 2025 22:41:14 +1200 Subject: [PATCH] fix: Initial barebones UI and API running --- accounting/api.py | 523 ++++++++++++++++++++++++-------- accounting/templates/index.html | 38 ++- server.py | 50 ++- 3 files changed, 443 insertions(+), 168 deletions(-) diff --git a/accounting/api.py b/accounting/api.py index 0aa5b61..79a9719 100644 --- a/accounting/api.py +++ b/accounting/api.py @@ -1,161 +1,416 @@ import cherrypy from sqlalchemy.orm import sessionmaker from datetime import datetime -from accounting.models import Account, BankAccount, BankTransaction, JournalEntry, JournalEntryLine, ReconciliationReport, \ +from accounting.models import Account, BankAccount, BankTransaction, JournalEntry, JournalEntryLine, \ + ReconciliationReport, \ ReconciliationMatch -import simplejson as json +@cherrypy.tools.json_out() class AccountingAPI: + exposed = True + def __init__(self, db_engine): self.db_engine = db_engine Session = sessionmaker(bind=db_engine) self.session = Session() - @cherrypy.expose - @cherrypy.tools.json_out() - def index(self): + # Mount resource handlers as attributes - these become URL segments + self.accounts = AccountHandler(self.session) + self.bank_accounts = BankAccountHandler(self.session) + self.journal_entries = JournalEntryHandler(self.session) + + def GET(self): + """Handle GET requests to the API root""" return {"status": "success", "message": "Accounting API is running"} - # Bank Accounts Endpoints - @cherrypy.expose - @cherrypy.tools.json_out() - def bank_accounts(self): - if cherrypy.request.method != 'GET': - raise cherrypy.HTTPError(405) + def OPTIONS(self): + """Handle OPTIONS requests (CORS preflight) to the API root""" + cherrypy.response.headers['Allow'] = 'GET, OPTIONS' + return "" - accounts = self.session.query(BankAccount).all() - return [{ - 'id': a.id, - 'name': a.name, - 'bank_name': a.bank_name, - 'account_number': a.account_number, - 'currency': a.currency - } for a in accounts] - @cherrypy.expose - @cherrypy.tools.json_in() - @cherrypy.tools.json_out() - def add_bank_account(self): - if cherrypy.request.method != 'POST': - raise cherrypy.HTTPError(405) +@cherrypy.tools.json_out() +class AccountHandler: + exposed = True - data = cherrypy.request.json - account = BankAccount( - name=data['name'], - bank_name=data['bank_name'], - account_number=data['account_number'], - currency=data.get('currency', 'USD') - ) - self.session.add(account) - self.session.commit() - return {'status': 'success', 'id': account.id} + def __init__(self, session): + self.session = session - # Add OPTIONS method handler for CORS preflight - @cherrypy.expose - def add_bank_account_options(self): - cherrypy.response.headers['Allow'] = 'POST, OPTIONS' - cherrypy.response.headers['Access-Control-Allow-Headers'] = 'Content-Type' - return '' + def GET(self, account_id=None): + """ + GET /api/accounts - List all accounts + GET /api/accounts/123 - Get specific account + """ + if account_id is not None: + # Get specific account + account = self.session.query(Account).get(account_id) + if not account: + raise cherrypy.HTTPError(404, "Account not found") - # Accounts Endpoints - @cherrypy.expose - @cherrypy.tools.json_out() - def accounts(self): - if cherrypy.request.method != 'GET': - raise cherrypy.HTTPError(405) + return { + 'id': account.id, + 'name': account.name, + 'code': account.code, + 'type': account.account_type, + 'balance': float(account.balance) if account.balance else 0 + } + else: + # List all accounts + accounts = self.session.query(Account).all() + return [{ + 'id': a.id, + 'name': a.name, + 'code': a.code, + 'type': a.account_type, + 'balance': float(a.balance) if a.balance else 0 + } for a in accounts] - accounts = self.session.query(Account).all() - return [{ - 'id': a.id, - 'name': a.name, - 'code': a.code, - 'type': a.account_type, - 'balance': float(a.balance) if a.balance else 0 - } for a in accounts] - - @cherrypy.expose - @cherrypy.tools.json_in() - @cherrypy.tools.json_out() - def add_account(self): - if cherrypy.request.method != 'POST': - raise cherrypy.HTTPError(405) - - data = cherrypy.request.json - account = Account( - name=data['name'], - account_type=data['type'], - code=data['code'], - parent_id=data.get('parent_id') - ) - self.session.add(account) - self.session.commit() - return {'status': 'success', 'id': account.id} - - # Journal Entries Endpoints - @cherrypy.expose - @cherrypy.tools.json_in() - @cherrypy.tools.json_out() - def add_journal_entry(self): - if cherrypy.request.method != 'POST': - raise cherrypy.HTTPError(405) - - data = cherrypy.request.json - total_debit = sum(line['amount'] for line in data['lines'] if line['is_debit']) - total_credit = sum(line['amount'] for line in data['lines'] if not line['is_debit']) - - if abs(total_debit - total_credit) > 0.01: - return {'status': 'error', 'message': 'Debits and credits must balance'} - - entry = JournalEntry( - date=datetime.strptime(data['date'], '%Y-%m-%d').date(), - reference=data.get('reference', ''), - description=data.get('description', '') - ) - self.session.add(entry) - - for line_data in data['lines']: - line = JournalEntryLine( - journal_entry=entry, - account_id=line_data['account_id'], - amount=line_data['amount'], - is_debit=line_data['is_debit'] + def POST(self): + """Handle POST to create a new account""" + try: + data = cherrypy.request.json + account = Account( + name=data['name'], + account_type=data['type'], + code=data['code'], + parent_id=data.get('parent_id') ) - self.session.add(line) + self.session.add(account) + self.session.commit() + return {'status': 'success', 'id': account.id} + except Exception as e: + self.session.rollback() + raise cherrypy.HTTPError(400, str(e)) - account = self.session.query(Account).get(line_data['account_id']) - if line_data['is_debit']: - account.balance += line_data['amount'] - else: - account.balance -= line_data['amount'] + def PUT(self, account_id): + """Handle PUT to update an existing account""" + try: + account = self.session.query(Account).get(account_id) + if not account: + raise cherrypy.HTTPError(404, "Account not found") - self.session.commit() - return {'status': 'success', 'id': entry.id} + data = cherrypy.request.json + if 'name' in data: + account.name = data['name'] + if 'type' in data: + account.account_type = data['type'] + if 'code' in data: + account.code = data['code'] + if 'parent_id' in data: + account.parent_id = data['parent_id'] - @cherrypy.expose - @cherrypy.tools.json_out() - def journal_entries(self): - if cherrypy.request.method != 'GET': - raise cherrypy.HTTPError(405) + self.session.commit() + return {'status': 'success', 'id': account.id} + except Exception as e: + self.session.rollback() + raise cherrypy.HTTPError(400, str(e)) - entries = self.session.query(JournalEntry).all() - return [{ - 'id': e.id, - 'date': e.date.isoformat(), - 'description': e.description, - 'reference': e.reference, - 'lines': [{ - 'account_id': l.account_id, - 'amount': float(l.amount), - 'is_debit': l.is_debit - } for l in e.lines] - } for e in entries] + def DELETE(self, account_id): + """Handle DELETE to remove an account""" + try: + account = self.session.query(Account).get(account_id) + if not account: + raise cherrypy.HTTPError(404, "Account not found") - # Add default OPTIONS handler for all endpoints - @cherrypy.expose - def default(self, *args, **kwargs): - if cherrypy.request.method == 'OPTIONS': + # Check for dependencies before deleting + if self.session.query(JournalEntryLine).filter_by(account_id=account_id).count() > 0: + return {'status': 'error', 'message': 'Cannot delete account with journal entries'} + + self.session.delete(account) + self.session.commit() + return {'status': 'success'} + except Exception as e: + self.session.rollback() + raise cherrypy.HTTPError(400, str(e)) + + def OPTIONS(self, account_id=None): + """Handle OPTIONS requests (CORS preflight)""" + if account_id is not None: + cherrypy.response.headers['Allow'] = 'GET, PUT, DELETE, OPTIONS' + else: cherrypy.response.headers['Allow'] = 'GET, POST, OPTIONS' - cherrypy.response.headers['Access-Control-Allow-Headers'] = 'Content-Type' - return '' - raise cherrypy.HTTPError(404) \ No newline at end of file + return "" + + +@cherrypy.tools.json_out() +class BankAccountHandler: + exposed = True + + def __init__(self, session): + self.session = session + # Sub-resource for transactions + self.transactions = BankTransactionHandler(session) + + def GET(self, bank_account_id=None): + """ + GET /api/bank_accounts - List all bank accounts + GET /api/bank_accounts/123 - Get specific bank account + """ + if bank_account_id is not None: + # Get specific bank account + account = self.session.query(BankAccount).get(bank_account_id) + if not account: + raise cherrypy.HTTPError(404, "Bank account not found") + data = { + 'id': account.id, + 'name': account.name, + 'bank_name': account.bank_name, + 'account_number': account.account_number, + 'currency': account.currency + } + return data + else: + # List all bank accounts + accounts = self.session.query(BankAccount).all() + data = [{ + 'id': a.id, + 'name': a.name, + 'bank_name': a.bank_name, + 'account_number': a.account_number, + 'currency': a.currency + } for a in accounts] + return data + + def POST(self): + """Handle POST to create a new bank account""" + try: + data = cherrypy.request.json + account = BankAccount( + name=data['name'], + bank_name=data['bank_name'], + account_number=data['account_number'], + currency=data.get('currency', 'USD') + ) + self.session.add(account) + self.session.commit() + return {'status': 'success', 'id': account.id} + except Exception as e: + self.session.rollback() + raise cherrypy.HTTPError(400, str(e)) + + def PUT(self, bank_account_id): + """Handle PUT to update a bank account""" + try: + account = self.session.query(BankAccount).get(bank_account_id) + if not account: + raise cherrypy.HTTPError(404, "Bank account not found") + data = cherrypy.request.json + if 'name' in data: + account.name = data['name'] + if 'bank_name' in data: + account.bank_name = data['bank_name'] + if 'account_number' in data: + account.account_number = data['account_number'] + if 'currency' in data: + account.currency = data['currency'] + self.session.commit() + return {'status': 'success', 'id': account.id} + except Exception as e: + self.session.rollback() + raise cherrypy.HTTPError(400, str(e)) + + def DELETE(self, bank_account_id): + """Handle DELETE to remove a bank account""" + try: + account = self.session.query(BankAccount).get(bank_account_id) + if not account: + raise cherrypy.HTTPError(404, "Bank account not found") + # Check for dependencies before deleting + if self.session.query(BankTransaction).filter_by(bank_account_id=bank_account_id).count() > 0: + return {'status': 'error', 'message': 'Cannot delete bank account with transactions'} + self.session.delete(account) + self.session.commit() + return {'status': 'success'} + except Exception as e: + self.session.rollback() + raise cherrypy.HTTPError(400, str(e)) + + def OPTIONS(self, bank_account_id=None): + """Handle OPTIONS requests (CORS preflight)""" + if bank_account_id is not None: + cherrypy.response.headers['Allow'] = 'GET, PUT, DELETE, OPTIONS' + else: + cherrypy.response.headers['Allow'] = 'GET, POST, OPTIONS' + return "" + + +class BankTransactionHandler: + exposed = True + + def __init__(self, session): + self.session = session + + def GET(self, bank_account_id, transaction_id=None): + """ + GET /api/bank_accounts/123/transactions - List all transactions for bank account + GET /api/bank_accounts/123/transactions/456 - Get specific transaction + """ + if not self.session.query(BankAccount).get(bank_account_id): + raise cherrypy.HTTPError(404, "Bank account not found") + + if transaction_id is not None: + # Get specific transaction + transaction = self.session.query(BankTransaction).get(transaction_id) + if not transaction or transaction.bank_account_id != int(bank_account_id): + raise cherrypy.HTTPError(404, "Transaction not found") + + return { + 'id': transaction.id, + 'date': transaction.date.isoformat(), + 'description': transaction.description, + 'amount': float(transaction.amount), + 'bank_account_id': transaction.bank_account_id + } + else: + # List all transactions for the bank account + transactions = self.session.query(BankTransaction).filter_by(bank_account_id=bank_account_id).all() + return [{ + 'id': t.id, + 'date': t.date.isoformat(), + 'description': t.description, + 'amount': float(t.amount), + 'bank_account_id': t.bank_account_id + } for t in transactions] + + def POST(self, bank_account_id): + """Handle POST to create a new transaction""" + try: + if not self.session.query(BankAccount).get(bank_account_id): + raise cherrypy.HTTPError(404, "Bank account not found") + + data = cherrypy.request.json + transaction = BankTransaction( + bank_account_id=bank_account_id, + date=datetime.strptime(data['date'], '%Y-%m-%d').date(), + description=data['description'], + amount=data['amount'] + ) + self.session.add(transaction) + self.session.commit() + return {'status': 'success', 'id': transaction.id} + except Exception as e: + self.session.rollback() + raise cherrypy.HTTPError(400, str(e)) + + def OPTIONS(self, bank_account_id, transaction_id=None): + """Handle OPTIONS requests (CORS preflight)""" + if transaction_id is not None: + cherrypy.response.headers['Allow'] = 'GET, OPTIONS' + else: + cherrypy.response.headers['Allow'] = 'GET, POST, OPTIONS' + return "" + + +@cherrypy.tools.json_out() +class JournalEntryHandler: + exposed = True + + def __init__(self, session): + self.session = session + + def GET(self, entry_id=None): + """ + GET /api/journal_entries - List all journal entries + GET /api/journal_entries/123 - Get specific journal entry + """ + if entry_id is not None: + # Get specific journal entry + entry = self.session.query(JournalEntry).get(entry_id) + if not entry: + raise cherrypy.HTTPError(404, "Journal entry not found") + + return { + 'id': entry.id, + 'date': entry.date.isoformat(), + 'description': entry.description, + 'reference': entry.reference, + 'lines': [{ + 'account_id': l.account_id, + 'amount': float(l.amount), + 'is_debit': l.is_debit + } for l in entry.lines] + } + else: + # List all journal entries + entries = self.session.query(JournalEntry).all() + return [{ + 'id': e.id, + 'date': e.date.isoformat(), + 'description': e.description, + 'reference': e.reference, + 'lines': [{ + 'account_id': l.account_id, + 'amount': float(l.amount), + 'is_debit': l.is_debit + } for l in e.lines] + } for e in entries] + + def POST(self): + """Handle POST to create a new journal entry""" + try: + data = cherrypy.request.json + total_debit = sum(line['amount'] for line in data['lines'] if line['is_debit']) + total_credit = sum(line['amount'] for line in data['lines'] if not line['is_debit']) + + if abs(total_debit - total_credit) > 0.01: + return {'status': 'error', 'message': 'Debits and credits must balance'} + + entry = JournalEntry( + date=datetime.strptime(data['date'], '%Y-%m-%d').date(), + reference=data.get('reference', ''), + description=data.get('description', '') + ) + self.session.add(entry) + + for line_data in data['lines']: + line = JournalEntryLine( + journal_entry=entry, + account_id=line_data['account_id'], + amount=line_data['amount'], + is_debit=line_data['is_debit'] + ) + self.session.add(line) + + account = self.session.query(Account).get(line_data['account_id']) + if line_data['is_debit']: + account.balance += line_data['amount'] + else: + account.balance -= line_data['amount'] + + self.session.commit() + return {'status': 'success', 'id': entry.id} + except Exception as e: + self.session.rollback() + raise cherrypy.HTTPError(400, str(e)) + + def DELETE(self, entry_id): + """Handle DELETE to remove a journal entry""" + try: + entry = self.session.query(JournalEntry).get(entry_id) + if not entry: + raise cherrypy.HTTPError(404, "Journal entry not found") + + # Reverse the effect on account balances + for line in entry.lines: + account = self.session.query(Account).get(line.account_id) + if line.is_debit: + account.balance -= line.amount + else: + account.balance += line.amount + + # Delete the journal entry and its lines (cascade should handle this) + self.session.delete(entry) + self.session.commit() + return {'status': 'success'} + except Exception as e: + self.session.rollback() + raise cherrypy.HTTPError(400, str(e)) + + def OPTIONS(self, entry_id=None): + """Handle OPTIONS requests (CORS preflight)""" + if entry_id is not None: + cherrypy.response.headers['Allow'] = 'GET, DELETE, OPTIONS' + else: + cherrypy.response.headers['Allow'] = 'GET, POST, OPTIONS' + return "" diff --git a/accounting/templates/index.html b/accounting/templates/index.html index ab6aeaf..79fbc6e 100644 --- a/accounting/templates/index.html +++ b/accounting/templates/index.html @@ -404,7 +404,7 @@ }); }, addAccount() { - axios.post('/api/add_account', this.newAccount) + axios.post('/api/accounts', this.newAccount) .then(response => { if (response.data.status === 'success') { this.fetchAccounts(); @@ -419,7 +419,7 @@ }); }, addBankAccount() { - axios.post('/api/add_bank_account', this.newBankAccount) + axios.post('/api/bank_accounts', this.newBankAccount) .then(response => { if (response.data.status === 'success') { this.fetchBankAccounts(); @@ -455,8 +455,7 @@ transactions.push(txn); } - axios.post('/api/import_bank_transactions', { - bank_account_id: this.selectedBankAccount, + axios.post(`/api/bank_accounts/${this.selectedBankAccount}/transactions`, { transactions: transactions }).then(response => { this.importResult = response.data; @@ -471,7 +470,7 @@ }, fetchUnreconciledTransactions() { if (this.selectedBankAccountForReconciliation) { - axios.get(`/api/get_unreconciled_transactions?bank_account_id=${this.selectedBankAccountForReconciliation}`) + axios.get(`/api/bank_accounts/${this.selectedBankAccountForReconciliation}/unreconciled`) .then(response => { this.unreconciledTransactions = response.data; }); @@ -506,7 +505,7 @@ const journalEntryId = this.selectedJournalEntries[txnId]; if (!journalEntryId) return; - axios.post('/api/reconcile_transaction', { + axios.post('/api/reconciliation', { transaction_id: txnId, journal_entry_id: journalEntryId }).then(response => { @@ -519,13 +518,13 @@ }); }, fetchReconciliationReports() { - axios.get('/api/reconciliation_reports') + axios.get('/api/reconciliation/reports') .then(response => { this.reconciliationReports = response.data; }); }, createReconciliationReport() { - axios.post('/api/create_reconciliation_report', this.newReport) + axios.post('/api/reconciliation/reports', this.newReport) .then(response => { if (response.data.status === 'success') { this.loadReport(response.data.report_id); @@ -534,7 +533,7 @@ }); }, loadReport(reportId) { - axios.get(`/api/get_reconciliation_report?report_id=${reportId}`) + axios.get(`/api/reconciliation/reports/${reportId}`) .then(response => { if (response.data.status === 'success') { this.activeReport = response.data.report; @@ -543,7 +542,7 @@ }); }, acceptMatch(match) { - axios.post('/api/reconcile_transaction', { + axios.post('/api/reconciliation', { transaction_id: match.transaction_id, journal_entry_id: match.journal_entry_id }).then(response => { @@ -553,21 +552,20 @@ }); }, rejectMatch(match) { - axios.delete(`/api/reconciliation_match/${match.id}`) + axios.delete(`/api/reconciliation/matches/${match.id}`) .then(() => { this.loadReport(this.activeReport.id); }); }, finalizeReconciliation() { - axios.post('/api/finalize_reconciliation', { - report_id: this.activeReport.id - }).then(response => { - if (response.data.status === 'success') { - this.activeReport = null; - this.fetchReconciliationReports(); - this.fetchUnreconciledTransactions(); - } - }); + axios.post(`/api/reconciliation/reports/${this.activeReport.id}/finalize`) + .then(response => { + if (response.data.status === 'success') { + this.activeReport = null; + this.fetchReconciliationReports(); + this.fetchUnreconciledTransactions(); + } + }); }, cancelReconciliation() { this.activeReport = null; diff --git a/server.py b/server.py index 987a21e..eb3455f 100644 --- a/server.py +++ b/server.py @@ -3,6 +3,7 @@ from sqlalchemy import create_engine from accounting.models import Base from accounting.api import AccountingAPI import os +import json from config import DATABASE_URI @@ -12,6 +13,16 @@ def CORS(): cherrypy.response.headers["Access-Control-Allow-Headers"] = "Content-Type" +# JSON Tools for response serialization +class JSONEncoder(object): + def __init__(self): + self.json_encoder = json.JSONEncoder() + + def __call__(self, value): + # Convert the Python object to a JSON string then to bytes + return json.dumps(value).encode('utf-8') + + class Root: @cherrypy.expose def index(self): @@ -40,8 +51,11 @@ def main(): if not os.path.exists(static_path): os.makedirs(static_path) - # CherryPy configuration - conf = { + # Register tools + cherrypy.tools.CORS = cherrypy.Tool('before_handler', CORS) + + # Root application config + root_conf = { '/': { 'tools.sessions.on': True, 'tools.staticdir.root': os.path.abspath(os.path.dirname(__file__)), @@ -50,23 +64,31 @@ def main(): '/static': { 'tools.staticdir.on': True, 'tools.staticdir.dir': 'static', - }, - '/api': { - 'request.dispatch': cherrypy.dispatch.MethodDispatcher(), - 'tools.CORS.on': True, - 'tools.response_headers.on': True, - 'tools.response_headers.headers': [('Content-Type', 'application/json')], } } - # Register CORS tool - cherrypy.tools.CORS = cherrypy.Tool('before_handler', CORS) + # API application config + api_conf = { + '/': { + 'request.dispatch': cherrypy.dispatch.MethodDispatcher(), + 'tools.CORS.on': True, + 'tools.json_out.on': True, # Use the built-in JSON serializer + 'tools.encode.on': True, + 'tools.encode.encoding': 'utf-8', + # Process JSON request bodies + 'request.body.processors': { + 'application/json': cherrypy.lib.jsontools.json_processor + } + } + } - # Create application + # Create and mount Root application root = Root() - root.api = AccountingAPI(db_engine) + cherrypy.tree.mount(root, '/', root_conf) - cherrypy.tree.mount(root, '/', conf) + # Create and mount API as a separate application + api = AccountingAPI(db_engine) + cherrypy.tree.mount(api, '/api', api_conf) # Start server cherrypy.config.update({ @@ -82,4 +104,4 @@ def main(): if __name__ == '__main__': - main() \ No newline at end of file + main()