Improved usability and accessibility:
ci/woodpecker/push/woodpecker Pipeline was successful Details

- Added keyboard support for approval modals (Enter key)
- Removed unnecessary keyboard shortcuts button
- Fixed database schema issues
- Added automatic approver name
- Enhanced approval modals with document viewing
- Version bump to 0.4.0
This commit is contained in:
colin 2025-07-04 23:19:49 -04:00
parent 0179b6debf
commit 1c84db947c
14 changed files with 372 additions and 82 deletions

View File

@ -1 +1 @@
0.3.1
0.4.0

View File

@ -17,7 +17,7 @@ services:
- POSTGRES_DB=ploughshares
- POSTGRES_USER=ploughshares
- POSTGRES_PASSWORD=ploughshares_password
- APP_VERSION=0.2.0
- APP_VERSION=0.4.0
- APP_ENV=development
depends_on:
db:

View File

@ -17,6 +17,7 @@ RUN pip install --no-cache-dir -r requirements.txt
# Copy application code
COPY app.py .
COPY schema.sql .
COPY migrate_approval.py .
COPY templates/ ./templates/
COPY static/ ./static/
# Tests directory is empty or doesn't contain required files

Binary file not shown.

View File

@ -8,6 +8,7 @@ import locale
import logging
from flask_talisman import Talisman
import re
from migrate_approval import migrate_database
# Configure logging
logging.basicConfig(
@ -263,8 +264,36 @@ def api_docs():
server_name = request.host
return render_template('api_docs.html', server_name=server_name, version=VERSION)
def get_transaction_documents(transaction_id):
"""
Get all documents for a transaction
"""
conn = get_db_connection()
if conn is None:
return []
documents = []
try:
with conn.cursor() as cur:
cur.execute('''
SELECT document_id, filename, file_path, document_type, description, note, upload_date
FROM transaction_documents
WHERE transaction_id = %s
ORDER BY upload_date DESC
''', (transaction_id,))
documents = cur.fetchall()
except Exception as e:
logger.error(f"Error fetching documents: {e}")
finally:
conn.close()
return documents
@app.route('/transaction/<int:id>')
def view_transaction(id):
"""
View a transaction
"""
conn = get_db_connection()
if conn is None:
flash("Database connection error", "error")
@ -279,28 +308,61 @@ def view_transaction(id):
cur.execute('SELECT * FROM transactions WHERE id = %s', (id,))
transaction = cur.fetchone()
if transaction:
# Get previous transaction ID
cur.execute('SELECT id FROM transactions WHERE id < %s ORDER BY id DESC LIMIT 1', (id,))
prev_result = cur.fetchone()
if prev_result:
prev_id = prev_result['id']
if transaction is None:
abort(404)
# Get next transaction ID
cur.execute('SELECT id FROM transactions WHERE id > %s ORDER BY id ASC LIMIT 1', (id,))
next_result = cur.fetchone()
if next_result:
next_id = next_result['id']
# Get previous and next transaction IDs
cur.execute('SELECT id FROM transactions WHERE id < %s ORDER BY id DESC LIMIT 1', (id,))
prev_result = cur.fetchone()
prev_id = prev_result['id'] if prev_result else None
cur.execute('SELECT id FROM transactions WHERE id > %s ORDER BY id ASC LIMIT 1', (id,))
next_result = cur.fetchone()
next_id = next_result['id'] if next_result else None
except Exception as e:
logger.error(f"Database error: {e}")
flash(f"Database error: {e}", "error")
abort(404)
finally:
conn.close()
# Get documents for this transaction
documents = get_transaction_documents(id)
if transaction is None:
abort(404)
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, documents=documents, prev_id=prev_id, next_id=next_id, version=VERSION)
@app.route('/document/<int:document_id>')
def view_document(document_id):
"""
View a document
"""
conn = get_db_connection()
if conn is None:
flash("Database connection error", "error")
abort(404)
document = None
try:
with conn.cursor() as cur:
cur.execute('SELECT * FROM transaction_documents WHERE document_id = %s', (document_id,))
document = cur.fetchone()
if document is None:
abort(404)
except Exception as e:
logger.error(f"Database error: {e}")
abort(404)
finally:
conn.close()
if document is None:
abort(404)
# Serve the file from the uploads directory
file_path = os.path.join(app.config['UPLOAD_FOLDER'], document['file_path'])
return send_from_directory(os.path.dirname(file_path), os.path.basename(file_path), as_attachment=False)
@app.route('/pending-approval')
def pending_approval():
@ -312,10 +374,19 @@ def pending_approval():
flash("Database connection error", "error")
return render_template('pending_approval.html', transactions=[], version=VERSION)
transactions = []
try:
with conn.cursor() as cur:
cur.execute('SELECT * FROM transactions WHERE approved = FALSE ORDER BY id DESC')
transactions = cur.fetchall()
# For each transaction, count the number of documents
for transaction in transactions:
cur.execute('SELECT COUNT(*) as doc_count FROM transaction_documents WHERE transaction_id = %s',
(transaction['id'],))
doc_count_result = cur.fetchone()
transaction['document_count'] = doc_count_result['doc_count'] if doc_count_result else 0
except Exception as e:
logger.error(f"Database error: {e}")
flash(f"Database error: {e}", "error")
@ -337,8 +408,8 @@ def approve_transaction(id):
try:
with conn.cursor() as cur:
# Get the approver name from the form or use default
approver = request.form.get('approver', 'System')
# Use a default approver name instead of requiring user input
approver = "System Administrator"
# Update the transaction
cur.execute(
@ -402,9 +473,8 @@ def api_approve_transaction(id):
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')
# Use the same default approver name for consistency
approver = "System Administrator"
try:
with conn.cursor() as cur:
@ -644,6 +714,11 @@ def bootstrap_database():
logger.error("Could not get count from database")
except Exception as count_error:
logger.error(f"Error counting transactions: {count_error}")
# Run database migrations to ensure all columns exist
logger.info(f"Ploughshares v{VERSION} - Running database migrations...")
migrate_database()
logger.info(f"Ploughshares v{VERSION} - Database migrations completed.")
except Exception as e:
logger.error(f"Error checking database: {e}")
finally:

View File

@ -1,8 +1,16 @@
import os
import psycopg2
import json
import logging
from datetime import datetime
# 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')
@ -31,15 +39,15 @@ def populate_test_data():
# Extract the transactions array
if 'transactions' not in data_json:
print("Error: 'transactions' key not found in data.json")
logger.error("'transactions' key not found in data.json")
return
data = data_json['transactions']
except FileNotFoundError:
print("Error: data.json file not found")
logger.error("data.json file not found")
return
except json.JSONDecodeError:
print("Error: data.json is not valid JSON")
logger.error("data.json is not valid JSON")
return
# Insert sample transactions
@ -75,14 +83,14 @@ def populate_test_data():
item.get('contract_number', ''),
item.get('comments', '')
))
print(f"Added transaction: {item.get('company_division', 'Unknown')} - {item.get('recipient', 'Unknown')}")
logger.info(f"Added transaction: {item.get('company_division', 'Unknown')} - {item.get('recipient', 'Unknown')}")
except Exception as e:
print(f"Error inserting transaction: {e}")
logger.error(f"Error inserting transaction: {e}")
cursor.close()
conn.close()
print("Database populated with test data successfully.")
logger.info("Database populated with test data successfully.")
if __name__ == "__main__":
populate_test_data()

