Add transaction approval system
ci/woodpecker/push/woodpecker Pipeline was successful Details

This commit is contained in:
colin 2025-07-04 22:26:20 -04:00
parent a64ac45046
commit 679c51263d
10 changed files with 444 additions and 7 deletions

View File

@ -1 +1 @@
0.2.1 0.3.0

View File

@ -302,6 +302,136 @@ def view_transaction(id):
return render_template('view_transaction.html', transaction=transaction, prev_id=prev_id, next_id=next_id, version=VERSION) return render_template('view_transaction.html', transaction=transaction, prev_id=prev_id, next_id=next_id, version=VERSION)
@app.route('/pending-approval')
def pending_approval():
"""
Display transactions pending approval
"""
conn = get_db_connection()
if conn is None:
flash("Database connection error", "error")
return render_template('pending_approval.html', transactions=[], version=VERSION)
try:
with conn.cursor() as cur:
cur.execute('SELECT * FROM transactions WHERE approved = FALSE 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()
return render_template('pending_approval.html', transactions=transactions, version=VERSION)
@app.route('/transaction/<int:id>/approve', methods=['POST'])
def approve_transaction(id):
"""
Approve a transaction
"""
conn = get_db_connection()
if conn is None:
flash("Database connection error", "error")
return redirect(url_for('pending_approval'))
try:
with conn.cursor() as cur:
# Get the approver name from the form or use default
approver = request.form.get('approver', 'System')
# Update the transaction
cur.execute(
"""
UPDATE transactions
SET approved = TRUE,
approved_at = CURRENT_TIMESTAMP,
approved_by = %s
WHERE id = %s
""",
(approver, id)
)
conn.commit()
flash(f"Transaction #{id} approved successfully", "success")
except Exception as e:
logger.error(f"Error approving transaction: {e}")
flash(f"Error approving transaction: {e}", "error")
finally:
conn.close()
return redirect(url_for('pending_approval'))
@app.route('/api/transactions/pending', methods=['GET'])
def api_get_pending_transactions():
"""API endpoint to get all pending (unapproved) transactions"""
conn = get_db_connection()
if conn is None:
return jsonify({"error": "Database connection error"}), 500
transactions = []
try:
with conn.cursor() as cur:
cur.execute('SELECT * FROM transactions WHERE approved = FALSE ORDER BY id DESC')
transactions = cur.fetchall()
# Convert transactions to a list of dictionaries
result = []
for transaction in transactions:
# Format the date if it exists
if transaction.get('source_date'):
transaction['source_date'] = transaction['source_date'].strftime('%Y-%m-%d')
# Format created_at if it exists
if transaction.get('created_at'):
transaction['created_at'] = transaction['created_at'].strftime('%Y-%m-%dT%H:%M:%S.%f')
result.append(dict(transaction))
except Exception as e:
logger.error(f"Database error in API: {e}")
return jsonify({"error": f"Database error: {str(e)}"}), 500
finally:
conn.close()
return jsonify(result)
@app.route('/api/transaction/<int:id>/approve', methods=['POST'])
def api_approve_transaction(id):
"""API endpoint to approve a transaction"""
conn = get_db_connection()
if conn is None:
return jsonify({"error": "Database connection error"}), 500
# Get the approver from the request data or use default
data = request.get_json() if request.is_json else {}
approver = data.get('approver', 'API')
try:
with conn.cursor() as cur:
# Check if transaction exists
cur.execute('SELECT id FROM transactions WHERE id = %s', (id,))
if cur.fetchone() is None:
return jsonify({"error": "Transaction not found"}), 404
# Update the transaction
cur.execute(
"""
UPDATE transactions
SET approved = TRUE,
approved_at = CURRENT_TIMESTAMP,
approved_by = %s
WHERE id = %s
""",
(approver, id)
)
conn.commit()
return jsonify({"message": "Transaction approved successfully"}), 200
except Exception as e:
logger.error(f"Error approving transaction via API: {e}")
return jsonify({"error": f"Error approving transaction: {str(e)}"}), 500
finally:
conn.close()
@app.route('/transaction/add', methods=['GET', 'POST']) @app.route('/transaction/add', methods=['GET', 'POST'])
def create_transaction(): def create_transaction():
if request.method == 'POST': if request.method == 'POST':
@ -343,12 +473,13 @@ def create_transaction():
INSERT INTO transactions ( INSERT INTO transactions (
transaction_type, company_division, address_1, address_2, transaction_type, company_division, address_1, address_2,
city, province, region, postal_code, is_primary, source_date, source_description, city, province, region, postal_code, is_primary, source_date, source_description,
grant_type, description, amount, recipient, commodity_class, contract_number, comments grant_type, description, amount, recipient, commodity_class, contract_number, comments,
approved
) VALUES ( ) VALUES (
%(transaction_type)s, %(company_division)s, %(address_1)s, %(address_2)s, %(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, %(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, %(source_description)s, %(grant_type)s, %(description)s, %(amount)s, %(recipient)s,
%(commodity_class)s, %(contract_number)s, %(comments)s %(commodity_class)s, %(contract_number)s, %(comments)s, FALSE
) RETURNING id ) RETURNING id
""", """,
data data
@ -367,6 +498,7 @@ def create_transaction():
finally: finally:
conn.close() conn.close()
flash("Transaction created successfully and is pending approval", "success")
return redirect(url_for('view_transaction', id=new_transaction_id)) return redirect(url_for('view_transaction', id=new_transaction_id))
return render_template('transaction_form.html', version=VERSION) return render_template('transaction_form.html', version=VERSION)
@ -641,12 +773,13 @@ def api_create_transaction():
INSERT INTO transactions ( INSERT INTO transactions (
transaction_type, company_division, address_1, address_2, transaction_type, company_division, address_1, address_2,
city, province, region, postal_code, is_primary, source_date, source_description, city, province, region, postal_code, is_primary, source_date, source_description,
grant_type, description, amount, recipient, commodity_class, contract_number, comments grant_type, description, amount, recipient, commodity_class, contract_number, comments,
approved
) VALUES ( ) VALUES (
%(transaction_type)s, %(company_division)s, %(address_1)s, %(address_2)s, %(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, %(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, %(source_description)s, %(grant_type)s, %(description)s, %(amount)s, %(recipient)s,
%(commodity_class)s, %(contract_number)s, %(comments)s %(commodity_class)s, %(contract_number)s, %(comments)s, FALSE
) RETURNING id ) RETURNING id
""", """,
data data
@ -655,7 +788,7 @@ def api_create_transaction():
if result and 'id' in result: if result and 'id' in result:
new_transaction_id = result['id'] new_transaction_id = result['id']
conn.commit() conn.commit()
return jsonify({"message": "Transaction created successfully", "transaction_id": new_transaction_id}), 201 return jsonify({"message": "Transaction created successfully and is pending approval", "transaction_id": new_transaction_id}), 201
else: else:
return jsonify({"error": "Could not create transaction"}), 500 return jsonify({"error": "Could not create transaction"}), 500
except Exception as e: except Exception as e:

View File

@ -0,0 +1,72 @@
import os
import psycopg2
import logging
# Configure logging
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
)
logger = logging.getLogger('ploughshares')
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')
conn = psycopg2.connect(
host=host,
port=port,
dbname=dbname,
user=user,
password=password
)
conn.autocommit = True
return conn
def migrate_database():
"""
Adds approval fields to the transactions table if they don't exist
"""
conn = get_db_connection()
cursor = conn.cursor()
try:
# Check if the approved column already exists
cursor.execute("""
SELECT column_name
FROM information_schema.columns
WHERE table_name = 'transactions' AND column_name = 'approved'
""")
if cursor.fetchone() is None:
logger.info("Adding approval fields to transactions table...")
# Add the new columns
cursor.execute("""
ALTER TABLE transactions
ADD COLUMN IF NOT EXISTS approved BOOLEAN DEFAULT FALSE,
ADD COLUMN IF NOT EXISTS approved_at TIMESTAMP,
ADD COLUMN IF NOT EXISTS approved_by VARCHAR(255)
""")
# Create an index on the approved column
cursor.execute("""
CREATE INDEX IF NOT EXISTS idx_transactions_approved
ON transactions(approved)
""")
logger.info("Successfully added approval fields to transactions table")
else:
logger.info("Approval fields already exist in transactions table")
except Exception as e:
logger.error(f"Error migrating database: {e}")
finally:
cursor.close()
conn.close()
if __name__ == "__main__":
migrate_database()

View File

@ -23,6 +23,9 @@ CREATE TABLE IF NOT EXISTS transactions (
commodity_class VARCHAR(255), commodity_class VARCHAR(255),
contract_number VARCHAR(255), contract_number VARCHAR(255),
comments TEXT, comments TEXT,
approved BOOLEAN DEFAULT FALSE,
approved_at TIMESTAMP,
approved_by VARCHAR(255),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
); );
@ -42,4 +45,5 @@ CREATE TABLE transaction_documents (
CREATE INDEX IF NOT EXISTS idx_transactions_type ON transactions(transaction_type); 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_division ON transactions(company_division);
CREATE INDEX IF NOT EXISTS idx_transactions_recipient ON transactions(recipient); CREATE INDEX IF NOT EXISTS idx_transactions_recipient ON transactions(recipient);
CREATE INDEX IF NOT EXISTS idx_transaction_date ON transactions(source_date); CREATE INDEX IF NOT EXISTS idx_transaction_date ON transactions(source_date);
CREATE INDEX IF NOT EXISTS idx_transactions_approved ON transactions(approved);

View File

@ -147,6 +147,55 @@
<h5>Response:</h5> <h5>Response:</h5>
<pre class="bg-dark text-light p-2"><code>{ <pre class="bg-dark text-light p-2"><code>{
"message": "Transaction deleted successfully" "message": "Transaction deleted successfully"
}</code></pre>
</div>
<h4 class="mt-4">6. Get Pending Transactions</h4>
<div class="bg-light p-3 mb-3">
<p><strong>GET</strong> <code>/api/transactions/pending</code></p>
<h5>Complete Example:</h5>
<pre class="bg-dark text-light p-2"><code id="getPendingTransactions">curl -X GET "http://{{ server_name }}/api/transactions/pending"</code></pre>
<button class="btn btn-sm btn-secondary" onclick="copyToClipboard('getPendingTransactions')">Copy</button>
<h5>Response:</h5>
<pre class="bg-dark text-light p-2"><code>[
{
"transaction_id": 1,
"transaction_type": "Subcontract",
"company_division": "C A E Inc",
"amount": 0.00,
"recipient": "US Army",
"created_at": "2023-07-02T12:34:56.789012",
"approved": false
},
{
"transaction_id": 2,
"transaction_type": "Purchase Order",
"company_division": "Example Corp",
"amount": 1000.00,
"recipient": "Test Recipient",
"created_at": "2023-07-03T10:11:12.131415",
"approved": false
}
]</code></pre>
</div>
<h4 class="mt-4">7. Approve Transaction</h4>
<div class="bg-light p-3 mb-3">
<p><strong>POST</strong> <code>/api/transaction/{id}/approve</code></p>
<h5>Complete Example:</h5>
<pre class="bg-dark text-light p-2"><code id="approveTransaction">curl -X POST "http://{{ server_name }}/api/transaction/2/approve" \
-H "Content-Type: application/json" \
-d '{
"approver": "John Doe"
}'</code></pre>
<button class="btn btn-sm btn-secondary" onclick="copyToClipboard('approveTransaction')">Copy</button>
<h5>Response:</h5>
<pre class="bg-dark text-light p-2"><code>{
"message": "Transaction approved successfully"
}</code></pre> }</code></pre>
</div> </div>
</div> </div>

View File

@ -50,6 +50,12 @@
<i class="bi bi-plus-circle" aria-hidden="true"></i> New Transaction <i class="bi bi-plus-circle" aria-hidden="true"></i> New Transaction
</a> </a>
</li> </li>
<li class="nav-item">
<a class="nav-link" href="{{ url_for('pending_approval') }}" aria-current="{% if request.endpoint == 'pending_approval' %}page{% endif %}">
<i class="bi bi-clock-history" aria-hidden="true"></i> Pending Approval
<span class="badge bg-warning rounded-pill" id="pending-count"></span>
</a>
</li>
<li class="nav-item"> <li class="nav-item">
<a class="nav-link" href="{{ url_for('api_docs') }}" aria-current="{% if request.endpoint == 'api_docs' %}page{% endif %}"> <a class="nav-link" href="{{ url_for('api_docs') }}" aria-current="{% if request.endpoint == 'api_docs' %}page{% endif %}">
<i class="bi bi-file-earmark-text" aria-hidden="true"></i> API Documentation <i class="bi bi-file-earmark-text" aria-hidden="true"></i> API Documentation
@ -105,6 +111,26 @@
} }
} }
}); });
// Update the pending count badge
const pendingCountBadge = document.getElementById('pending-count');
if (pendingCountBadge) {
fetch('/api/transactions/pending')
.then(response => response.json())
.then(data => {
const count = data.length;
if (count > 0) {
pendingCountBadge.textContent = count;
pendingCountBadge.style.display = 'inline-block';
} else {
pendingCountBadge.style.display = 'none';
}
})
.catch(error => {
console.error('Error fetching pending transactions:', error);
pendingCountBadge.style.display = 'none';
});
}
}); });
</script> </script>
</body> </body>

View File

@ -22,6 +22,7 @@
<th class="amount-cell">Amount</th> <th class="amount-cell">Amount</th>
<th>Source Date</th> <th>Source Date</th>
<th>Recipient</th> <th>Recipient</th>
<th>Status</th>
<th>Actions</th> <th>Actions</th>
</tr> </tr>
</thead> </thead>
@ -34,6 +35,13 @@
<td class="amount-cell"><span class="currency-value">{{ transaction['amount']|currency }}</span></td> <td class="amount-cell"><span class="currency-value">{{ transaction['amount']|currency }}</span></td>
<td>{{ transaction['source_date'].strftime('%Y-%m-%d') if transaction['source_date'] else 'N/A' }}</td> <td>{{ transaction['source_date'].strftime('%Y-%m-%d') if transaction['source_date'] else 'N/A' }}</td>
<td>{{ transaction['recipient'] }}</td> <td>{{ transaction['recipient'] }}</td>
<td>
{% if transaction['approved'] %}
<span class="badge bg-success">Approved</span>
{% else %}
<span class="badge bg-warning">Pending</span>
{% endif %}
</td>
<td> <td>
<a href="{{ url_for('view_transaction', id=transaction['id']) }}" class="btn btn-sm btn-info" aria-label="View transaction {{ transaction['id'] }}"> <a href="{{ url_for('view_transaction', id=transaction['id']) }}" class="btn btn-sm btn-info" aria-label="View transaction {{ transaction['id'] }}">
<i class="bi bi-eye"></i> <i class="bi bi-eye"></i>

