From 1fbc28124507f68486d9a322f15a62c3f4d8146d Mon Sep 17 00:00:00 2001 From: colin Date: Thu, 3 Jul 2025 12:14:16 -0400 Subject: [PATCH] Fix currency formatting and app structure. Add Woodpecker CI badge to README. --- README.md | 2 + docker/ploughshares/app.py | 240 +++++++++++------- docker/ploughshares/app_fixed.py | 220 ---------------- docker/ploughshares/schema.sql | 32 +-- docker/ploughshares/templates/index.html | 2 +- .../templates/view_transaction.html | 38 +-- 6 files changed, 182 insertions(+), 352 deletions(-) delete mode 100644 docker/ploughshares/app_fixed.py diff --git a/README.md b/README.md index 78e38b7..1b58620 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,7 @@ # Project Ploughshares +[![Build Status](https://woodpecker.nixc.us/api/badges/265/status.svg)](https://woodpecker.nixc.us/repos/265) + Transaction Management System for Project Ploughshares. ## Development diff --git a/docker/ploughshares/app.py b/docker/ploughshares/app.py index 5c105ee..fa2c552 100644 --- a/docker/ploughshares/app.py +++ b/docker/ploughshares/app.py @@ -5,7 +5,14 @@ from flask import Flask, render_template, request, redirect, url_for, flash, jso from werkzeug.utils import secure_filename from datetime import datetime import locale -import decimal +import logging + +# Configure logging +logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' +) +logger = logging.getLogger('ploughshares') # Get version from environment variable or VERSION file VERSION = os.environ.get('APP_VERSION') @@ -37,23 +44,12 @@ def currency_filter(value): if value is None: return "$0.00" try: - # Convert to Decimal for precise handling - if isinstance(value, str): - value = decimal.Decimal(value.replace('$', '').replace(',', '')) - else: - value = decimal.Decimal(str(value)) - - # Format with 2 decimal places - value = value.quantize(decimal.Decimal('0.01'), rounding=decimal.ROUND_HALF_UP) - return locale.currency(float(value), grouping=True) - except (ValueError, TypeError, decimal.InvalidOperation, locale.Error): - # Fallback formatting if locale doesn't work - try: - if isinstance(value, str): - value = float(value.replace('$', '').replace(',', '')) - return f"${float(value):,.2f}" - except: - return "$0.00" + # Convert to float first, then format to exactly 2 decimal places + float_value = float(value) + return f"${float_value:,.2f}" + except (ValueError, TypeError): + # Fallback formatting + return "$0.00" # --- Database Connection --- def get_db_connection(): @@ -73,10 +69,10 @@ def get_db_connection(): cursor_factory=RealDictCursor ) conn.autocommit = True - print(f"Ploughshares v{VERSION} - Connected to PostgreSQL at {host}:{port}") + logger.info(f"Ploughshares v{VERSION} - Connected to PostgreSQL at {host}:{port}") return conn except psycopg2.OperationalError as e: - print(f"Error connecting to PostgreSQL: {e}") + logger.error(f"Error connecting to PostgreSQL: {e}") return None # --- Routes --- @@ -87,19 +83,17 @@ def index(): flash("Database connection error", "error") return render_template('index.html', transactions=[], version=VERSION) - with conn.cursor() as cur: - cur.execute('SELECT * FROM transactions ORDER BY id DESC') - transactions = cur.fetchall() + try: + with conn.cursor() as cur: + cur.execute('SELECT * FROM transactions ORDER BY id DESC') + transactions = cur.fetchall() + except Exception as e: + logger.error(f"Database error: {e}") + flash(f"Database error: {e}", "error") + transactions = [] + finally: + conn.close() - # Ensure amount is properly formatted for the template - for transaction in transactions: - if 'amount' in transaction and transaction['amount'] is not None: - # Store the numeric value for sorting - transaction['amount_raw'] = float(transaction['amount']) - else: - transaction['amount_raw'] = 0.0 - - conn.close() return render_template('index.html', transactions=transactions, version=VERSION) @app.route('/api-docs') @@ -110,12 +104,24 @@ def api_docs(): @app.route('/transaction/') def view_transaction(id): conn = get_db_connection() - with conn.cursor() as cur: - cur.execute('SELECT * FROM transactions WHERE id = %s', (id,)) - transaction = cur.fetchone() - conn.close() + if conn is None: + flash("Database connection error", "error") + abort(404) + + transaction = None + try: + with conn.cursor() as cur: + cur.execute('SELECT * FROM transactions WHERE id = %s', (id,)) + transaction = cur.fetchone() + except Exception as e: + logger.error(f"Database error: {e}") + flash(f"Database error: {e}", "error") + finally: + conn.close() + if transaction is None: abort(404) + return render_template('view_transaction.html', transaction=transaction, version=VERSION) @app.route('/transaction/add', methods=['GET', 'POST']) @@ -148,25 +154,41 @@ def create_transaction(): data['amount'] = 0.0 conn = get_db_connection() - with conn.cursor() as cur: - cur.execute( - """ - INSERT INTO transactions ( - transaction_type, company_division, address_1, address_2, - city, province, region, postal_code, is_primary, source_date, source_description, - grant_type, description, amount, recipient, commodity_class, contract_number, comments - ) VALUES ( - %(transaction_type)s, %(company_division)s, %(address_1)s, %(address_2)s, - %(city)s, %(province)s, %(region)s, %(postal_code)s, %(is_primary)s, %(source_date)s, - %(source_description)s, %(grant_type)s, %(description)s, %(amount)s, %(recipient)s, - %(commodity_class)s, %(contract_number)s, %(comments)s - ) RETURNING id - """, - data - ) - new_transaction_id = cur.fetchone()['id'] - conn.commit() - conn.close() + if conn is None: + flash("Database connection error", "error") + return render_template('transaction_form.html', version=VERSION) + + try: + with conn.cursor() as cur: + cur.execute( + """ + INSERT INTO transactions ( + transaction_type, company_division, address_1, address_2, + city, province, region, postal_code, is_primary, source_date, source_description, + grant_type, description, amount, recipient, commodity_class, contract_number, comments + ) VALUES ( + %(transaction_type)s, %(company_division)s, %(address_1)s, %(address_2)s, + %(city)s, %(province)s, %(region)s, %(postal_code)s, %(is_primary)s, %(source_date)s, + %(source_description)s, %(grant_type)s, %(description)s, %(amount)s, %(recipient)s, + %(commodity_class)s, %(contract_number)s, %(comments)s + ) RETURNING id + """, + data + ) + result = cur.fetchone() + if result and 'id' in result: + new_transaction_id = result['id'] + conn.commit() + else: + flash("Error creating transaction: Could not get transaction ID", "error") + return render_template('transaction_form.html', version=VERSION) + except Exception as e: + logger.error(f"Error creating transaction: {e}") + flash(f"Error creating transaction: {e}", "error") + return render_template('transaction_form.html', version=VERSION) + finally: + conn.close() + return redirect(url_for('view_transaction', id=new_transaction_id)) return render_template('transaction_form.html', version=VERSION) @@ -174,6 +196,10 @@ def create_transaction(): @app.route('/transaction//edit', methods=['GET', 'POST']) def update_transaction(id): conn = get_db_connection() + if conn is None: + flash("Database connection error", "error") + abort(404) + if request.method == 'POST': # Get form data and set defaults for missing fields data = request.form.to_dict() @@ -202,64 +228,86 @@ def update_transaction(id): except ValueError: data['amount'] = 0.0 - with conn.cursor() as cur: - cur.execute( - """ - UPDATE transactions - SET transaction_type = %(transaction_type)s, - company_division = %(company_division)s, - address_1 = %(address_1)s, - address_2 = %(address_2)s, - city = %(city)s, - province = %(province)s, - region = %(region)s, - postal_code = %(postal_code)s, - is_primary = %(is_primary)s, - source_date = %(source_date)s, - source_description = %(source_description)s, - grant_type = %(grant_type)s, - description = %(description)s, - amount = %(amount)s, - recipient = %(recipient)s, - commodity_class = %(commodity_class)s, - contract_number = %(contract_number)s, - comments = %(comments)s - WHERE id = %(id)s - """, - data - ) - conn.commit() - conn.close() + try: + with conn.cursor() as cur: + cur.execute( + """ + UPDATE transactions + SET transaction_type = %(transaction_type)s, + company_division = %(company_division)s, + address_1 = %(address_1)s, + address_2 = %(address_2)s, + city = %(city)s, + province = %(province)s, + region = %(region)s, + postal_code = %(postal_code)s, + is_primary = %(is_primary)s, + source_date = %(source_date)s, + source_description = %(source_description)s, + grant_type = %(grant_type)s, + description = %(description)s, + amount = %(amount)s, + recipient = %(recipient)s, + commodity_class = %(commodity_class)s, + contract_number = %(contract_number)s, + comments = %(comments)s + WHERE id = %(id)s + """, + data + ) + conn.commit() + except Exception as e: + logger.error(f"Error updating transaction: {e}") + flash(f"Error updating transaction: {e}", "error") + return render_template('transaction_form.html', transaction=data, version=VERSION) + finally: + conn.close() + return redirect(url_for('view_transaction', id=id)) - with conn.cursor() as cur: - cur.execute('SELECT * FROM transactions WHERE id = %s', (id,)) - transaction = cur.fetchone() - conn.close() + transaction = None + try: + with conn.cursor() as cur: + cur.execute('SELECT * FROM transactions WHERE id = %s', (id,)) + transaction = cur.fetchone() + except Exception as e: + logger.error(f"Error retrieving transaction: {e}") + flash(f"Error retrieving transaction: {e}", "error") + finally: + conn.close() if transaction is None: abort(404) + return render_template('transaction_form.html', transaction=transaction, version=VERSION) def bootstrap_database(): """ Checks if the database is empty. Test data can be loaded separately using the tests/generate_test_data.py script. """ - print(f"Ploughshares v{VERSION} - Checking database for existing data...") + logger.info(f"Ploughshares v{VERSION} - Checking database for existing data...") conn = get_db_connection() if conn is None: - print("Database connection failed. Exiting.") + logger.error("Database connection failed. Exiting.") exit(1) - with conn.cursor() as cur: - cur.execute("SELECT COUNT(*) FROM transactions") - count = cur.fetchone()['count'] - if count == 0: - print(f"Ploughshares v{VERSION} - Database is empty. You can populate it with test data using the tests/generate_test_data.py script.") - conn.close() + try: + with conn.cursor() as cur: + cur.execute("SELECT COUNT(*) FROM transactions") + result = cur.fetchone() + if result and 'count' in result: + count = result['count'] + if count == 0: + logger.info(f"Ploughshares v{VERSION} - Database is empty. You can populate it with test data using the tests/generate_test_data.py script.") + else: + logger.error("Could not get count from database") + except Exception as e: + logger.error(f"Error checking database: {e}") + finally: + conn.close() if __name__ == '__main__': - print(f"Starting Ploughshares v{VERSION}") + logger.info(f"Starting Ploughshares v{VERSION}") bootstrap_database() port = int(os.environ.get('FLASK_RUN_PORT', 5001)) app.run(host='0.0.0.0', port=port) \ No newline at end of file diff --git a/docker/ploughshares/app_fixed.py b/docker/ploughshares/app_fixed.py deleted file mode 100644 index cbce9f1..0000000 --- a/docker/ploughshares/app_fixed.py +++ /dev/null @@ -1,220 +0,0 @@ -import os -import psycopg2 -from psycopg2.extras import RealDictCursor -from flask import Flask, render_template, request, redirect, url_for, flash, jsonify, send_from_directory, abort -from werkzeug.utils import secure_filename -from datetime import datetime -import locale - -# Get version from environment variable or VERSION file -VERSION = os.environ.get('APP_VERSION') -if not VERSION: - try: - with open(os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), 'VERSION'), 'r') as f: - VERSION = f.read().strip() - except: - VERSION = "unknown" - -# Initialize the Flask app -app = Flask(__name__) -app.secret_key = 'supersecretkey' -app.config['UPLOAD_FOLDER'] = 'uploads' -app.config['VERSION'] = VERSION - -# Set locale for currency formatting -try: - locale.setlocale(locale.LC_ALL, 'en_US.UTF-8') -except locale.Error: - try: - locale.setlocale(locale.LC_ALL, '') # Use system default locale - except locale.Error: - pass # If all fails, we'll use the fallback in the filter - -# Custom filter for currency formatting -@app.template_filter('currency') -def currency_filter(value): - if value is None: - return "$0.00" - try: - return locale.currency(float(value), grouping=True) - except (ValueError, TypeError, locale.Error): - # Fallback formatting if locale doesn't work - return f"${float(value):,.2f}" - -# --- Database Connection --- -def get_db_connection(): - host = os.environ.get('POSTGRES_HOST', 'db') - port = os.environ.get('POSTGRES_PORT', '5432') - dbname = os.environ.get('POSTGRES_DB', 'ploughshares') - user = os.environ.get('POSTGRES_USER', 'ploughshares') - password = os.environ.get('POSTGRES_PASSWORD', 'ploughshares_password') - - try: - conn = psycopg2.connect( - host=host, - port=port, - dbname=dbname, - user=user, - password=password, - cursor_factory=RealDictCursor - ) - conn.autocommit = True - print(f"Ploughshares v{VERSION} - Connected to PostgreSQL at {host}:{port}") - return conn - except psycopg2.OperationalError as e: - print(f"Error connecting to PostgreSQL: {e}") - return None - -# --- Routes --- -@app.route('/') -def index(): - conn = get_db_connection() - with conn.cursor() as cur: - cur.execute('SELECT * FROM transactions ORDER BY id DESC') - transactions = cur.fetchall() - conn.close() - return render_template('index.html', transactions=transactions, version=VERSION) - -@app.route('/api-docs') -def api_docs(): - server_name = request.host - return render_template('api_docs.html', server_name=server_name, version=VERSION) - -@app.route('/transaction/') -def view_transaction(id): - conn = get_db_connection() - with conn.cursor() as cur: - cur.execute('SELECT * FROM transactions WHERE id = %s', (id,)) - transaction = cur.fetchone() - conn.close() - if transaction is None: - abort(404) - return render_template('view_transaction.html', transaction=transaction, version=VERSION) - -@app.route('/transaction/add', methods=['GET', 'POST']) -def create_transaction(): - if request.method == 'POST': - # Get form data and set defaults for missing fields - data = request.form.to_dict() - default_fields = { - 'transaction_type': '', 'company_division': '', 'address_1': '', 'address_2': '', - 'city': '', 'province': '', 'region': '', 'postal_code': '', 'source_date': None, - 'source_description': '', 'grant_type': '', 'description': '', 'amount': 0, - 'recipient': '', 'commodity_class': '', 'contract_number': '', 'comments': '' - } - - # Fill in missing fields with defaults - for field, default in default_fields.items(): - if field not in data: - data[field] = default - - # Handle checkbox fields - data['is_primary'] = 'is_primary' in data - - conn = get_db_connection() - with conn.cursor() as cur: - cur.execute( - """ - INSERT INTO transactions ( - transaction_type, company_division, address_1, address_2, - city, province, region, postal_code, is_primary, source_date, source_description, - grant_type, description, amount, recipient, commodity_class, contract_number, comments - ) VALUES ( - %(transaction_type)s, %(company_division)s, %(address_1)s, %(address_2)s, - %(city)s, %(province)s, %(region)s, %(postal_code)s, %(is_primary)s, %(source_date)s, - %(source_description)s, %(grant_type)s, %(description)s, %(amount)s, %(recipient)s, - %(commodity_class)s, %(contract_number)s, %(comments)s - ) RETURNING id - """, - data - ) - new_transaction_id = cur.fetchone()['id'] - conn.commit() - conn.close() - return redirect(url_for('view_transaction', id=new_transaction_id)) - - return render_template('transaction_form.html', version=VERSION) - -@app.route('/transaction//edit', methods=['GET', 'POST']) -def update_transaction(id): - conn = get_db_connection() - if request.method == 'POST': - # Get form data and set defaults for missing fields - data = request.form.to_dict() - default_fields = { - 'transaction_type': '', 'company_division': '', 'address_1': '', 'address_2': '', - 'city': '', 'province': '', 'region': '', 'postal_code': '', 'source_date': None, - 'source_description': '', 'grant_type': '', 'description': '', 'amount': 0, - 'recipient': '', 'commodity_class': '', 'contract_number': '', 'comments': '' - } - - # Fill in missing fields with defaults - for field, default in default_fields.items(): - if field not in data: - data[field] = default - - # Handle checkbox fields - data['is_primary'] = 'is_primary' in data - data['id'] = id - - with conn.cursor() as cur: - cur.execute( - """ - UPDATE transactions - SET transaction_type = %(transaction_type)s, - company_division = %(company_division)s, - address_1 = %(address_1)s, - address_2 = %(address_2)s, - city = %(city)s, - province = %(province)s, - region = %(region)s, - postal_code = %(postal_code)s, - is_primary = %(is_primary)s, - source_date = %(source_date)s, - source_description = %(source_description)s, - grant_type = %(grant_type)s, - description = %(description)s, - amount = %(amount)s, - recipient = %(recipient)s, - commodity_class = %(commodity_class)s, - contract_number = %(contract_number)s, - comments = %(comments)s - WHERE id = %(id)s - """, - data - ) - conn.commit() - conn.close() - return redirect(url_for('view_transaction', id=id)) - - with conn.cursor() as cur: - cur.execute('SELECT * FROM transactions WHERE id = %s', (id,)) - transaction = cur.fetchone() - conn.close() - - if transaction is None: - abort(404) - return render_template('transaction_form.html', transaction=transaction, version=VERSION) - -def bootstrap_database(): - """ - Checks if the database is empty. Test data can be loaded separately using the tests/generate_test_data.py script. - """ - print(f"Ploughshares v{VERSION} - Checking database for existing data...") - conn = get_db_connection() - if conn is None: - print("Database connection failed. Exiting.") - exit(1) - - with conn.cursor() as cur: - cur.execute("SELECT COUNT(*) FROM transactions") - count = cur.fetchone()['count'] - if count == 0: - print(f"Ploughshares v{VERSION} - Database is empty. You can populate it with test data using the tests/generate_test_data.py script.") - conn.close() - -if __name__ == '__main__': - print(f"Starting Ploughshares v{VERSION}") - bootstrap_database() - port = int(os.environ.get('FLASK_RUN_PORT', 5001)) - app.run(host='0.0.0.0', port=port) \ No newline at end of file diff --git a/docker/ploughshares/schema.sql b/docker/ploughshares/schema.sql index 2d20d12..c8bdd1d 100644 --- a/docker/ploughshares/schema.sql +++ b/docker/ploughshares/schema.sql @@ -3,28 +3,27 @@ DROP TABLE IF EXISTS transaction_documents; DROP TABLE IF EXISTS transactions; -- Create transactions table -CREATE TABLE transactions ( +CREATE TABLE IF NOT EXISTS transactions ( id SERIAL PRIMARY KEY, - transaction_no SERIAL UNIQUE, - transaction_type VARCHAR(50), - company_division VARCHAR(100), + transaction_type VARCHAR(255), + company_division VARCHAR(255), address_1 VARCHAR(255), address_2 VARCHAR(255), - city VARCHAR(100), - province VARCHAR(100), - region VARCHAR(100), - postal_code VARCHAR(20), - is_primary BOOLEAN, + city VARCHAR(255), + province VARCHAR(255), + region VARCHAR(255), + postal_code VARCHAR(50), + is_primary BOOLEAN DEFAULT FALSE, source_date DATE, source_description TEXT, - grant_type VARCHAR(100), + grant_type VARCHAR(255), description TEXT, amount NUMERIC(15, 2), recipient VARCHAR(255), - commodity_class VARCHAR(100), - contract_number VARCHAR(100), + commodity_class VARCHAR(255), + contract_number VARCHAR(255), comments TEXT, - created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); -- Create table for document attachments @@ -40,6 +39,7 @@ CREATE TABLE transaction_documents ( ); -- Create indexes for better performance -CREATE INDEX idx_transaction_no ON transactions(transaction_no); -CREATE INDEX idx_transaction_date ON transactions(source_date); -CREATE INDEX idx_transaction_documents ON transaction_documents(transaction_id); \ No newline at end of file +CREATE INDEX IF NOT EXISTS idx_transactions_type ON transactions(transaction_type); +CREATE INDEX IF NOT EXISTS idx_transactions_division ON transactions(company_division); +CREATE INDEX IF NOT EXISTS idx_transactions_recipient ON transactions(recipient); +CREATE INDEX IF NOT EXISTS idx_transaction_date ON transactions(source_date); \ No newline at end of file diff --git a/docker/ploughshares/templates/index.html b/docker/ploughshares/templates/index.html index 6cfaf98..6c08ec2 100644 --- a/docker/ploughshares/templates/index.html +++ b/docker/ploughshares/templates/index.html @@ -28,7 +28,7 @@ {% for transaction in transactions %} - {{ transaction['transaction_no'] }} + {{ transaction['id'] }} {{ transaction['transaction_type'] }} {{ transaction['company_division'] }} {{ transaction['amount']|currency }} diff --git a/docker/ploughshares/templates/view_transaction.html b/docker/ploughshares/templates/view_transaction.html index 72441ea..fe3984e 100644 --- a/docker/ploughshares/templates/view_transaction.html +++ b/docker/ploughshares/templates/view_transaction.html @@ -1,14 +1,14 @@ {% extends "base.html" %} -{% block title %}Transaction {{ transaction.transaction_no }} - Project Ploughshares{% endblock %} +{% block title %}Transaction {{ transaction['id'] }} - Project Ploughshares{% endblock %} {% block content %}
-