View File

@ -0,0 +1,20 @@
#!/usr/bin/env python3
"""
Run this script to manually apply database migrations to add the 'approved' column
to the transactions table if it doesn't exist.
"""
import logging
from migrate_approval import migrate_database
# Configure logging
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
)
logger = logging.getLogger('ploughshares')
if __name__ == "__main__":
logger.info("Starting manual database migration...")
migrate_database()
logger.info("Database migration completed.")

View File

@ -16,20 +16,6 @@ kbd {
box-shadow: inset 0 -0.1rem 0 rgba(0, 0, 0, 0.25);
}
/* Hotkey reference button */
.hotkey-help-btn {
position: fixed;
bottom: 20px;
right: 20px;
z-index: 1030;
opacity: 0.7;
transition: opacity 0.3s;
}
.hotkey-help-btn:hover {
opacity: 1;
}
/* Tooltip for hotkeys */
[data-hotkey]::after {
content: attr(data-hotkey);
@ -64,11 +50,6 @@ kbd {
/* Responsive adjustments */
@media (max-width: 768px) {
.hotkey-help-btn {
bottom: 10px;
right: 10px;
}
[data-hotkey]::after {
display: none;
}

View File

@ -348,27 +348,6 @@ function addHotkeyTooltips() {
// Get the current page based on URL
const currentPath = window.location.pathname;
// Add tooltips for global elements
const helpLink = document.createElement('div');
helpLink.className = 'position-fixed bottom-0 end-0 p-3';
helpLink.innerHTML = `
<button type="button" class="btn btn-sm btn-secondary" onclick="showHotkeyReference()"
title="Show Keyboard Shortcuts (H)" id="hotkeyHelpButton">
<i class="bi bi-keyboard"></i> <span class="d-none d-md-inline">Keyboard Shortcuts</span>
</button>
`;
document.body.appendChild(helpLink);
// Add click event listener directly
document.addEventListener('DOMContentLoaded', function() {
const hotkeyHelpButton = document.getElementById('hotkeyHelpButton');
if (hotkeyHelpButton) {
hotkeyHelpButton.addEventListener('click', function() {
showHotkeyReference();
});
}
});
// Page-specific tooltips
if (currentPath === '/' || currentPath === '/index.html') {
// Transaction list page

View File

@ -195,15 +195,11 @@
<p class="alert alert-warning">
<i class="bi bi-exclamation-triangle"></i> <strong>Important:</strong> This endpoint provides human-in-the-loop verification.
All transactions (especially those created via API) require explicit approval before being considered valid.
The approver name is recorded for audit purposes.
The system automatically records the approval with a standard identifier.
</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>
<pre class="bg-dark text-light p-2"><code id="approveTransaction">curl -X POST "http://{{ server_name }}/api/transaction/2/approve"</code></pre>
<button class="btn btn-sm btn-secondary" onclick="copyToClipboard('approveTransaction')">Copy</button>
<h5>Response:</h5>

View File

@ -29,6 +29,7 @@
<th>Source Date</th>
<th>Recipient</th>
<th>Created At</th>
<th>Documents</th>
<th>Actions</th>
</tr>
</thead>
@ -42,6 +43,13 @@
<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>
{% if transaction['document_count'] > 0 %}
<span class="badge bg-info">{{ transaction['document_count'] }}</span>
{% else %}
<span class="badge bg-secondary">0</span>
{% endif %}
</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'] }}" title="View ({{ loop.index if loop.index < 10 else '' }})">
@ -57,7 +65,7 @@
<!-- 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-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="approveModalLabel{{ transaction['id'] }}">Approve Transaction #{{ transaction['id'] }}</h5>
@ -65,10 +73,42 @@
</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 class="row">
<div class="col-md-12">
<h6>Transaction Details</h6>
<table class="table table-sm table-bordered">
<tbody>
<tr>
<th style="width: 20%;">Transaction Type:</th>
<td>{{ transaction['transaction_type'] }}</td>
</tr>
<tr>
<th>Company/Division:</th>
<td>{{ transaction['company_division'] }}</td>
</tr>
<tr>
<th>Amount:</th>
<td><span class="currency-value">{{ transaction['amount']|currency }}</span></td>
</tr>
<tr>
<th>Recipient:</th>
<td>{{ transaction['recipient'] }}</td>
</tr>
<tr>
<th>Description:</th>
<td>{{ transaction['description'] }}</td>
</tr>
</tbody>
</table>
</div>
</div>
<div class="mt-3">
<a href="{{ url_for('view_transaction', id=transaction['id']) }}" target="_blank" class="btn btn-info">
<i class="bi bi-eye"></i> View Full Details and Documents
</a>
</div>
<div class="alert alert-info mt-3">
<p>Are you sure you want to approve this transaction? The transaction will be marked as approved by the system.</p>
</div>
</div>
<div class="modal-footer">
@ -126,6 +166,29 @@
}
});
// Enable Enter key for approval modals
const approvalModals = document.querySelectorAll('[id^="approveModal"]');
approvalModals.forEach(function(modal) {
// When modal is shown, focus on the approve button
modal.addEventListener('shown.bs.modal', function() {
const approveButton = modal.querySelector('button[type="submit"]');
if (approveButton) {
approveButton.focus();
}
});
// Add keydown event listener for Enter key
modal.addEventListener('keydown', function(e) {
if (e.key === 'Enter') {
const approveButton = modal.querySelector('button[type="submit"]');
if (approveButton) {
e.preventDefault();
approveButton.click();
}
}
});
});
// Helper function to check if user is typing in an input field
function isUserTyping() {
const activeElement = document.activeElement;

View File

@ -35,7 +35,7 @@
<!-- Approve Modal -->
<div class="modal fade" id="approveModal" tabindex="-1" aria-labelledby="approveModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="approveModalLabel">Approve Transaction #{{ transaction['id'] }}</h5>
@ -43,10 +43,48 @@
</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 class="row">
<div class="col-md-6">
<h6>Transaction Details</h6>
<dl class="row">
<dt class="col-sm-4">Type:</dt>
<dd class="col-sm-8">{{ transaction['transaction_type'] }}</dd>
<dt class="col-sm-4">Company:</dt>
<dd class="col-sm-8">{{ transaction['company_division'] }}</dd>
<dt class="col-sm-4">Amount:</dt>
<dd class="col-sm-8">{{ transaction['amount']|currency }}</dd>
<dt class="col-sm-4">Recipient:</dt>
<dd class="col-sm-8">{{ transaction['recipient'] }}</dd>
<dt class="col-sm-4">Description:</dt>
<dd class="col-sm-8">{{ transaction['description'] }}</dd>
</dl>
</div>
<div class="col-md-6">
<h6>Associated Documents</h6>
{% if documents %}
<div class="list-group">
{% for doc in documents %}
<a href="{{ url_for('view_document', document_id=doc['document_id']) }}" target="_blank" class="list-group-item list-group-item-action">
<div class="d-flex w-100 justify-content-between">
<h6 class="mb-1">{{ doc['filename'] }}</h6>
<small>{{ doc['document_type'] }}</small>
</div>
<p class="mb-1">{{ doc['description'] }}</p>
<small>{{ doc['upload_date'].strftime('%Y-%m-%d %H:%M') if doc['upload_date'] else 'N/A' }}</small>
</a>
{% endfor %}
</div>
{% else %}
<div class="alert alert-info">No documents attached to this transaction.</div>
{% endif %}
</div>
</div>
<div class="alert alert-info mt-3">
<p>Are you sure you want to approve this transaction? The transaction will be marked as approved by the system.</p>
</div>
</div>
<div class="modal-footer">
@ -87,6 +125,9 @@
<li class="nav-item" role="presentation">
<button class="nav-link active" id="details-tab" data-bs-toggle="tab" data-bs-target="#details" type="button" role="tab" aria-controls="details" aria-selected="true">Details</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="documents-tab" data-bs-toggle="tab" data-bs-target="#documents" type="button" role="tab" aria-controls="documents" aria-selected="false">Documents</button>
</li>
</ul>
<div class="tab-content" id="myTabContent">
<div class="tab-pane fade show active" id="details" role="tabpanel" aria-labelledby="details-tab">
@ -154,6 +195,44 @@
</table>
</div>
</div>
<div class="tab-pane fade" id="documents" role="tabpanel" aria-labelledby="documents-tab">
<div class="p-3">
{% if documents %}
<div class="table-responsive">
<table class="table table-bordered table-hover">
<thead>
<tr>
<th>Filename</th>
<th>Type</th>
<th>Description</th>
<th>Note</th>
<th>Upload Date</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for doc in documents %}
<tr>
<td>{{ doc['filename'] }}</td>
<td>{{ doc['document_type'] }}</td>
<td>{{ doc['description'] }}</td>
<td>{{ doc['note'] }}</td>
<td>{{ doc['upload_date'].strftime('%Y-%m-%d %H:%M') if doc['upload_date'] else 'N/A' }}</td>
<td>
<a href="{{ url_for('view_document', document_id=doc['document_id']) }}" target="_blank" class="btn btn-sm btn-primary">
<i class="bi bi-eye"></i> View
</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="alert alert-info">No documents attached to this transaction.</div>
{% endif %}
</div>
</div>
</div>
<!-- Bottom navigation buttons for easier access -->
@ -185,4 +264,91 @@
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script nonce="{{ csp_nonce() }}">
document.addEventListener('DOMContentLoaded', function() {
// Enable Enter key for approval modal
const approveModal = document.getElementById('approveModal');
if (approveModal) {
// When modal is shown, focus on the approve button
approveModal.addEventListener('shown.bs.modal', function() {
const approveButton = approveModal.querySelector('button[type="submit"]');
if (approveButton) {
approveButton.focus();
}
});
// Add keydown event listener for Enter key
approveModal.addEventListener('keydown', function(e) {
if (e.key === 'Enter') {
const approveButton = approveModal.querySelector('button[type="submit"]');
if (approveButton) {
e.preventDefault();
approveButton.click();
}
}
});
}
// Transaction view keyboard shortcuts
document.addEventListener('keydown', function(e) {
if (isModalOpen()) return; // Don't trigger shortcuts if a modal is open
switch(e.key) {
case 'e': // Edit
case 'E':
const editBtn = document.querySelector('a[href$="/edit"]');
if (editBtn) {
e.preventDefault();
editBtn.click();
}
break;
case 'b': // Back
case 'B':
const backBtn = document.querySelector('a[href="/"]');
if (backBtn) {
e.preventDefault();
backBtn.click();
}
break;
case 'ArrowLeft': // Previous
const prevBtn = document.querySelector('a[aria-label="Previous transaction"]');
if (prevBtn) {
e.preventDefault();
prevBtn.click();
}
break;
case 'ArrowRight': // Next
const nextBtn = document.querySelector('a[aria-label="Next transaction"]');
if (nextBtn) {
e.preventDefault();
nextBtn.click();
}
break;
case 'a': // Approve
case 'A':
const approveBtn = document.querySelector('button[data-bs-target="#approveModal"]');
if (approveBtn) {
e.preventDefault();
approveBtn.click();
}
break;
}
});
// Helper functions
function isModalOpen() {
return document.querySelector('.modal.show') !== null;
}
function isUserTyping() {
const activeElement = document.activeElement;
return activeElement.tagName === 'INPUT' ||
activeElement.tagName === 'TEXTAREA' ||
activeElement.isContentEditable;
}
});
</script>
{% endblock %}

View File

@ -4,3 +4,4 @@ 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 22:25:55 EDT 2025: Version changed from 0.2.1 to 0.3.0
Fri Jul 4 22:33:48 EDT 2025: Version changed from 0.3.0 to 0.3.1
Fri Jul 4 23:19:37 EDT 2025: Version changed from 0.3.1 to 0.4.0