View File

@ -0,0 +1,101 @@
{% extends "base.html" %}
{% block title %}Transactions Pending Approval - Project Ploughshares{% endblock %}
{% block content %}
<div class="container-fluid mt-4">
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<h2>Transactions Pending Approval</h2>
<div>
<a href="{{ url_for('index') }}" class="btn btn-secondary">
<i class="bi bi-arrow-left"></i> Back to All Transactions
</a>
</div>
</div>
<div class="card-body">
{% if transactions %}
<div class="table-responsive">
<table class="table table-hover">
<thead class="thead-light">
<tr>
<th>Transaction No.</th>
<th>Type</th>
<th>Division</th>
<th class="amount-cell">Amount</th>
<th>Source Date</th>
<th>Recipient</th>
<th>Created At</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for transaction in transactions %}
<tr>
<td>{{ transaction['id'] }}</td>
<td>{{ transaction['transaction_type'] }}</td>
<td>{{ transaction['company_division'] }}</td>
<td class="amount-cell"><span class="currency-value">{{ transaction['amount']|currency }}</span></td>
<td>{{ transaction['source_date'].strftime('%Y-%m-%d') if transaction['source_date'] else 'N/A' }}</td>
<td>{{ transaction['recipient'] }}</td>
<td>{{ transaction['created_at'].strftime('%Y-%m-%d %H:%M') if transaction['created_at'] else 'N/A' }}</td>
<td>
<div class="btn-group" role="group">
<a href="{{ url_for('view_transaction', id=transaction['id']) }}" class="btn btn-sm btn-info" aria-label="View transaction {{ transaction['id'] }}">
<i class="bi bi-eye"></i>
</a>
<a href="{{ url_for('update_transaction', id=transaction['id']) }}" class="btn btn-sm btn-warning" aria-label="Edit transaction {{ transaction['id'] }}">
<i class="bi bi-pencil"></i>
</a>
<button type="button" class="btn btn-sm btn-success" data-bs-toggle="modal" data-bs-target="#approveModal{{ transaction['id'] }}" aria-label="Approve transaction {{ transaction['id'] }}">
<i class="bi bi-check-lg"></i>
</button>
</div>
<!-- Approve Modal -->
<div class="modal fade" id="approveModal{{ transaction['id'] }}" tabindex="-1" aria-labelledby="approveModalLabel{{ transaction['id'] }}" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="approveModalLabel{{ transaction['id'] }}">Approve Transaction #{{ transaction['id'] }}</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<form action="{{ url_for('approve_transaction', id=transaction['id']) }}" method="post">
<div class="modal-body">
<p>Are you sure you want to approve this transaction?</p>
<div class="mb-3">
<label for="approver{{ transaction['id'] }}" class="form-label">Your Name</label>
<input type="text" class="form-control" id="approver{{ transaction['id'] }}" name="approver" required>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="submit" class="btn btn-success">Approve</button>
</div>
</form>
</div>
</div>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="alert alert-info">
<p>No transactions pending approval.</p>
</div>
{% endif %}
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script nonce="{{ csp_nonce() }}">
document.addEventListener('DOMContentLoaded', function() {
// Add any specific scripts for the pending approval page
});
</script>
{% endblock %}