Transaction #{{ transaction.transaction_no }} - Project Ploughshares

+

Transaction #{{ transaction['id'] }} - Project Ploughshares

- + Edit @@ -29,60 +29,60 @@ Transaction Type: - {{ transaction.transaction_type }} + {{ transaction['transaction_type'] }} Company/Division: - {{ transaction.company_division }} + {{ transaction['company_division'] }} Address: - {{ transaction.address_1 }}
- {% if transaction.address_2 %}{{ transaction.address_2 }}
{% endif %} - {{ transaction.city }}, {{ transaction.province }}, {{ transaction.postal_code }}
- {{ transaction.region }} + {{ transaction['address_1'] }}
+ {% if transaction['address_2'] %}{{ transaction['address_2'] }}
{% endif %} + {{ transaction['city'] }}, {{ transaction['province'] }}, {{ transaction['postal_code'] }}
+ {{ transaction['region'] }} Primary: - {{ 'Yes' if transaction.is_primary else 'No' }} + {{ 'Yes' if transaction['is_primary'] else 'No' }} Source Date: - {{ transaction.source_date.strftime('%Y-%m-%d') if transaction.source_date else 'N/A' }} + {{ transaction['source_date'].strftime('%Y-%m-%d') if transaction['source_date'] else 'N/A' }} Source Description: - {{ transaction.source_description }} + {{ transaction['source_description'] }} Grant Type: - {{ transaction.grant_type }} + {{ transaction['grant_type'] }} Description: - {{ transaction.description }} + {{ transaction['description'] }} Amount: - {{ transaction.amount|currency }} + {{ transaction['amount']|currency }} Recipient: - {{ transaction.recipient }} + {{ transaction['recipient'] }} Commodity Class: - {{ transaction.commodity_class }} + {{ transaction['commodity_class'] }} Contract Number: - {{ transaction.contract_number }} + {{ transaction['contract_number'] }} Comments: - {{ transaction.comments }} + {{ transaction['comments'] }}