Database & Migrations: - Update all Alembic migrations for PostgreSQL compatibility - Remove SQLite-specific syntax (AUTOINCREMENT, etc.) - Add database utility helpers for PostgreSQL operations - Fix services to use PostgreSQL-compatible queries Documentation: - Add comprehensive Docker deployment guide - Add production deployment documentation - Add infrastructure architecture documentation - Update database setup guide for PostgreSQL-only - Expand troubleshooting guide Architecture & Validation: - Add migration.yaml rules for SQL compatibility checking - Enhance validate_architecture.py with migration validation - Update architecture rules to validate Alembic migrations Development: - Fix duplicate install-all target in Makefile - Add Celery/Redis validation to install.py script - Add docker-compose.test.yml for CI testing - Add squash_migrations.py utility script - Update tests for PostgreSQL compatibility - Improve test fixtures in conftest.py Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
596 lines
21 KiB
Python
Executable File
596 lines
21 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""
|
|
Wizamart Platform Installation Script
|
|
|
|
This script handles first-time installation of the Wizamart platform:
|
|
1. Validates Python version and dependencies
|
|
2. Checks environment configuration (.env file)
|
|
3. Validates required settings for production
|
|
4. Runs database migrations
|
|
5. Initializes essential data (admin, settings, CMS, email templates)
|
|
6. Provides a configuration status report
|
|
|
|
Usage:
|
|
make install
|
|
# or directly:
|
|
python scripts/install.py
|
|
|
|
This script is idempotent - safe to run multiple times.
|
|
"""
|
|
|
|
import os
|
|
import subprocess
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
# Add project root to path
|
|
project_root = Path(__file__).parent.parent
|
|
sys.path.insert(0, str(project_root))
|
|
|
|
|
|
# =============================================================================
|
|
# CONSOLE OUTPUT HELPERS
|
|
# =============================================================================
|
|
|
|
class Colors:
|
|
"""ANSI color codes for terminal output."""
|
|
HEADER = "\033[95m"
|
|
BLUE = "\033[94m"
|
|
CYAN = "\033[96m"
|
|
GREEN = "\033[92m"
|
|
WARNING = "\033[93m"
|
|
FAIL = "\033[91m"
|
|
ENDC = "\033[0m"
|
|
BOLD = "\033[1m"
|
|
DIM = "\033[2m"
|
|
|
|
|
|
def print_header(text: str):
|
|
"""Print a bold header."""
|
|
print(f"\n{Colors.BOLD}{Colors.HEADER}{'=' * 70}{Colors.ENDC}")
|
|
print(f"{Colors.BOLD}{Colors.HEADER} {text}{Colors.ENDC}")
|
|
print(f"{Colors.BOLD}{Colors.HEADER}{'=' * 70}{Colors.ENDC}")
|
|
|
|
|
|
def print_section(text: str):
|
|
"""Print a section header."""
|
|
print(f"\n{Colors.BOLD}{Colors.CYAN}[*] {text}{Colors.ENDC}")
|
|
|
|
|
|
def print_step(step: int, total: int, text: str):
|
|
"""Print a step indicator."""
|
|
print(f"\n{Colors.BLUE}[{step}/{total}] {text}{Colors.ENDC}")
|
|
|
|
|
|
def print_success(text: str):
|
|
"""Print success message."""
|
|
print(f" {Colors.GREEN}✓{Colors.ENDC} {text}")
|
|
|
|
|
|
def print_warning(text: str):
|
|
"""Print warning message."""
|
|
print(f" {Colors.WARNING}⚠{Colors.ENDC} {text}")
|
|
|
|
|
|
def print_error(text: str):
|
|
"""Print error message."""
|
|
print(f" {Colors.FAIL}✗{Colors.ENDC} {text}")
|
|
|
|
|
|
def print_info(text: str):
|
|
"""Print info message."""
|
|
print(f" {Colors.DIM}→{Colors.ENDC} {text}")
|
|
|
|
|
|
# =============================================================================
|
|
# VALIDATION FUNCTIONS
|
|
# =============================================================================
|
|
|
|
def check_python_version() -> bool:
|
|
"""Check if Python version is supported."""
|
|
major, minor = sys.version_info[:2]
|
|
if major < 3 or (major == 3 and minor < 11):
|
|
print_error(f"Python 3.11+ required. Found: {major}.{minor}")
|
|
return False
|
|
print_success(f"Python version: {major}.{minor}")
|
|
return True
|
|
|
|
|
|
def check_env_file() -> tuple[bool, dict]:
|
|
"""Check if .env file exists and load it."""
|
|
env_path = project_root / ".env"
|
|
env_example_path = project_root / ".env.example"
|
|
|
|
if not env_path.exists():
|
|
if env_example_path.exists():
|
|
print_warning(".env file not found")
|
|
print_info("Copy .env.example to .env and configure it:")
|
|
print_info(" cp .env.example .env")
|
|
return False, {}
|
|
else:
|
|
print_warning("Neither .env nor .env.example found")
|
|
return False, {}
|
|
|
|
print_success(".env file found")
|
|
|
|
# Load .env manually to check values
|
|
env_vars = {}
|
|
with open(env_path) as f:
|
|
for line in f:
|
|
line = line.strip()
|
|
if line and not line.startswith("#") and "=" in line:
|
|
key, _, value = line.partition("=")
|
|
# Remove quotes if present
|
|
value = value.strip().strip("'\"")
|
|
env_vars[key.strip()] = value
|
|
|
|
return True, env_vars
|
|
|
|
|
|
def validate_configuration(env_vars: dict) -> dict:
|
|
"""
|
|
Validate configuration and return status for each category.
|
|
|
|
Returns dict with categories and their status:
|
|
{
|
|
"category": {
|
|
"status": "ok" | "warning" | "missing",
|
|
"message": "...",
|
|
"items": [...]
|
|
}
|
|
}
|
|
"""
|
|
results = {}
|
|
|
|
# -------------------------------------------------------------------------
|
|
# Database
|
|
# -------------------------------------------------------------------------
|
|
db_url = env_vars.get("DATABASE_URL", "")
|
|
if db_url and "sqlite" not in db_url.lower():
|
|
results["database"] = {
|
|
"status": "ok",
|
|
"message": "Production database configured",
|
|
"items": [f"URL: {db_url[:50]}..."]
|
|
}
|
|
elif db_url and "sqlite" in db_url.lower():
|
|
results["database"] = {
|
|
"status": "warning",
|
|
"message": "SQLite database (OK for development)",
|
|
"items": ["Consider PostgreSQL for production"]
|
|
}
|
|
else:
|
|
results["database"] = {
|
|
"status": "ok",
|
|
"message": "Using default SQLite database",
|
|
"items": ["Configure DATABASE_URL for production"]
|
|
}
|
|
|
|
# -------------------------------------------------------------------------
|
|
# Security (JWT)
|
|
# -------------------------------------------------------------------------
|
|
jwt_key = env_vars.get("JWT_SECRET_KEY", "")
|
|
if jwt_key and jwt_key != "change-this-in-production" and len(jwt_key) >= 32:
|
|
results["security"] = {
|
|
"status": "ok",
|
|
"message": "JWT secret configured",
|
|
"items": ["Secret key is set and sufficiently long"]
|
|
}
|
|
elif jwt_key and jwt_key == "change-this-in-production":
|
|
results["security"] = {
|
|
"status": "warning",
|
|
"message": "Using default JWT secret",
|
|
"items": [
|
|
"CRITICAL: Change JWT_SECRET_KEY for production!",
|
|
"Use: openssl rand -hex 32"
|
|
]
|
|
}
|
|
else:
|
|
results["security"] = {
|
|
"status": "warning",
|
|
"message": "JWT secret not explicitly set",
|
|
"items": ["Set JWT_SECRET_KEY in .env"]
|
|
}
|
|
|
|
# -------------------------------------------------------------------------
|
|
# Admin Credentials
|
|
# -------------------------------------------------------------------------
|
|
admin_pass = env_vars.get("ADMIN_PASSWORD", "admin123")
|
|
admin_email = env_vars.get("ADMIN_EMAIL", "admin@wizamart.com")
|
|
if admin_pass != "admin123":
|
|
results["admin"] = {
|
|
"status": "ok",
|
|
"message": "Admin credentials configured",
|
|
"items": [f"Email: {admin_email}"]
|
|
}
|
|
else:
|
|
results["admin"] = {
|
|
"status": "warning",
|
|
"message": "Using default admin password",
|
|
"items": [
|
|
"Set ADMIN_PASSWORD in .env",
|
|
"Change immediately after first login"
|
|
]
|
|
}
|
|
|
|
# -------------------------------------------------------------------------
|
|
# Stripe Billing
|
|
# -------------------------------------------------------------------------
|
|
stripe_secret = env_vars.get("STRIPE_SECRET_KEY", "")
|
|
stripe_pub = env_vars.get("STRIPE_PUBLISHABLE_KEY", "")
|
|
stripe_webhook = env_vars.get("STRIPE_WEBHOOK_SECRET", "")
|
|
|
|
if stripe_secret and stripe_pub:
|
|
if stripe_secret.startswith("sk_live_"):
|
|
results["stripe"] = {
|
|
"status": "ok",
|
|
"message": "Stripe LIVE mode configured",
|
|
"items": [
|
|
"Live secret key set",
|
|
f"Webhook secret: {'configured' if stripe_webhook else 'NOT SET'}"
|
|
]
|
|
}
|
|
elif stripe_secret.startswith("sk_test_"):
|
|
results["stripe"] = {
|
|
"status": "warning",
|
|
"message": "Stripe TEST mode configured",
|
|
"items": [
|
|
"Using test keys (OK for development)",
|
|
"Switch to live keys for production"
|
|
]
|
|
}
|
|
else:
|
|
results["stripe"] = {
|
|
"status": "warning",
|
|
"message": "Stripe keys set but format unclear",
|
|
"items": ["Verify STRIPE_SECRET_KEY format"]
|
|
}
|
|
else:
|
|
results["stripe"] = {
|
|
"status": "missing",
|
|
"message": "Stripe not configured",
|
|
"items": [
|
|
"Set STRIPE_SECRET_KEY",
|
|
"Set STRIPE_PUBLISHABLE_KEY",
|
|
"Set STRIPE_WEBHOOK_SECRET",
|
|
"Billing features will not work!"
|
|
]
|
|
}
|
|
|
|
# -------------------------------------------------------------------------
|
|
# Email Configuration
|
|
# -------------------------------------------------------------------------
|
|
email_provider = env_vars.get("EMAIL_PROVIDER", "smtp")
|
|
email_enabled = env_vars.get("EMAIL_ENABLED", "true").lower() == "true"
|
|
email_debug = env_vars.get("EMAIL_DEBUG", "false").lower() == "true"
|
|
|
|
if not email_enabled:
|
|
results["email"] = {
|
|
"status": "warning",
|
|
"message": "Email disabled",
|
|
"items": ["EMAIL_ENABLED=false - no emails will be sent"]
|
|
}
|
|
elif email_debug:
|
|
results["email"] = {
|
|
"status": "warning",
|
|
"message": "Email in debug mode",
|
|
"items": ["EMAIL_DEBUG=true - emails logged, not sent"]
|
|
}
|
|
elif email_provider == "smtp":
|
|
smtp_host = env_vars.get("SMTP_HOST", "localhost")
|
|
smtp_user = env_vars.get("SMTP_USER", "")
|
|
if smtp_host != "localhost" and smtp_user:
|
|
results["email"] = {
|
|
"status": "ok",
|
|
"message": f"SMTP configured ({smtp_host})",
|
|
"items": [f"User: {smtp_user}"]
|
|
}
|
|
else:
|
|
results["email"] = {
|
|
"status": "warning",
|
|
"message": "SMTP using defaults",
|
|
"items": [
|
|
"Configure SMTP_HOST, SMTP_USER, SMTP_PASSWORD",
|
|
"Or use EMAIL_DEBUG=true for development"
|
|
]
|
|
}
|
|
elif email_provider == "sendgrid":
|
|
api_key = env_vars.get("SENDGRID_API_KEY", "")
|
|
if api_key:
|
|
results["email"] = {
|
|
"status": "ok",
|
|
"message": "SendGrid configured",
|
|
"items": ["API key set"]
|
|
}
|
|
else:
|
|
results["email"] = {
|
|
"status": "missing",
|
|
"message": "SendGrid selected but not configured",
|
|
"items": ["Set SENDGRID_API_KEY"]
|
|
}
|
|
elif email_provider == "mailgun":
|
|
api_key = env_vars.get("MAILGUN_API_KEY", "")
|
|
domain = env_vars.get("MAILGUN_DOMAIN", "")
|
|
if api_key and domain:
|
|
results["email"] = {
|
|
"status": "ok",
|
|
"message": f"Mailgun configured ({domain})",
|
|
"items": ["API key and domain set"]
|
|
}
|
|
else:
|
|
results["email"] = {
|
|
"status": "missing",
|
|
"message": "Mailgun selected but not configured",
|
|
"items": ["Set MAILGUN_API_KEY and MAILGUN_DOMAIN"]
|
|
}
|
|
elif email_provider == "ses":
|
|
access_key = env_vars.get("AWS_ACCESS_KEY_ID", "")
|
|
secret_key = env_vars.get("AWS_SECRET_ACCESS_KEY", "")
|
|
if access_key and secret_key:
|
|
results["email"] = {
|
|
"status": "ok",
|
|
"message": "Amazon SES configured",
|
|
"items": [f"Region: {env_vars.get('AWS_REGION', 'eu-west-1')}"]
|
|
}
|
|
else:
|
|
results["email"] = {
|
|
"status": "missing",
|
|
"message": "SES selected but not configured",
|
|
"items": ["Set AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY"]
|
|
}
|
|
|
|
# -------------------------------------------------------------------------
|
|
# Platform Domain
|
|
# -------------------------------------------------------------------------
|
|
domain = env_vars.get("PLATFORM_DOMAIN", "wizamart.com")
|
|
if domain != "wizamart.com":
|
|
results["domain"] = {
|
|
"status": "ok",
|
|
"message": f"Custom domain: {domain}",
|
|
"items": []
|
|
}
|
|
else:
|
|
results["domain"] = {
|
|
"status": "warning",
|
|
"message": "Using default domain",
|
|
"items": ["Set PLATFORM_DOMAIN for your deployment"]
|
|
}
|
|
|
|
# -------------------------------------------------------------------------
|
|
# Celery / Redis Task Queue
|
|
# -------------------------------------------------------------------------
|
|
redis_url = env_vars.get("REDIS_URL", "")
|
|
use_celery = env_vars.get("USE_CELERY", "false").lower() == "true"
|
|
flower_url = env_vars.get("FLOWER_URL", "")
|
|
flower_password = env_vars.get("FLOWER_PASSWORD", "")
|
|
|
|
if use_celery:
|
|
if redis_url:
|
|
celery_items = [f"Redis: {redis_url.split('@')[-1] if '@' in redis_url else redis_url}"]
|
|
|
|
if flower_url:
|
|
celery_items.append(f"Flower: {flower_url}")
|
|
else:
|
|
celery_items.append("FLOWER_URL not set (monitoring disabled)")
|
|
|
|
if flower_password and flower_password != "changeme":
|
|
celery_items.append("Flower password configured")
|
|
elif flower_password == "changeme":
|
|
celery_items.append("WARNING: Change FLOWER_PASSWORD for production!")
|
|
|
|
results["celery"] = {
|
|
"status": "ok",
|
|
"message": "Celery enabled with Redis",
|
|
"items": celery_items
|
|
}
|
|
else:
|
|
results["celery"] = {
|
|
"status": "missing",
|
|
"message": "Celery enabled but Redis not configured",
|
|
"items": [
|
|
"Set REDIS_URL (e.g., redis://localhost:6379/0)",
|
|
"Or disable Celery: USE_CELERY=false"
|
|
]
|
|
}
|
|
else:
|
|
results["celery"] = {
|
|
"status": "warning",
|
|
"message": "Celery disabled (using FastAPI BackgroundTasks)",
|
|
"items": [
|
|
"Set USE_CELERY=true for production",
|
|
"Requires Redis: docker-compose up -d redis"
|
|
]
|
|
}
|
|
|
|
return results
|
|
|
|
|
|
def print_configuration_report(config_status: dict):
|
|
"""Print a formatted configuration status report."""
|
|
print_section("Configuration Status")
|
|
|
|
ok_count = 0
|
|
warning_count = 0
|
|
missing_count = 0
|
|
|
|
for category, status in config_status.items():
|
|
if status["status"] == "ok":
|
|
icon = f"{Colors.GREEN}✓{Colors.ENDC}"
|
|
ok_count += 1
|
|
elif status["status"] == "warning":
|
|
icon = f"{Colors.WARNING}⚠{Colors.ENDC}"
|
|
warning_count += 1
|
|
else:
|
|
icon = f"{Colors.FAIL}✗{Colors.ENDC}"
|
|
missing_count += 1
|
|
|
|
print(f"\n {icon} {Colors.BOLD}{category.upper()}{Colors.ENDC}: {status['message']}")
|
|
for item in status.get("items", []):
|
|
print(f" {Colors.DIM}→ {item}{Colors.ENDC}")
|
|
|
|
print(f"\n {Colors.DIM}─" * 35 + Colors.ENDC)
|
|
print(f" Summary: {Colors.GREEN}{ok_count} OK{Colors.ENDC}, "
|
|
f"{Colors.WARNING}{warning_count} warnings{Colors.ENDC}, "
|
|
f"{Colors.FAIL}{missing_count} missing{Colors.ENDC}")
|
|
|
|
return ok_count, warning_count, missing_count
|
|
|
|
|
|
# =============================================================================
|
|
# INSTALLATION STEPS
|
|
# =============================================================================
|
|
|
|
def run_migrations() -> bool:
|
|
"""Run database migrations."""
|
|
try:
|
|
result = subprocess.run(
|
|
[sys.executable, "-m", "alembic", "upgrade", "head"],
|
|
cwd=project_root,
|
|
capture_output=True,
|
|
text=True
|
|
)
|
|
if result.returncode == 0:
|
|
print_success("Migrations completed successfully")
|
|
return True
|
|
else:
|
|
print_error("Migration failed")
|
|
if result.stderr:
|
|
print_info(result.stderr[:500])
|
|
return False
|
|
except Exception as e:
|
|
print_error(f"Failed to run migrations: {e}")
|
|
return False
|
|
|
|
|
|
def run_init_script(script_name: str, description: str) -> bool:
|
|
"""Run an initialization script."""
|
|
script_path = project_root / "scripts" / script_name
|
|
try:
|
|
result = subprocess.run(
|
|
[sys.executable, str(script_path)],
|
|
cwd=project_root,
|
|
capture_output=True,
|
|
text=True
|
|
)
|
|
if result.returncode == 0:
|
|
print_success(description)
|
|
return True
|
|
else:
|
|
print_error(f"Failed: {description}")
|
|
if result.stderr:
|
|
print_info(result.stderr[:300])
|
|
return False
|
|
except Exception as e:
|
|
print_error(f"Error running {script_name}: {e}")
|
|
return False
|
|
|
|
|
|
# =============================================================================
|
|
# MAIN INSTALLATION FLOW
|
|
# =============================================================================
|
|
|
|
def main():
|
|
"""Main installation flow."""
|
|
print_header("WIZAMART PLATFORM INSTALLATION")
|
|
|
|
# -------------------------------------------------------------------------
|
|
# Step 1: Pre-flight checks
|
|
# -------------------------------------------------------------------------
|
|
print_step(1, 4, "Pre-flight checks")
|
|
|
|
if not check_python_version():
|
|
sys.exit(1)
|
|
|
|
env_exists, env_vars = check_env_file()
|
|
if not env_exists:
|
|
print()
|
|
print_warning("Installation can continue with defaults,")
|
|
print_warning("but you should configure .env before going to production.")
|
|
print()
|
|
response = input("Continue with default configuration? [y/N]: ")
|
|
if response.lower() != "y":
|
|
print("\nInstallation cancelled. Please configure .env first.")
|
|
sys.exit(0)
|
|
env_vars = {}
|
|
|
|
# -------------------------------------------------------------------------
|
|
# Step 2: Validate configuration
|
|
# -------------------------------------------------------------------------
|
|
print_step(2, 4, "Validating configuration")
|
|
|
|
config_status = validate_configuration(env_vars)
|
|
ok_count, warning_count, missing_count = print_configuration_report(config_status)
|
|
|
|
if missing_count > 0:
|
|
print()
|
|
print_warning(f"{missing_count} critical configuration(s) missing!")
|
|
print_warning("The platform may not function correctly.")
|
|
response = input("\nContinue anyway? [y/N]: ")
|
|
if response.lower() != "y":
|
|
print("\nInstallation cancelled. Please fix configuration first.")
|
|
sys.exit(0)
|
|
|
|
# -------------------------------------------------------------------------
|
|
# Step 3: Database setup
|
|
# -------------------------------------------------------------------------
|
|
print_step(3, 4, "Database setup")
|
|
|
|
print_info("Running database migrations...")
|
|
if not run_migrations():
|
|
print_error("Failed to run migrations. Cannot continue.")
|
|
sys.exit(1)
|
|
|
|
# -------------------------------------------------------------------------
|
|
# Step 4: Initialize platform data
|
|
# -------------------------------------------------------------------------
|
|
print_step(4, 4, "Initializing platform data")
|
|
|
|
init_scripts = [
|
|
("init_production.py", "Admin user and platform settings"),
|
|
("init_log_settings.py", "Log settings"),
|
|
("create_default_content_pages.py", "Default CMS pages"),
|
|
("create_platform_pages.py", "Platform pages and landing"),
|
|
("seed_email_templates.py", "Email templates"),
|
|
]
|
|
|
|
all_success = True
|
|
for script, description in init_scripts:
|
|
if not run_init_script(script, description):
|
|
all_success = False
|
|
|
|
# -------------------------------------------------------------------------
|
|
# Summary
|
|
# -------------------------------------------------------------------------
|
|
print_header("INSTALLATION COMPLETE")
|
|
|
|
if all_success:
|
|
print(f"\n {Colors.GREEN}✓ Platform installed successfully!{Colors.ENDC}")
|
|
else:
|
|
print(f"\n {Colors.WARNING}⚠ Installation completed with some errors{Colors.ENDC}")
|
|
|
|
print(f"\n {Colors.BOLD}Next Steps:{Colors.ENDC}")
|
|
print(" 1. Review any warnings above")
|
|
|
|
if warning_count > 0 or missing_count > 0:
|
|
print(" 2. Update .env with production values")
|
|
print(" 3. Run 'make install' again to verify")
|
|
else:
|
|
print(" 2. Start the application: make dev")
|
|
|
|
print(f"\n {Colors.BOLD}Admin Login:{Colors.ENDC}")
|
|
admin_email = env_vars.get("ADMIN_EMAIL", "admin@wizamart.com")
|
|
print(f" URL: /admin/login")
|
|
print(f" Email: {admin_email}")
|
|
print(f" Password: {'(configured in .env)' if env_vars.get('ADMIN_PASSWORD') else 'admin123'}")
|
|
|
|
if not env_vars.get("ADMIN_PASSWORD"):
|
|
print(f"\n {Colors.WARNING}⚠ CHANGE DEFAULT PASSWORD IMMEDIATELY!{Colors.ENDC}")
|
|
|
|
print(f"\n {Colors.BOLD}For demo data (development only):{Colors.ENDC}")
|
|
print(" make seed-demo")
|
|
|
|
print()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|