fix: Initial barebones UI and API running

This commit is contained in:
2025-05-08 22:41:14 +12:00
parent a2942f6d7d
commit 5046ac67f1
3 changed files with 443 additions and 168 deletions

View File

@@ -1,161 +1,416 @@
import cherrypy import cherrypy
from sqlalchemy.orm import sessionmaker from sqlalchemy.orm import sessionmaker
from datetime import datetime 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 ReconciliationMatch
import simplejson as json
@cherrypy.tools.json_out()
class AccountingAPI: class AccountingAPI:
exposed = True
def __init__(self, db_engine): def __init__(self, db_engine):
self.db_engine = db_engine self.db_engine = db_engine
Session = sessionmaker(bind=db_engine) Session = sessionmaker(bind=db_engine)
self.session = Session() self.session = Session()
@cherrypy.expose # Mount resource handlers as attributes - these become URL segments
@cherrypy.tools.json_out() self.accounts = AccountHandler(self.session)
def index(self): 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"} return {"status": "success", "message": "Accounting API is running"}
# Bank Accounts Endpoints def OPTIONS(self):
@cherrypy.expose """Handle OPTIONS requests (CORS preflight) to the API root"""
@cherrypy.tools.json_out() cherrypy.response.headers['Allow'] = 'GET, OPTIONS'
def bank_accounts(self): return ""
if cherrypy.request.method != 'GET':
raise cherrypy.HTTPError(405)
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_out()
@cherrypy.tools.json_in() class AccountHandler:
@cherrypy.tools.json_out() exposed = True
def add_bank_account(self):
if cherrypy.request.method != 'POST':
raise cherrypy.HTTPError(405)
data = cherrypy.request.json def __init__(self, session):
account = BankAccount( self.session = session
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}
# Add OPTIONS method handler for CORS preflight def GET(self, account_id=None):
@cherrypy.expose """
def add_bank_account_options(self): GET /api/accounts - List all accounts
cherrypy.response.headers['Allow'] = 'POST, OPTIONS' GET /api/accounts/123 - Get specific account
cherrypy.response.headers['Access-Control-Allow-Headers'] = 'Content-Type' """
return '' 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 return {
@cherrypy.expose 'id': account.id,
@cherrypy.tools.json_out() 'name': account.name,
def accounts(self): 'code': account.code,
if cherrypy.request.method != 'GET': 'type': account.account_type,
raise cherrypy.HTTPError(405) '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() def POST(self):
return [{ """Handle POST to create a new account"""
'id': a.id, try:
'name': a.name, data = cherrypy.request.json
'code': a.code, account = Account(
'type': a.account_type, name=data['name'],
'balance': float(a.balance) if a.balance else 0 account_type=data['type'],
} for a in accounts] code=data['code'],
parent_id=data.get('parent_id')
@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']
) )
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']) def PUT(self, account_id):
if line_data['is_debit']: """Handle PUT to update an existing account"""
account.balance += line_data['amount'] try:
else: account = self.session.query(Account).get(account_id)
account.balance -= line_data['amount'] if not account:
raise cherrypy.HTTPError(404, "Account not found")
self.session.commit() data = cherrypy.request.json
return {'status': 'success', 'id': entry.id} 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 self.session.commit()
@cherrypy.tools.json_out() return {'status': 'success', 'id': account.id}
def journal_entries(self): except Exception as e:
if cherrypy.request.method != 'GET': self.session.rollback()
raise cherrypy.HTTPError(405) raise cherrypy.HTTPError(400, str(e))
entries = self.session.query(JournalEntry).all() def DELETE(self, account_id):
return [{ """Handle DELETE to remove an account"""
'id': e.id, try:
'date': e.date.isoformat(), account = self.session.query(Account).get(account_id)
'description': e.description, if not account:
'reference': e.reference, raise cherrypy.HTTPError(404, "Account not found")
'lines': [{
'account_id': l.account_id,
'amount': float(l.amount),
'is_debit': l.is_debit
} for l in e.lines]
} for e in entries]
# Add default OPTIONS handler for all endpoints # Check for dependencies before deleting
@cherrypy.expose if self.session.query(JournalEntryLine).filter_by(account_id=account_id).count() > 0:
def default(self, *args, **kwargs): return {'status': 'error', 'message': 'Cannot delete account with journal entries'}
if cherrypy.request.method == 'OPTIONS':
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['Allow'] = 'GET, POST, OPTIONS'
cherrypy.response.headers['Access-Control-Allow-Headers'] = 'Content-Type' return ""
return ''
raise cherrypy.HTTPError(404)
@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 ""

View File