View File

@ -17,6 +17,49 @@
</div> </div>
</div> </div>
<div class="card-body"> <div class="card-body">
<!-- Approval status banner -->
{% if transaction['approved'] %}
<div class="alert alert-success mb-3">
<i class="bi bi-check-circle-fill"></i>
Approved by {{ transaction['approved_by'] }} on {{ transaction['approved_at'].strftime('%Y-%m-%d %H:%M') if transaction['approved_at'] else 'N/A' }}
</div>
{% else %}
<div class="alert alert-warning mb-3 d-flex justify-content-between align-items-center">
<div>
<i class="bi bi-exclamation-triangle-fill"></i>
Pending Approval
</div>
<button type="button" class="btn btn-success" data-bs-toggle="modal" data-bs-target="#approveModal">
<i class="bi bi-check-lg"></i> Approve
</button>
<!-- Approve Modal -->
<div class="modal fade" id="approveModal" tabindex="-1" aria-labelledby="approveModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="approveModalLabel">Approve Transaction #{{ transaction['id'] }}</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<form action="{{ url_for('approve_transaction', id=transaction['id']) }}" method="post">
<div class="modal-body">
<p>Are you sure you want to approve this transaction?</p>
<div class="mb-3">
<label for="approver" class="form-label">Your Name</label>
<input type="text" class="form-control" id="approver" name="approver" required>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="submit" class="btn btn-success">Approve</button>
</div>
</form>
</div>
</div>
</div>
</div>
{% endif %}
<!-- Navigation buttons --> <!-- Navigation buttons -->
<div class="d-flex justify-content-between mb-3"> <div class="d-flex justify-content-between mb-3">
{% if prev_id %} {% if prev_id %}

View File

@ -2,3 +2,4 @@ Thu Jul 3 13:33:04 EDT 2025: Version changed from 0.1.0 to 0.1.1
Thu Jul 3 13:40:53 EDT 2025: Version changed from 0.1.1 to 0.1.2 Thu Jul 3 13:40:53 EDT 2025: Version changed from 0.1.1 to 0.1.2
Fri Jul 4 16:04:29 EDT 2025: Version changed from 0.1.2 to 0.2.0 Fri Jul 4 16:04:29 EDT 2025: Version changed from 0.1.2 to 0.2.0
Fri Jul 4 20:03:15 EDT 2025: Version changed from 0.2.0 to 0.2.1 Fri Jul 4 20:03:15 EDT 2025: Version changed from 0.2.0 to 0.2.1
Fri Jul 4 22:25:55 EDT 2025: Version changed from 0.2.1 to 0.3.0