diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..58de578 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,83 @@ +# Git +.git +.gitignore + +# Python +__pycache__ +*.py[cod] +*$py.class +*.so +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg + +# Virtual environments +venv/ +ENV/ +env/ +.venv/ + +# IDE +.idea/ +.vscode/ +*.swp +*.swo +*~ + +# Testing +.pytest_cache/ +.coverage +htmlcov/ +.tox/ +.nox/ + +# Environment files (will be mounted or passed via env vars) +.env +.env.local +.env.*.local +*.env + +# Logs +*.log +logs/ + +# Database +*.db +*.sqlite3 + +# Alembic +alembic/versions/__pycache__/ + +# Docker +Dockerfile +docker-compose*.yml +.docker/ + +# Documentation +*.md +docs/ + +# Temporary files +tmp/ +temp/ +*.tmp + +# OS files +.DS_Store +Thumbs.db + +# Uploads (will be mounted as volume) +uploads/ diff --git a/.gitignore b/.gitignore index fc7287d..7e6ee36 100644 --- a/.gitignore +++ b/.gitignore @@ -245,6 +245,9 @@ temp_uploads/ tmp/ temporary/ +# Generated SQL files (from scripts) +create_superadmin.sql + # CSV imports imports/*.csv !imports/.gitkeep diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..e533c87 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,40 @@ +# Backend Dockerfile - FastAPI with Python +FROM python:3.11-slim + +# Set environment variables +ENV PYTHONDONTWRITEBYTECODE=1 +ENV PYTHONUNBUFFERED=1 +ENV PYTHONPATH=/app + +# Set work directory +WORKDIR /app + +# Install system dependencies +RUN apt-get update && apt-get install -y \ + gcc \ + libpq-dev \ + curl \ + && rm -rf /var/lib/apt/lists/* + +# Install Python dependencies +COPY requirements.txt . +RUN pip install --no-cache-dir --upgrade pip && \ + pip install --no-cache-dir -r requirements.txt + +# Copy application code +COPY . . + +# Create non-root user for security +RUN adduser --disabled-password --gecos '' appuser && \ + chown -R appuser:appuser /app +USER appuser + +# Expose port +EXPOSE 8000 + +# Health check +HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ + CMD curl -f http://localhost:8000/health || exit 1 + +# Run the application +CMD ["uvicorn", "server:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/create_superadmin.py b/create_superadmin.py index 7644bc9..2b6e68c 100644 --- a/create_superadmin.py +++ b/create_superadmin.py @@ -1,38 +1,15 @@ #!/usr/bin/env python3 """ Create Superadmin User Script -Generates a superadmin user with hashed password for LOAF membership platform +Directly creates a superadmin user in the database for LOAF membership platform """ -import bcrypt import sys import os from getpass import getpass -def generate_password_hash(password: str) -> str: - """Generate bcrypt hash for password""" - return bcrypt.hashpw(password.encode(), bcrypt.gensalt()).decode() - -def generate_sql(email: str, password_hash: str, first_name: str, last_name: str) -> str: - """Generate SQL INSERT statement""" - return f""" --- Create Superadmin User -INSERT INTO users ( - id, email, password_hash, first_name, last_name, - status, role, email_verified, created_at, updated_at -) VALUES ( - gen_random_uuid(), - '{email}', - '{password_hash}', - '{first_name}', - '{last_name}', - 'active', - 'superadmin', - true, - NOW(), - NOW() -); -""" +# Add the backend directory to path for imports +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) def main(): print("=" * 70) @@ -40,6 +17,15 @@ def main(): print("=" * 70) print() + # Check for DATABASE_URL + from dotenv import load_dotenv + load_dotenv() + + database_url = os.getenv("DATABASE_URL") + if not database_url: + print("❌ DATABASE_URL not found in environment or .env file") + sys.exit(1) + # Get user input email = input("Email address: ").strip() if not email or '@' not in email: @@ -68,31 +54,89 @@ def main(): sys.exit(1) print() - print("Generating password hash...") - password_hash = generate_password_hash(password) + print("Creating superadmin user...") - print("✅ Password hash generated") - print() - print("=" * 70) - print("SQL STATEMENT") - print("=" * 70) + try: + # Import database dependencies + from sqlalchemy import create_engine, text + from passlib.context import CryptContext - sql = generate_sql(email, password_hash, first_name, last_name) - print(sql) + # Create password hash + pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") + password_hash = pwd_context.hash(password) - # Save to file - output_file = "create_superadmin.sql" - with open(output_file, 'w') as f: - f.write(sql) + # Connect to database + engine = create_engine(database_url) - print("=" * 70) - print(f"✅ SQL saved to: {output_file}") - print() - print("Run this command to create the user:") - print(f" psql -U postgres -d loaf_new -f {output_file}") - print() - print("Or copy the SQL above and run it directly in psql") - print("=" * 70) + with engine.connect() as conn: + # Check if user already exists + result = conn.execute( + text("SELECT id FROM users WHERE email = :email"), + {"email": email} + ) + if result.fetchone(): + print(f"❌ User with email '{email}' already exists") + sys.exit(1) + + # Insert superadmin user + conn.execute( + text(""" + INSERT INTO users ( + id, email, password_hash, first_name, last_name, + phone, address, city, state, zipcode, date_of_birth, + status, role, email_verified, + newsletter_subscribed, accepts_tos, + created_at, updated_at + ) VALUES ( + gen_random_uuid(), + :email, + :password_hash, + :first_name, + :last_name, + '', + '', + '', + '', + '', + '1990-01-01', + 'active', + 'superadmin', + true, + false, + true, + NOW(), + NOW() + ) + """), + { + "email": email, + "password_hash": password_hash, + "first_name": first_name, + "last_name": last_name + } + ) + conn.commit() + + print() + print("=" * 70) + print("✅ Superadmin user created successfully!") + print("=" * 70) + print() + print(f" Email: {email}") + print(f" Name: {first_name} {last_name}") + print(f" Role: superadmin") + print(f" Status: active") + print() + print("You can now log in with these credentials.") + print("=" * 70) + + except ImportError as e: + print(f"❌ Missing dependency: {e}") + print(" Run: pip install sqlalchemy psycopg2-binary passlib python-dotenv") + sys.exit(1) + except Exception as e: + print(f"❌ Database error: {e}") + sys.exit(1) if __name__ == "__main__": try: @@ -100,6 +144,3 @@ if __name__ == "__main__": except KeyboardInterrupt: print("\n\n❌ Cancelled by user") sys.exit(1) - except Exception as e: - print(f"\n❌ Error: {e}") - sys.exit(1) diff --git a/migrations/000_initial_schema.sql b/migrations/000_initial_schema.sql index 87c5f8a..bf84705 100644 --- a/migrations/000_initial_schema.sql +++ b/migrations/000_initial_schema.sql @@ -94,6 +94,30 @@ BEGIN; -- SECTION 2: Create Core Tables -- ============================================================================ +-- Import Jobs table (must be created before users due to FK reference) +CREATE TABLE IF NOT EXISTS import_jobs ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + + filename VARCHAR NOT NULL, + status importjobstatus NOT NULL DEFAULT 'processing', + total_rows INTEGER DEFAULT 0, + processed_rows INTEGER DEFAULT 0, + success_count INTEGER DEFAULT 0, + error_count INTEGER DEFAULT 0, + error_log JSONB DEFAULT '[]'::jsonb, + + -- WordPress import enhancements + field_mapping JSONB DEFAULT '{}'::jsonb, + wordpress_metadata JSONB DEFAULT '{}'::jsonb, + imported_user_ids JSONB DEFAULT '[]'::jsonb, + rollback_at TIMESTAMP WITH TIME ZONE, + rollback_by UUID, -- Will be updated with FK after users table exists + + started_by UUID, -- Will be updated with FK after users table exists + started_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + completed_at TIMESTAMP WITH TIME ZONE +); + -- Users table CREATE TABLE IF NOT EXISTS users ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), @@ -103,6 +127,7 @@ CREATE TABLE IF NOT EXISTS users ( password_hash VARCHAR NOT NULL, email_verified BOOLEAN NOT NULL DEFAULT FALSE, email_verification_token VARCHAR UNIQUE, + email_verification_expires TIMESTAMP WITH TIME ZONE, -- Personal Information first_name VARCHAR NOT NULL, @@ -113,7 +138,6 @@ CREATE TABLE IF NOT EXISTS users ( state VARCHAR(2), zipcode VARCHAR(10), date_of_birth DATE, - bio TEXT, -- Profile profile_photo_url VARCHAR, @@ -137,20 +161,44 @@ CREATE TABLE IF NOT EXISTS users ( -- Status & Role status userstatus NOT NULL DEFAULT 'pending_email', role userrole NOT NULL DEFAULT 'guest', - role_id UUID, -- For dynamic RBAC (added in later migration) + role_id UUID, -- For dynamic RBAC - -- Rejection Tracking - rejection_reason TEXT, - rejected_at TIMESTAMP WITH TIME ZONE, - rejected_by UUID REFERENCES users(id), + -- Newsletter Preferences + newsletter_subscribed BOOLEAN DEFAULT TRUE, + newsletter_publish_name BOOLEAN DEFAULT FALSE NOT NULL, + newsletter_publish_photo BOOLEAN DEFAULT FALSE NOT NULL, + newsletter_publish_birthday BOOLEAN DEFAULT FALSE NOT NULL, + newsletter_publish_none BOOLEAN DEFAULT FALSE NOT NULL, + + -- Volunteer Interests + volunteer_interests JSONB DEFAULT '[]'::jsonb, + + -- Scholarship Request + scholarship_requested BOOLEAN DEFAULT FALSE NOT NULL, + scholarship_reason TEXT, + + -- Directory Settings + show_in_directory BOOLEAN DEFAULT FALSE NOT NULL, + directory_email VARCHAR, + directory_bio TEXT, + directory_address VARCHAR, + directory_phone VARCHAR, + directory_dob DATE, + directory_partner_name VARCHAR, + + -- Password Reset + password_reset_token VARCHAR, + password_reset_expires TIMESTAMP WITH TIME ZONE, + force_password_change BOOLEAN DEFAULT FALSE NOT NULL, + + -- Terms of Service + accepts_tos BOOLEAN DEFAULT FALSE NOT NULL, + tos_accepted_at TIMESTAMP WITH TIME ZONE, -- Membership member_since DATE, - accepts_tos BOOLEAN DEFAULT FALSE, - tos_accepted_at TIMESTAMP WITH TIME ZONE, - newsletter_subscribed BOOLEAN DEFAULT TRUE, - -- Reminder Tracking (from migration 004) + -- Reminder Tracking email_verification_reminders_sent INTEGER DEFAULT 0 NOT NULL, last_email_verification_reminder_at TIMESTAMP WITH TIME ZONE, event_attendance_reminders_sent INTEGER DEFAULT 0 NOT NULL, @@ -160,12 +208,21 @@ CREATE TABLE IF NOT EXISTS users ( renewal_reminders_sent INTEGER DEFAULT 0 NOT NULL, last_renewal_reminder_at TIMESTAMP WITH TIME ZONE, + -- Rejection Tracking + rejection_reason TEXT, + rejected_at TIMESTAMP WITH TIME ZONE, + rejected_by UUID REFERENCES users(id), + -- WordPress Import Tracking import_source VARCHAR(50), import_job_id UUID REFERENCES import_jobs(id), wordpress_user_id BIGINT, wordpress_registered_date TIMESTAMP WITH TIME ZONE, + -- Role Change Audit Trail + role_changed_at TIMESTAMP WITH TIME ZONE, + role_changed_by UUID REFERENCES users(id) ON DELETE SET NULL, + -- Timestamps created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP @@ -255,11 +312,23 @@ CREATE TABLE IF NOT EXISTS subscription_plans ( name VARCHAR NOT NULL, description TEXT, price_cents INTEGER NOT NULL, - billing_cycle VARCHAR NOT NULL DEFAULT 'annual', + billing_cycle VARCHAR NOT NULL DEFAULT 'yearly', + stripe_price_id VARCHAR, -- Legacy, deprecated -- Configuration active BOOLEAN NOT NULL DEFAULT TRUE, - features JSONB DEFAULT '[]'::jsonb, + + -- Custom billing cycle fields (for recurring date ranges like Jan 1 - Dec 31) + custom_cycle_enabled BOOLEAN DEFAULT FALSE NOT NULL, + custom_cycle_start_month INTEGER, + custom_cycle_start_day INTEGER, + custom_cycle_end_month INTEGER, + custom_cycle_end_day INTEGER, + + -- Dynamic pricing fields + minimum_price_cents INTEGER DEFAULT 3000 NOT NULL, + suggested_price_cents INTEGER, + allow_donation BOOLEAN DEFAULT TRUE NOT NULL, -- Timestamps created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, @@ -281,13 +350,21 @@ CREATE TABLE IF NOT EXISTS subscriptions ( status subscriptionstatus DEFAULT 'active', start_date TIMESTAMP WITH TIME ZONE NOT NULL, end_date TIMESTAMP WITH TIME ZONE, - next_billing_date TIMESTAMP WITH TIME ZONE, -- Payment Details amount_paid_cents INTEGER, base_subscription_cents INTEGER NOT NULL, donation_cents INTEGER DEFAULT 0 NOT NULL, + -- Stripe transaction metadata (for validation and audit) + stripe_payment_intent_id VARCHAR, + stripe_charge_id VARCHAR, + stripe_invoice_id VARCHAR, + payment_completed_at TIMESTAMP WITH TIME ZONE, + card_last4 VARCHAR(4), + card_brand VARCHAR(20), + stripe_receipt_url VARCHAR, + -- Manual Payment Support manual_payment BOOLEAN DEFAULT FALSE NOT NULL, manual_payment_notes TEXT, @@ -319,6 +396,14 @@ CREATE TABLE IF NOT EXISTS donations ( stripe_payment_intent_id VARCHAR, payment_method VARCHAR, + -- Stripe transaction metadata (for validation and audit) + stripe_charge_id VARCHAR, + stripe_customer_id VARCHAR, + payment_completed_at TIMESTAMP WITH TIME ZONE, + card_last4 VARCHAR(4), + card_brand VARCHAR(20), + stripe_receipt_url VARCHAR, + -- Metadata notes TEXT, created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, @@ -466,29 +551,10 @@ CREATE TABLE IF NOT EXISTS user_invitations ( created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP ); --- Import Jobs table -CREATE TABLE IF NOT EXISTS import_jobs ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - - filename VARCHAR NOT NULL, - status importjobstatus NOT NULL DEFAULT 'processing', - total_rows INTEGER DEFAULT 0, - processed_rows INTEGER DEFAULT 0, - success_count INTEGER DEFAULT 0, - error_count INTEGER DEFAULT 0, - error_log JSONB DEFAULT '[]'::jsonb, - - -- WordPress import enhancements - field_mapping JSONB DEFAULT '{}'::jsonb, - wordpress_metadata JSONB DEFAULT '{}'::jsonb, - imported_user_ids JSONB DEFAULT '[]'::jsonb, - rollback_at TIMESTAMP WITH TIME ZONE, - rollback_by UUID REFERENCES users(id), - - started_by UUID REFERENCES users(id), - started_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, - completed_at TIMESTAMP WITH TIME ZONE -); +-- Add FK constraints to import_jobs (now that users table exists) +ALTER TABLE import_jobs + ADD CONSTRAINT fk_import_jobs_rollback_by FOREIGN KEY (rollback_by) REFERENCES users(id), + ADD CONSTRAINT fk_import_jobs_started_by FOREIGN KEY (started_by) REFERENCES users(id); -- Import Rollback Audit table (for tracking rollback operations) CREATE TABLE IF NOT EXISTS import_rollback_audit ( @@ -542,12 +608,18 @@ CREATE INDEX IF NOT EXISTS idx_subscriptions_user_id ON subscriptions(user_id); CREATE INDEX IF NOT EXISTS idx_subscriptions_plan_id ON subscriptions(plan_id); CREATE INDEX IF NOT EXISTS idx_subscriptions_status ON subscriptions(status); CREATE INDEX IF NOT EXISTS idx_subscriptions_stripe_subscription_id ON subscriptions(stripe_subscription_id); +CREATE INDEX IF NOT EXISTS idx_subscriptions_payment_intent ON subscriptions(stripe_payment_intent_id); +CREATE INDEX IF NOT EXISTS idx_subscriptions_charge_id ON subscriptions(stripe_charge_id); +CREATE INDEX IF NOT EXISTS idx_subscriptions_invoice_id ON subscriptions(stripe_invoice_id); -- Donations indexes CREATE INDEX IF NOT EXISTS idx_donation_user ON donations(user_id); CREATE INDEX IF NOT EXISTS idx_donation_type ON donations(donation_type); CREATE INDEX IF NOT EXISTS idx_donation_status ON donations(status); CREATE INDEX IF NOT EXISTS idx_donation_created ON donations(created_at); +CREATE INDEX IF NOT EXISTS idx_donation_payment_intent ON donations(stripe_payment_intent_id); +CREATE INDEX IF NOT EXISTS idx_donation_charge_id ON donations(stripe_charge_id); +CREATE INDEX IF NOT EXISTS idx_donation_customer_id ON donations(stripe_customer_id); -- Import Jobs indexes CREATE INDEX IF NOT EXISTS idx_import_jobs_status ON import_jobs(status); diff --git a/seed_permissions_rbac.py b/seed_permissions_rbac.py index 6e0529b..1b810a3 100755 --- a/seed_permissions_rbac.py +++ b/seed_permissions_rbac.py @@ -118,6 +118,40 @@ PERMISSIONS = [ {"code": "permissions.audit", "name": "View Permission Audit Log", "description": "View permission change audit logs", "module": "permissions"}, ] +# Default system roles that must exist +DEFAULT_ROLES = [ + { + "code": "guest", + "name": "Guest", + "description": "Default role for new registrations with no special permissions", + "is_system_role": True + }, + { + "code": "member", + "name": "Member", + "description": "Active paying members with access to member-only content", + "is_system_role": True + }, + { + "code": "finance", + "name": "Finance", + "description": "Financial management role with access to payments, subscriptions, and reports", + "is_system_role": True + }, + { + "code": "admin", + "name": "Admin", + "description": "Board members with full management access except RBAC", + "is_system_role": True + }, + { + "code": "superadmin", + "name": "Superadmin", + "description": "Full system access including RBAC management", + "is_system_role": True + }, +] + # Default permission assignments for dynamic roles DEFAULT_ROLE_PERMISSIONS = { "guest": [], # Guests have no permissions @@ -196,7 +230,34 @@ def seed_permissions(): print(f"\n⚠️ WARNING: Tables not fully cleared! Stopping.") return - # Step 2: Create permissions + # Step 2: Create default system roles + print(f"\n👤 Creating {len(DEFAULT_ROLES)} system roles...") + role_map = {} + + for role_data in DEFAULT_ROLES: + # Check if role already exists + existing_role = db.query(Role).filter(Role.code == role_data["code"]).first() + if existing_role: + print(f" • {role_data['name']}: Already exists, updating...") + existing_role.name = role_data["name"] + existing_role.description = role_data["description"] + existing_role.is_system_role = role_data["is_system_role"] + role_map[role_data["code"]] = existing_role + else: + print(f" • {role_data['name']}: Creating...") + role = Role( + code=role_data["code"], + name=role_data["name"], + description=role_data["description"], + is_system_role=role_data["is_system_role"] + ) + db.add(role) + role_map[role_data["code"]] = role + + db.commit() + print(f"✓ Created/updated {len(DEFAULT_ROLES)} system roles") + + # Step 3: Create permissions print(f"\n📝 Creating {len(PERMISSIONS)} permissions...") permission_map = {} # Map code to permission object @@ -213,13 +274,13 @@ def seed_permissions(): db.commit() print(f"✓ Created {len(PERMISSIONS)} permissions") - # Step 3: Get all roles from database - print("\n🔍 Fetching dynamic roles...") + # Step 4: Verify roles exist + print("\n🔍 Verifying dynamic roles...") roles = db.query(Role).all() role_map = {role.code: role for role in roles} print(f"✓ Found {len(roles)} roles: {', '.join(role_map.keys())}") - # Step 4: Assign permissions to roles + # Step 5: Assign permissions to roles print("\n🔐 Assigning permissions to roles...") from models import UserRole # Import for enum mapping @@ -258,7 +319,7 @@ def seed_permissions(): db.commit() print(f" ✓ {role.name}: Assigned {len(permission_codes)} permissions") - # Step 5: Summary + # Step 6: Summary print("\n" + "=" * 80) print("📊 SEEDING SUMMARY") print("=" * 80) @@ -273,7 +334,8 @@ def seed_permissions(): for module, count in sorted(modules.items()): print(f" • {module.capitalize()}: {count} permissions") - print(f"\nTotal permissions created: {len(PERMISSIONS)}") + print(f"\nTotal system roles created: {len(DEFAULT_ROLES)}") + print(f"Total permissions created: {len(PERMISSIONS)}") print(f"Total role-permission mappings: {total_assigned}") print("\n✅ Permission seeding completed successfully!") print("\nNext step: Restart backend server") diff --git a/server.py b/server.py index 00676ec..3ed669e 100644 --- a/server.py +++ b/server.py @@ -97,6 +97,15 @@ logging.basicConfig( ) logger = logging.getLogger(__name__) +# ============================================================ +# Health Check Endpoint (for Kubernetes probes) +# ============================================================ + +@app.get("/health") +async def health_check(): + """Health check endpoint for Kubernetes liveness/readiness probes.""" + return {"status": "healthy", "service": "membership-backend"} + # ============================================================ # Helper Functions # ============================================================