@@ -404,7 +404,7 @@
}); });
}, },
addAccount() { addAccount() {
axios.post('/api/add_account', this.newAccount) axios.post('/api/accounts', this.newAccount)
.then(response => { .then(response => {
if (response.data.status === 'success') { if (response.data.status === 'success') {
this.fetchAccounts(); this.fetchAccounts();
@@ -419,7 +419,7 @@
}); });
}, },
addBankAccount() { addBankAccount() {
axios.post('/api/add_bank_account', this.newBankAccount) axios.post('/api/bank_accounts', this.newBankAccount)
.then(response => { .then(response => {
if (response.data.status === 'success') { if (response.data.status === 'success') {
this.fetchBankAccounts(); this.fetchBankAccounts();
@@ -455,8 +455,7 @@
transactions.push(txn); transactions.push(txn);
} }
axios.post('/api/import_bank_transactions', { axios.post(`/api/bank_accounts/${this.selectedBankAccount}/transactions`, {
bank_account_id: this.selectedBankAccount,
transactions: transactions transactions: transactions
}).then(response => { }).then(response => {
this.importResult = response.data; this.importResult = response.data;
@@ -471,7 +470,7 @@
}, },
fetchUnreconciledTransactions() { fetchUnreconciledTransactions() {
if (this.selectedBankAccountForReconciliation) { if (this.selectedBankAccountForReconciliation) {
axios.get(`/api/get_unreconciled_transactions?bank_account_id=${this.selectedBankAccountForReconciliation}`) axios.get(`/api/bank_accounts/${this.selectedBankAccountForReconciliation}/unreconciled`)
.then(response => { .then(response => {
this.unreconciledTransactions = response.data; this.unreconciledTransactions = response.data;
}); });
@@ -506,7 +505,7 @@
const journalEntryId = this.selectedJournalEntries[txnId]; const journalEntryId = this.selectedJournalEntries[txnId];
if (!journalEntryId) return; if (!journalEntryId) return;
axios.post('/api/reconcile_transaction', { axios.post('/api/reconciliation', {
transaction_id: txnId, transaction_id: txnId,
journal_entry_id: journalEntryId journal_entry_id: journalEntryId
}).then(response => { }).then(response => {
@@ -519,13 +518,13 @@
}); });
}, },
fetchReconciliationReports() { fetchReconciliationReports() {
axios.get('/api/reconciliation_reports') axios.get('/api/reconciliation/reports')
.then(response => { .then(response => {
this.reconciliationReports = response.data; this.reconciliationReports = response.data;
}); });
}, },
createReconciliationReport() { createReconciliationReport() {
axios.post('/api/create_reconciliation_report', this.newReport) axios.post('/api/reconciliation/reports', this.newReport)
.then(response => { .then(response => {
if (response.data.status === 'success') { if (response.data.status === 'success') {
this.loadReport(response.data.report_id); this.loadReport(response.data.report_id);
@@ -534,7 +533,7 @@
}); });
}, },
loadReport(reportId) { loadReport(reportId) {
axios.get(`/api/get_reconciliation_report?report_id=${reportId}`) axios.get(`/api/reconciliation/reports/${reportId}`)
.then(response => { .then(response => {
if (response.data.status === 'success') { if (response.data.status === 'success') {
this.activeReport = response.data.report; this.activeReport = response.data.report;
@@ -543,7 +542,7 @@
}); });
}, },
acceptMatch(match) { acceptMatch(match) {
axios.post('/api/reconcile_transaction', { axios.post('/api/reconciliation', {
transaction_id: match.transaction_id, transaction_id: match.transaction_id,
journal_entry_id: match.journal_entry_id journal_entry_id: match.journal_entry_id
}).then(response => { }).then(response => {
@@ -553,21 +552,20 @@
}); });
}, },
rejectMatch(match) { rejectMatch(match) {
axios.delete(`/api/reconciliation_match/${match.id}`) axios.delete(`/api/reconciliation/matches/${match.id}`)
.then(() => { .then(() => {
this.loadReport(this.activeReport.id); this.loadReport(this.activeReport.id);
}); });
}, },
finalizeReconciliation() { finalizeReconciliation() {
axios.post('/api/finalize_reconciliation', { axios.post(`/api/reconciliation/reports/${this.activeReport.id}/finalize`)
report_id: this.activeReport.id .then(response => {
}).then(response => { if (response.data.status === 'success') {
if (response.data.status === 'success') { this.activeReport = null;
this.activeReport = null; this.fetchReconciliationReports();
this.fetchReconciliationReports(); this.fetchUnreconciledTransactions();
this.fetchUnreconciledTransactions(); }
} });
});
}, },
cancelReconciliation() { cancelReconciliation() {
this.activeReport = null; this.activeReport = null;

View File

@@ -3,6 +3,7 @@ from sqlalchemy import create_engine
from accounting.models import Base from accounting.models import Base
from accounting.api import AccountingAPI from accounting.api import AccountingAPI
import os import os
import json
from config import DATABASE_URI from config import DATABASE_URI
@@ -12,6 +13,16 @@ def CORS():
cherrypy.response.headers["Access-Control-Allow-Headers"] = "Content-Type" 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: class Root:
@cherrypy.expose @cherrypy.expose
def index(self): def index(self):
@@ -40,8 +51,11 @@ def main():
if not os.path.exists(static_path): if not os.path.exists(static_path):
os.makedirs(static_path) os.makedirs(static_path)
# CherryPy configuration # Register tools
conf = { cherrypy.tools.CORS = cherrypy.Tool('before_handler', CORS)
# Root application config
root_conf = {
'/': { '/': {
'tools.sessions.on': True, 'tools.sessions.on': True,
'tools.staticdir.root': os.path.abspath(os.path.dirname(__file__)), 'tools.staticdir.root': os.path.abspath(os.path.dirname(__file__)),
@@ -50,23 +64,31 @@ def main():
'/static': { '/static': {
'tools.staticdir.on': True, 'tools.staticdir.on': True,
'tools.staticdir.dir': 'static', '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 # API application config
cherrypy.tools.CORS = cherrypy.Tool('before_handler', CORS) 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 = 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 # Start server
cherrypy.config.update({ cherrypy.config.update({
@@ -82,4 +104,4 @@ def main():
if __name__ == '__main__': if __name__ == '__main__':
main() main()