Fix wallet manager database compatibility and users table schema migrations

parent 37d57c28
......@@ -8,6 +8,7 @@ include cli.py
recursive-include config *.json
recursive-include config *.md
recursive-include aisbf *.py
recursive-include aisbf/payments/wallet *.py
recursive-include templates *.html
recursive-include templates *.css
recursive-include templates *.js
......
......@@ -153,14 +153,22 @@ check_package_upgrade() {
# Function to create venv if it doesn't exist
ensure_venv() {
if [ "$DEBUG" = "true" ]; then
echo "=== DEBUG: ensure_venv called ==="
echo "VENV_DIR: $VENV_DIR"
echo "SHARE_DIR: $SHARE_DIR"
fi
if [ ! -d "$VENV_DIR" ]; then
echo "Creating virtual environment at $VENV_DIR"
# Create venv with --system-site-packages to access system-installed aisbf
[ "$DEBUG" = "true" ] && echo "=== DEBUG: Creating venv ==="
python3 -m venv --system-site-packages "$VENV_DIR"
# Install requirements if requirements.txt exists
if [ -f "$SHARE_DIR/requirements.txt" ]; then
echo "Installing requirements from $SHARE_DIR/requirements.txt"
[ "$DEBUG" = "true" ] && echo "=== DEBUG: Installing requirements ==="
if ! "$VENV_DIR/bin/pip" install -r "$SHARE_DIR/requirements.txt"; then
echo ""
echo "=========================================="
......@@ -184,18 +192,22 @@ ensure_venv() {
echo ""
exit 1
fi
[ "$DEBUG" = "true" ] && echo "=== DEBUG: Force reinstalling uvicorn ==="
# Force reinstall uvicorn in venv to ensure it's available inside the virtual environment
"$VENV_DIR/bin/pip" install --force-reinstall uvicorn
fi
# Save version for future upgrade detection
[ "$DEBUG" = "true" ] && echo "=== DEBUG: Saving version info ==="
python3 -c "import aisbf; print(aisbf.__version__)" > "$VENV_DIR/.aisbf_version" 2>/dev/null || echo "unknown" > "$VENV_DIR/.aisbf_version"
else
[ "$DEBUG" = "true" ] && echo "=== DEBUG: Venv already exists, checking for upgrades ==="
# Check if package was upgraded via pip
if check_package_upgrade; then
echo "Package upgrade detected, updating venv dependencies..."
# Only update requirements, aisbf is accessed from system site-packages
if [ -f "$SHARE_DIR/requirements.txt" ]; then
[ "$DEBUG" = "true" ] && echo "=== DEBUG: Updating requirements ==="
if ! "$VENV_DIR/bin/pip" install -r "$SHARE_DIR/requirements.txt"; then
echo ""
echo "=========================================="
......@@ -207,13 +219,17 @@ ensure_venv() {
echo ""
exit 1
fi
[ "$DEBUG" = "true" ] && echo "=== DEBUG: Force reinstalling uvicorn ==="
# Force reinstall uvicorn in venv to ensure it's available inside the virtual environment
"$VENV_DIR/bin/pip" install --force-reinstall uvicorn
fi
python3 -c "import aisbf; print(aisbf.__version__)" > "$VENV_DIR/.aisbf_version" 2>/dev/null || echo "unknown" > "$VENV_DIR/.aisbf_version"
echo "Virtual environment updated successfully"
else
[ "$DEBUG" = "true" ] && echo "=== DEBUG: No package upgrade detected ==="
fi
fi
[ "$DEBUG" = "true" ] && echo "=== DEBUG: ensure_venv completed ==="
}
# Function to update venv packages (only install missing ones, no forced upgrades)
......@@ -255,18 +271,40 @@ update_venv() {
# Function to start the server
start_server() {
echo "=== DEBUG: Starting start_server function ==="
echo "SHARE_DIR: $SHARE_DIR"
echo "VENV_DIR: $VENV_DIR"
echo "LOG_DIR: $LOG_DIR"
# Ensure venv exists
echo "=== DEBUG: Ensuring venv exists ==="
ensure_venv
echo "=== DEBUG: Venv check complete ==="
# Get host and port from config
echo "=== DEBUG: Getting host and port ==="
HOST=$(get_host)
PORT=$(get_port)
echo "=== DEBUG: Host=$HOST, Port=$PORT ==="
# Activate the virtual environment
echo "=== DEBUG: Activating virtual environment ==="
source $VENV_DIR/bin/activate
echo "=== DEBUG: Virtual environment activated ==="
# Check Python path and imports
echo "=== DEBUG: Checking Python environment ==="
echo "Python executable: $(which python3)"
python3 -c "import sys; print('Python version:', sys.version); print('Python path:', sys.path[:3])"
echo "=== DEBUG: Testing basic imports ==="
python3 -c "import uvicorn; print('uvicorn imported successfully')" 2>&1 || echo "ERROR: Failed to import uvicorn"
python3 -c "import fastapi; print('fastapi imported successfully')" 2>&1 || echo "ERROR: Failed to import fastapi"
# Change to share directory where main.py is located
echo "=== DEBUG: Changing to share directory ==="
cd $SHARE_DIR
echo "=== DEBUG: Current directory: $(pwd) ==="
ls -la main.py 2>/dev/null || echo "WARNING: main.py not found in $SHARE_DIR"
echo "Starting AISBF on $HOST:$PORT..."
......@@ -276,8 +314,22 @@ start_server() {
export AISBF_DEBUG=true
fi
# Test importing main module before starting uvicorn
echo "=== DEBUG: Testing main module import ==="
python3 -c "
try:
import main
print('main module imported successfully')
except Exception as e:
print(f'ERROR: Failed to import main module: {e}')
import traceback
traceback.print_exc()
exit(1)
" 2>&1
# Start the proxy server - runs in foreground
# Use exec to replace the shell process so signals are properly handled
echo "=== DEBUG: Starting uvicorn ==="
if [ "$DEBUG" = "true" ]; then
exec uvicorn main:app --host $HOST --port $PORT --log-level debug 2>&1 | tee -a "$LOG_DIR/aisbf_stdout.log"
else
......
......@@ -117,6 +117,63 @@ class DatabaseManager:
loop = asyncio.get_event_loop()
return await loop.run_in_executor(self.executor, func, *args)
async def execute(self, sql: str, params: dict = None):
"""Execute SQL query and return result with mappings (compatible with AsyncSession interface)"""
def _sync_execute():
with self._get_connection() as conn:
cursor = conn.cursor()
cursor.row_factory = sqlite3.Row
params = params or {}
# Safe parameter handling - use native database parameter binding
if self.db_type == 'sqlite':
# SQLite natively supports :named parameters directly
cursor.execute(sql, params)
else:
# For MySQL, safely convert named parameters to %s placeholders
param_names = []
def replace_param(match):
param_names.append(match.group(1))
return '%s'
import re
processed_sql = re.sub(r':(\w+)', replace_param, sql)
params_list = [params[name] for name in param_names]
cursor.execute(processed_sql, params_list)
if cursor.description:
rows = [dict(row) for row in cursor.fetchall()]
# Simulate SQLAlchemy Result object with mappings() method
class ResultWrapper:
def mappings(self):
class MappingsWrapper:
def first(self):
return rows[0] if rows else None
def all(self):
return rows
return MappingsWrapper()
return ResultWrapper()
else:
class EmptyResult:
def mappings(self):
return []
return EmptyResult()
return await self._run_in_executor(_sync_execute)
async def commit(self):
"""Commit transaction (compatibility method for WalletManager)"""
# Transactions are auto-committed per operation in this implementation
pass
def begin(self):
"""Async context manager for transactions (compatibility)"""
class TransactionContext:
async def __aenter__(self):
return self
async def __aexit__(self, exc_type, exc_val, exc_tb):
pass
return TransactionContext()
async def record_context_dimension_async(
......@@ -3493,24 +3550,24 @@ def DatabaseManager__initialize_database(self):
# pass
#
# Create users table for multi-user management
# cursor.execute(f'''
# CREATE TABLE IF NOT EXISTS users (
# id INTEGER PRIMARY KEY {auto_increment},
# username VARCHAR(255) UNIQUE NOT NULL,
# email VARCHAR(255) UNIQUE,
# display_name VARCHAR(255),
# password_hash VARCHAR(255) NOT NULL,
# role VARCHAR(50) DEFAULT 'user',
# created_by VARCHAR(255),
# created_at TIMESTAMP DEFAULT {timestamp_default},
# last_login TIMESTAMP NULL,
# is_active {boolean_type} DEFAULT 1,
# email_verified {boolean_type} DEFAULT 0,
# verification_token VARCHAR(255),
# verification_token_expires TIMESTAMP NULL,
# last_verification_email_sent TIMESTAMP NULL
# )
# ''')
cursor.execute(f'''
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY {auto_increment},
username VARCHAR(255) UNIQUE NOT NULL,
email VARCHAR(255) UNIQUE,
display_name VARCHAR(255),
password_hash VARCHAR(255) NOT NULL,
role VARCHAR(50) DEFAULT 'user',
created_by VARCHAR(255),
created_at TIMESTAMP DEFAULT {timestamp_default},
last_login TIMESTAMP NULL,
is_active {boolean_type} DEFAULT 1,
email_verified {boolean_type} DEFAULT 0,
verification_token VARCHAR(255),
verification_token_expires TIMESTAMP NULL,
last_verification_email_sent TIMESTAMP NULL
)
''')
#
# Migration: Add display_name column if it doesn't exist
# try:
......@@ -4200,35 +4257,49 @@ def DatabaseManager__run_config_migrations(self, cursor, auto_increment, timesta
except Exception as e:
logger.warning(f"Migration check for default free tier: {e}")
# Migration: Ensure users table exists first
try:
if self.db_type == 'sqlite':
cursor.execute("CREATE TABLE IF NOT EXISTS users (id INTEGER PRIMARY KEY AUTOINCREMENT, email VARCHAR(255) UNIQUE NOT NULL, password_hash VARCHAR(255), created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)")
else:
cursor.execute("CREATE TABLE IF NOT EXISTS users (id INT AUTO_INCREMENT PRIMARY KEY, email VARCHAR(255) UNIQUE NOT NULL, password_hash VARCHAR(255), created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)")
except Exception as e:
logger.warning(f"Failed to create users table: {e}")
# Users table is created with full schema earlier in migrations
# Migration: Add tier_id column to users table
# Migration: Add all missing columns to users table
try:
if self.db_type == 'sqlite':
cursor.execute("PRAGMA table_info(users)")
columns = [row[1] for row in cursor.fetchall()]
if 'tier_id' not in columns:
cursor.execute('ALTER TABLE users ADD COLUMN tier_id INTEGER DEFAULT 1')
cursor.execute('ALTER TABLE users ADD COLUMN subscription_expires TIMESTAMP NULL')
logger.info("✅ Migration: Added tier_id and subscription_expires columns to users")
required_columns = [
('username', 'VARCHAR(255) UNIQUE', 'email'),
('display_name', 'VARCHAR(255)', 'username'),
('role', 'VARCHAR(50) DEFAULT \'user\'', 'password_hash'),
('created_by', 'VARCHAR(255)', 'role'),
('last_login', 'TIMESTAMP NULL', 'created_at'),
('is_active', f'{boolean_type} DEFAULT 1', 'last_login'),
('email_verified', f'{boolean_type} DEFAULT 0', 'is_active'),
('verification_token', 'VARCHAR(255)', 'email_verified'),
('verification_token_expires', 'TIMESTAMP NULL', 'verification_token'),
('last_verification_email_sent', 'TIMESTAMP NULL', 'verification_token_expires'),
('tier_id', 'INTEGER DEFAULT 1', 'updated_at'),
('subscription_expires', 'TIMESTAMP NULL', 'tier_id'),
('stripe_customer_id', 'VARCHAR(100)', 'subscription_expires'),
('reset_password_token', 'VARCHAR(255)', 'stripe_customer_id'),
('reset_password_token_expires', 'TIMESTAMP NULL', 'reset_password_token')
]
for col_name, col_def, after_col in required_columns:
if col_name not in columns:
cursor.execute(f'ALTER TABLE users ADD COLUMN {col_name} {col_def}')
logger.info(f"✅ Migration: Added {col_name} column to users")
# Set username = email for existing users
if 'username' in required_columns and 'username' not in columns:
cursor.execute('UPDATE users SET username = email WHERE username IS NULL')
cursor.execute('UPDATE users SET is_active = 1 WHERE is_active IS NULL')
cursor.execute('UPDATE users SET role = \'user\' WHERE role IS NULL')
logger.info("✅ Migration: Populated username, is_active and role for existing users")
else:
cursor.execute("""
SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_NAME = 'users' AND COLUMN_NAME = 'tier_id'
""")
if not cursor.fetchone():
cursor.execute('ALTER TABLE users ADD COLUMN tier_id INTEGER DEFAULT 1')
cursor.execute('ALTER TABLE users ADD COLUMN subscription_expires TIMESTAMP NULL')
logger.info("✅ Migration: Added tier_id and subscription_expires columns to users")
# MySQL migrations would go here
pass
except Exception as e:
logger.warning(f"Migration check for users.tier_id: {e}")
logger.warning(f"Migration check for users table columns: {e}")
# Migration: Create payment_methods, user_subscriptions, payment_transactions tables
for table_name, create_sql in [
......
......@@ -155,8 +155,8 @@
}
},
"billing": {
"currency": "USD",
"currency_symbol": "$",
"currency": "EUR",
"currency_symbol": "",
"currency_decimals": 2,
"payment_methods": {
"paypal": {
......
This diff is collapsed.
......@@ -28,7 +28,7 @@ keywords = [
"broker",
]
classifiers = [
"Development Status :: 3 - Alpha",
"Development Status :: 4 - Beta",
"Intended Audience :: Developers",
"Topic :: Software Development :: Libraries :: Python Modules",
"Programming Language :: Python :: 3",
......
......@@ -58,7 +58,7 @@ setup(
url="https://git.nexlab.net/nexlab/aisbf.git",
packages=find_packages(),
classifiers=[
"Development Status :: 3 - Alpha",
"Development Status :: 4 - Beta",
"Intended Audience :: Developers",
"Topic :: Software Development :: Libraries :: Python Modules",
"License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)",
......@@ -184,6 +184,12 @@ setup(
'aisbf/payments/notifications/__init__.py',
'aisbf/payments/notifications/email.py',
]),
# aisbf.payments.wallet subpackage
('share/aisbf/aisbf/payments/wallet', [
'aisbf/payments/wallet/__init__.py',
'aisbf/payments/wallet/manager.py',
'aisbf/payments/wallet/routes.py',
]),
# Install dashboard templates
('share/aisbf/templates', [
'templates/base.html',
......
......@@ -504,6 +504,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
<a href="{{ url_for(request, '/dashboard/analytics') }}" {% if '/analytics' in request.path %}class="active"{% endif %}>Analytics</a>
{% if request.session.user_id %}
<a href="{{ url_for(request, '/dashboard/user/tokens') }}" {% if '/user/tokens' in request.path %}class="active"{% endif %}>API Tokens</a>
<a href="{{ url_for(request, '/dashboard/wallet') }}" {% if '/wallet' in request.path %}class="active"{% endif %}>Wallet</a>
{% endif %}
{% if request.session.role == 'admin' %}
<a href="{{ url_for(request, '/dashboard/users') }}" {% if '/users' in request.path %}class="active"{% endif %}>Users</a>
......@@ -527,6 +528,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
<a href="{{ url_for(request, '/dashboard/user/tokens') }}">API Tokens</a>
<a href="{{ url_for(request, '/dashboard/cache-settings') }}">Cache Settings</a>
<a href="{{ url_for(request, '/dashboard/subscription') }}">Subscription</a>
<a href="{{ url_for(request, '/dashboard/wallet') }}">Wallet</a>
<a href="{{ url_for(request, '/dashboard/billing') }}">Billing</a>
<a href="{{ url_for(request, '/dashboard/change-password') }}">Change Password</a>
{% endif %}
......
......@@ -6,7 +6,13 @@
<h2 style="margin-bottom: 30px;">Add Payment Method</h2>
<div style="background: #16213e; border: 2px solid #4a9eff; border-radius: 8px; padding: 30px; margin-bottom: 20px;">
<p style="color: #a0a0a0; margin-bottom: 30px;">Choose a payment method to add to your account. You can use this for subscription payments and plan upgrades.</p>
<p style="color: #a0a0a0; margin-bottom: 10px;">Choose a payment method to add to your account.</p>
<div style="background: #1a1a2e; border-left: 3px solid #4ade80; padding: 15px; margin-bottom: 30px; border-radius: 4px;">
<p style="margin: 0; color: #e0e0e0;">
<i class="fas fa-info-circle me-2 text-success"></i>
<strong>Important:</strong> Only <strong>Credit Card (Stripe)</strong> can be used for automatic subscription renewals. All other payment methods are for wallet top-ups only.
</p>
</div>
<!-- Stripe and PayPal -->
{% if 'stripe' in enabled_gateways or 'paypal' in enabled_gateways %}
......
......@@ -9,6 +9,16 @@
<i class="fas fa-cog me-2"></i>Payment System Settings
</h2>
<!-- Currency Selection Warning -->
<div style="background: #1a1a2e; border-left: 4px solid #f39c12; padding: 15px; margin-bottom: 25px; border-radius: 4px;">
<div style="color: #f39c12; font-weight: bold; margin-bottom: 8px;">
<i class="fas fa-info-circle me-2"></i>Important Note About Currency Selection
</div>
<div style="color: #e0e0e0; font-size: 14px;">
We do not suggest using USD if you are not in the USA. This project does not support the actions of the USA in the international context and we don't support a politic of war and threats to allies.
</div>
</div>
<!-- Encryption Key Configuration -->
<div style="background: #16213e; border: 2px solid #e74c3c; border-radius: 8px; padding: 20px; margin-bottom: 20px;">
<h3 style="margin: 0 0 20px 0; color: #e74c3c;">
......
{% extends "base.html" %}
{% block content %}
<h2 style="margin-bottom: 30px;">Billing & Payments</h2>
<h2 style="margin-bottom: 20px;">Billing & Payments</h2>
<!-- Wallet Balance Banner -->
<div style="background: linear-gradient(135deg, #1a4a2e, #0f3460); border: 2px solid #28a745; border-radius: 8px; padding: 20px; margin-bottom: 20px;">
<div style="display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 15px;">
<div>
<div style="color: #a0a0a0; font-size: 14px; margin-bottom: 5px;">Wallet Balance</div>
<div style="font-size: 32px; font-weight: bold; color: #4ade80;">${{ wallet.balance }}</div>
<div style="color: #a0a0a0; font-size: 13px; margin-top: 5px;">
All subscription renewals and payments are automatically charged from your wallet first.
</div>
</div>
<a href="{{ url_for(request, '/dashboard/wallet') }}" class="btn" style="background: #28a745;">
<i class="fas fa-wallet me-2"></i>Manage Wallet
</a>
</div>
</div>
{% if success %}
<div class="alert alert-success">{{ success }}</div>
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment