13 Commits

Author SHA1 Message Date
kayela
5ab0038c0a Merge branch 'dev' into docker 2026-01-17 13:37:04 -06:00
kayela
38e5f5377a Merge branch 'dev' into docker 2026-01-06 12:31:29 -06:00
kayela
e06f18ce17 Add start script for backend server initialization and update .gitignore 2026-01-06 12:30:26 -06:00
kayela
84285861cc Merge branch 'dev' into docker 2026-01-05 13:01:16 -06:00
kayela
56d1b97261 docker deleted 2026-01-05 12:58:22 -06:00
kayela
6b6173bd5b Refactor docker-compose.yml by removing unnecessary lines and cleaning up formatting 2025-12-26 17:33:40 -06:00
kayela
cf8d38a4a4 Remove database service configuration from docker-compose 2025-12-26 16:50:21 -06:00
kayela
09712e52bb Merge remote-tracking branch 'origin/dev' into docker 2025-12-26 16:47:24 -06:00
kayela
366245acc7 Add database service configuration to docker-compose 2025-12-24 13:00:42 -06:00
kayela
a75bf743f4 Merge remote-tracking branch 'origin/dev' into docker 2025-12-24 12:46:32 -06:00
kayela
fb369977d0 Update compiled Python bytecode for server module 2025-12-24 12:30:09 -06:00
kayela
1ed9aa0994 Merge remote-tracking branch 'origin' into docker 2025-12-19 12:51:56 -06:00
kayela-c
04783f66f1 docker set up 2025-12-13 12:23:28 -06:00
25 changed files with 150 additions and 860 deletions

View File

@@ -1,83 +0,0 @@
# 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/

6
.gitignore vendored
View File

@@ -1,3 +1,5 @@
.env
.venv
# ============================================================================
# Python Backend .gitignore
# For FastAPI + PostgreSQL + Cloudflare R2 + Stripe
@@ -8,6 +10,7 @@
.env.*
!.env.example
.envrc
.sh
# ===== Python =====
# Byte-compiled / optimized / DLL files
@@ -245,9 +248,6 @@ temp_uploads/
tmp/
temporary/
# Generated SQL files (from scripts)
create_superadmin.sql
# CSV imports
imports/*.csv
!imports/.gitkeep

View File

@@ -1,40 +1,20 @@
# Backend Dockerfile - FastAPI with Python
FROM python:3.11-slim
# Use an official Python image (Linux)
FROM python:3.12-slim
# Set environment variables
ENV PYTHONDONTWRITEBYTECODE=1
ENV PYTHONUNBUFFERED=1
ENV PYTHONPATH=/app
# Set work directory
# Set a working 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 dependency list
COPY requirements.txt .
RUN pip install --no-cache-dir --upgrade pip && \
pip install --no-cache-dir -r requirements.txt
# Copy application code
# Install dependencies
RUN pip3 install --no-cache-dir -r requirements.txt
# Copy the rest of the project
COPY . .
# Create non-root user for security
RUN adduser --disabled-password --gecos '' appuser && \
chown -R appuser:appuser /app
USER appuser
# Expose port
# Expose port (whatever your backend runs on)
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"]
# Run exactly your command
CMD ["python", "-m", "uvicorn", "server:app", "--host", "0.0.0.0", "--port", "8000", "--reload"]

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -1,76 +0,0 @@
"""add_stripe_transaction_metadata
Revision ID: 956ea1628264
Revises: ec4cb4a49cde
Create Date: 2026-01-20 22:00:01.806931
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '956ea1628264'
down_revision: Union[str, None] = 'ec4cb4a49cde'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# Add Stripe transaction metadata to subscriptions table
op.add_column('subscriptions', sa.Column('stripe_payment_intent_id', sa.String(), nullable=True))
op.add_column('subscriptions', sa.Column('stripe_charge_id', sa.String(), nullable=True))
op.add_column('subscriptions', sa.Column('stripe_invoice_id', sa.String(), nullable=True))
op.add_column('subscriptions', sa.Column('payment_completed_at', sa.DateTime(timezone=True), nullable=True))
op.add_column('subscriptions', sa.Column('card_last4', sa.String(4), nullable=True))
op.add_column('subscriptions', sa.Column('card_brand', sa.String(20), nullable=True))
op.add_column('subscriptions', sa.Column('stripe_receipt_url', sa.String(), nullable=True))
# Add indexes for Stripe transaction IDs in subscriptions
op.create_index('idx_subscriptions_payment_intent', 'subscriptions', ['stripe_payment_intent_id'])
op.create_index('idx_subscriptions_charge_id', 'subscriptions', ['stripe_charge_id'])
op.create_index('idx_subscriptions_invoice_id', 'subscriptions', ['stripe_invoice_id'])
# Add Stripe transaction metadata to donations table
op.add_column('donations', sa.Column('stripe_charge_id', sa.String(), nullable=True))
op.add_column('donations', sa.Column('stripe_customer_id', sa.String(), nullable=True))
op.add_column('donations', sa.Column('payment_completed_at', sa.DateTime(timezone=True), nullable=True))
op.add_column('donations', sa.Column('card_last4', sa.String(4), nullable=True))
op.add_column('donations', sa.Column('card_brand', sa.String(20), nullable=True))
op.add_column('donations', sa.Column('stripe_receipt_url', sa.String(), nullable=True))
# Add indexes for Stripe transaction IDs in donations
op.create_index('idx_donations_payment_intent', 'donations', ['stripe_payment_intent_id'])
op.create_index('idx_donations_charge_id', 'donations', ['stripe_charge_id'])
op.create_index('idx_donations_customer_id', 'donations', ['stripe_customer_id'])
def downgrade() -> None:
# Remove indexes from donations
op.drop_index('idx_donations_customer_id', table_name='donations')
op.drop_index('idx_donations_charge_id', table_name='donations')
op.drop_index('idx_donations_payment_intent', table_name='donations')
# Remove columns from donations
op.drop_column('donations', 'stripe_receipt_url')
op.drop_column('donations', 'card_brand')
op.drop_column('donations', 'card_last4')
op.drop_column('donations', 'payment_completed_at')
op.drop_column('donations', 'stripe_customer_id')
op.drop_column('donations', 'stripe_charge_id')
# Remove indexes from subscriptions
op.drop_index('idx_subscriptions_invoice_id', table_name='subscriptions')
op.drop_index('idx_subscriptions_charge_id', table_name='subscriptions')
op.drop_index('idx_subscriptions_payment_intent', table_name='subscriptions')
# Remove columns from subscriptions
op.drop_column('subscriptions', 'stripe_receipt_url')
op.drop_column('subscriptions', 'card_brand')
op.drop_column('subscriptions', 'card_last4')
op.drop_column('subscriptions', 'payment_completed_at')
op.drop_column('subscriptions', 'stripe_invoice_id')
op.drop_column('subscriptions', 'stripe_charge_id')
op.drop_column('subscriptions', 'stripe_payment_intent_id')

View File

@@ -1,15 +1,38 @@
#!/usr/bin/env python3
"""
Create Superadmin User Script
Directly creates a superadmin user in the database for LOAF membership platform
Generates a superadmin user with hashed password for LOAF membership platform
"""
import bcrypt
import sys
import os
from getpass import getpass
# Add the backend directory to path for imports
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
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()
);
"""
def main():
print("=" * 70)
@@ -17,15 +40,6 @@ 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:
@@ -54,89 +68,31 @@ def main():
sys.exit(1)
print()
print("Creating superadmin user...")
print("Generating password hash...")
password_hash = generate_password_hash(password)
try:
# Import database dependencies
from sqlalchemy import create_engine, text
from passlib.context import CryptContext
print("✅ Password hash generated")
print()
print("=" * 70)
print("SQL STATEMENT")
print("=" * 70)
# Create password hash
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
password_hash = pwd_context.hash(password)
sql = generate_sql(email, password_hash, first_name, last_name)
print(sql)
# Connect to database
engine = create_engine(database_url)
# Save to file
output_file = "create_superadmin.sql"
with open(output_file, 'w') as f:
f.write(sql)
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)
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)
if __name__ == "__main__":
try:
@@ -144,3 +100,6 @@ 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)

14
docker-compose.yml Normal file
View File

@@ -0,0 +1,14 @@
services:
backend:
build:
context: .
dockerfile: Dockerfile # Use Dockerfile.prod for production
ports:
- "8000:8000"
env_file:
- .env
environment:
DATABASE_URL: ${DATABASE_URL}
volumes:
- .:/app # sync code for hot reload

View File

@@ -94,30 +94,6 @@ 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(),
@@ -127,7 +103,6 @@ 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,
@@ -138,6 +113,7 @@ CREATE TABLE IF NOT EXISTS users (
state VARCHAR(2),
zipcode VARCHAR(10),
date_of_birth DATE,
bio TEXT,
-- Profile
profile_photo_url VARCHAR,
@@ -161,44 +137,20 @@ 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
role_id UUID, -- For dynamic RBAC (added in later migration)
-- 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,
-- Rejection Tracking
rejection_reason TEXT,
rejected_at TIMESTAMP WITH TIME ZONE,
rejected_by UUID REFERENCES users(id),
-- Membership
member_since DATE,
accepts_tos BOOLEAN DEFAULT FALSE,
tos_accepted_at TIMESTAMP WITH TIME ZONE,
newsletter_subscribed BOOLEAN DEFAULT TRUE,
-- Reminder Tracking
-- Reminder Tracking (from migration 004)
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,
@@ -208,21 +160,12 @@ 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
@@ -312,23 +255,11 @@ CREATE TABLE IF NOT EXISTS subscription_plans (
name VARCHAR NOT NULL,
description TEXT,
price_cents INTEGER NOT NULL,
billing_cycle VARCHAR NOT NULL DEFAULT 'yearly',
stripe_price_id VARCHAR, -- Legacy, deprecated
billing_cycle VARCHAR NOT NULL DEFAULT 'annual',
-- Configuration
active BOOLEAN NOT NULL DEFAULT TRUE,
-- 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,
features JSONB DEFAULT '[]'::jsonb,
-- Timestamps
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
@@ -350,21 +281,13 @@ 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,
@@ -396,14 +319,6 @@ 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,
@@ -551,10 +466,29 @@ CREATE TABLE IF NOT EXISTS user_invitations (
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);
-- 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 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
);
-- Import Rollback Audit table (for tracking rollback operations)
CREATE TABLE IF NOT EXISTS import_rollback_audit (
@@ -608,18 +542,12 @@ 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);

View File

@@ -238,15 +238,6 @@ class Subscription(Base):
donation_cents = Column(Integer, default=0, nullable=False) # Additional donation amount
# Note: amount_paid_cents = base_subscription_cents + donation_cents
# Stripe transaction metadata (for validation and audit)
stripe_payment_intent_id = Column(String, nullable=True, index=True) # Initial payment transaction ID
stripe_charge_id = Column(String, nullable=True, index=True) # Actual charge reference
stripe_invoice_id = Column(String, nullable=True, index=True) # Invoice reference
payment_completed_at = Column(DateTime(timezone=True), nullable=True) # Exact payment timestamp from Stripe
card_last4 = Column(String(4), nullable=True) # Last 4 digits of card
card_brand = Column(String(20), nullable=True) # Visa, Mastercard, etc.
stripe_receipt_url = Column(String, nullable=True) # Link to Stripe receipt
# Manual payment fields
manual_payment = Column(Boolean, default=False, nullable=False) # Whether this was a manual offline payment
manual_payment_notes = Column(Text, nullable=True) # Admin notes about the payment
@@ -278,17 +269,9 @@ class Donation(Base):
# Payment details
stripe_checkout_session_id = Column(String, nullable=True)
stripe_payment_intent_id = Column(String, nullable=True, index=True)
stripe_payment_intent_id = Column(String, nullable=True)
payment_method = Column(String, nullable=True) # card, bank_transfer, etc.
# Stripe transaction metadata (for validation and audit)
stripe_charge_id = Column(String, nullable=True, index=True) # Actual charge reference
stripe_customer_id = Column(String, nullable=True, index=True) # Customer ID if created
payment_completed_at = Column(DateTime(timezone=True), nullable=True) # Exact payment timestamp from Stripe
card_last4 = Column(String(4), nullable=True) # Last 4 digits of card
card_brand = Column(String(20), nullable=True) # Visa, Mastercard, etc.
stripe_receipt_url = Column(String, nullable=True) # Link to Stripe receipt
# Metadata
notes = Column(Text, nullable=True)
created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))

View File

@@ -31,7 +31,7 @@ motor==3.3.1
msal==1.27.0
mypy==1.18.2
mypy_extensions==1.1.0
numpy==2.3.5
numpy==2.2.6
oauthlib==3.3.1
packaging==25.0
pandas==2.3.3

View File

@@ -118,40 +118,6 @@ 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
@@ -230,34 +196,7 @@ def seed_permissions():
print(f"\n⚠️ WARNING: Tables not fully cleared! Stopping.")
return
# 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
# Step 2: Create permissions
print(f"\n📝 Creating {len(PERMISSIONS)} permissions...")
permission_map = {} # Map code to permission object
@@ -274,13 +213,13 @@ def seed_permissions():
db.commit()
print(f"✓ Created {len(PERMISSIONS)} permissions")
# Step 4: Verify roles exist
print("\n🔍 Verifying dynamic roles...")
# Step 3: Get all roles from database
print("\n🔍 Fetching 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 5: Assign permissions to roles
# Step 4: Assign permissions to roles
print("\n🔐 Assigning permissions to roles...")
from models import UserRole # Import for enum mapping
@@ -319,7 +258,7 @@ def seed_permissions():
db.commit()
print(f"{role.name}: Assigned {len(permission_codes)} permissions")
# Step 6: Summary
# Step 5: Summary
print("\n" + "=" * 80)
print("📊 SEEDING SUMMARY")
print("=" * 80)
@@ -334,8 +273,7 @@ def seed_permissions():
for module, count in sorted(modules.items()):
print(f"{module.capitalize()}: {count} permissions")
print(f"\nTotal system roles created: {len(DEFAULT_ROLES)}")
print(f"Total permissions created: {len(PERMISSIONS)}")
print(f"\nTotal permissions created: {len(PERMISSIONS)}")
print(f"Total role-permission mappings: {total_assigned}")
print("\n✅ Permission seeding completed successfully!")
print("\nNext step: Restart backend server")

399
server.py
View File

@@ -97,15 +97,6 @@ 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
# ============================================================
@@ -236,7 +227,6 @@ class UserResponse(BaseModel):
role: str
email_verified: bool
created_at: datetime
member_since: Optional[datetime] = None # Date when user became active member
# Profile
profile_photo_url: Optional[str] = None
# Subscription info (optional)
@@ -492,31 +482,6 @@ class InviteUserRequest(BaseModel):
last_name: Optional[str] = None
phone: Optional[str] = None
class AdminUpdateUserRequest(BaseModel):
"""Admin-only endpoint for updating user profile fields"""
first_name: Optional[str] = None
last_name: Optional[str] = None
phone: Optional[str] = None
address: Optional[str] = None
city: Optional[str] = None
state: Optional[str] = None
zipcode: Optional[str] = None
date_of_birth: Optional[datetime] = None
member_since: Optional[datetime] = None
# Partner information
partner_first_name: Optional[str] = None
partner_last_name: Optional[str] = None
partner_is_member: Optional[bool] = None
partner_plan_to_become_member: Optional[bool] = None
referred_by_member_name: Optional[str] = None
@validator('date_of_birth', 'member_since', pre=True)
def empty_str_to_none(cls, v):
"""Convert empty string to None for optional datetime fields"""
if v == '' or v is None:
return None
return v
class InvitationResponse(BaseModel):
id: str
email: str
@@ -1751,75 +1716,6 @@ async def get_my_event_activity(
"total_rsvps": len(rsvps)
}
# ============================================================================
# Member Transaction History Endpoint
# ============================================================================
@api_router.get("/members/transactions")
async def get_member_transactions(
current_user: User = Depends(get_active_member),
db: Session = Depends(get_db)
):
"""
Get current member's transaction history including subscriptions and donations.
Returns both types of transactions sorted by date (newest first).
"""
# Get user's subscriptions with plan details
subscriptions = db.query(Subscription).filter(
Subscription.user_id == current_user.id
).order_by(Subscription.created_at.desc()).all()
subscription_list = []
for sub in subscriptions:
plan = db.query(SubscriptionPlan).filter(SubscriptionPlan.id == sub.plan_id).first()
subscription_list.append({
"id": str(sub.id),
"type": "subscription",
"description": plan.name if plan else "Subscription",
"amount_cents": sub.amount_paid_cents or (sub.base_subscription_cents + sub.donation_cents),
"base_amount_cents": sub.base_subscription_cents,
"donation_cents": sub.donation_cents,
"status": sub.status.value if sub.status else "unknown",
"payment_method": sub.payment_method,
"card_brand": sub.card_brand,
"card_last4": sub.card_last4,
"stripe_receipt_url": sub.stripe_receipt_url,
"created_at": sub.created_at.isoformat() if sub.created_at else None,
"payment_completed_at": sub.payment_completed_at.isoformat() if sub.payment_completed_at else None,
"start_date": sub.start_date.isoformat() if sub.start_date else None,
"end_date": sub.end_date.isoformat() if sub.end_date else None,
"billing_cycle": plan.billing_cycle if plan else None,
"manual_payment": sub.manual_payment
})
# Get user's donations
donations = db.query(Donation).filter(
Donation.user_id == current_user.id
).order_by(Donation.created_at.desc()).all()
donation_list = []
for don in donations:
donation_list.append({
"id": str(don.id),
"type": "donation",
"description": "Donation",
"amount_cents": don.amount_cents,
"status": don.status.value if don.status else "unknown",
"payment_method": don.payment_method,
"card_brand": don.card_brand,
"card_last4": don.card_last4,
"stripe_receipt_url": don.stripe_receipt_url,
"created_at": don.created_at.isoformat() if don.created_at else None,
"payment_completed_at": don.payment_completed_at.isoformat() if don.payment_completed_at else None,
"notes": don.notes
})
return {
"subscriptions": subscription_list,
"donations": donation_list,
"total_subscription_amount_cents": sum(s["amount_cents"] or 0 for s in subscription_list),
"total_donation_amount_cents": sum(d["amount_cents"] or 0 for d in donation_list)
}
# ============================================================================
# Calendar Export Endpoints (Universal iCalendar .ics format)
# ============================================================================
@@ -2378,143 +2274,10 @@ async def get_user_by_id(
"email_verified": user.email_verified,
"newsletter_subscribed": user.newsletter_subscribed,
"lead_sources": user.lead_sources,
"member_since": user.member_since.isoformat() if user.member_since else None,
"created_at": user.created_at.isoformat() if user.created_at else None,
"updated_at": user.updated_at.isoformat() if user.updated_at else None
}
@api_router.get("/admin/users/{user_id}/transactions")
async def get_user_transactions(
user_id: str,
db: Session = Depends(get_db),
current_user: User = Depends(require_permission("users.view"))
):
"""
Get a specific user's transaction history (admin only).
Returns subscriptions and donations for the specified user.
"""
# Verify user exists
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise HTTPException(status_code=404, detail="User not found")
# Get user's subscriptions with plan details
subscriptions = db.query(Subscription).filter(
Subscription.user_id == user_id
).order_by(Subscription.created_at.desc()).all()
subscription_list = []
for sub in subscriptions:
plan = db.query(SubscriptionPlan).filter(SubscriptionPlan.id == sub.plan_id).first()
subscription_list.append({
"id": str(sub.id),
"type": "subscription",
"description": plan.name if plan else "Subscription",
"amount_cents": sub.amount_paid_cents or (sub.base_subscription_cents + sub.donation_cents),
"base_amount_cents": sub.base_subscription_cents,
"donation_cents": sub.donation_cents,
"status": sub.status.value if sub.status else "unknown",
"payment_method": sub.payment_method,
"card_brand": sub.card_brand,
"card_last4": sub.card_last4,
"stripe_receipt_url": sub.stripe_receipt_url,
"created_at": sub.created_at.isoformat() if sub.created_at else None,
"payment_completed_at": sub.payment_completed_at.isoformat() if sub.payment_completed_at else None,
"start_date": sub.start_date.isoformat() if sub.start_date else None,
"end_date": sub.end_date.isoformat() if sub.end_date else None,
"billing_cycle": plan.billing_cycle if plan else None,
"manual_payment": sub.manual_payment,
"manual_payment_notes": sub.manual_payment_notes
})
# Get user's donations
donations = db.query(Donation).filter(
Donation.user_id == user_id
).order_by(Donation.created_at.desc()).all()
donation_list = []
for don in donations:
donation_list.append({
"id": str(don.id),
"type": "donation",
"description": "Donation",
"amount_cents": don.amount_cents,
"status": don.status.value if don.status else "unknown",
"payment_method": don.payment_method,
"card_brand": don.card_brand,
"card_last4": don.card_last4,
"stripe_receipt_url": don.stripe_receipt_url,
"created_at": don.created_at.isoformat() if don.created_at else None,
"payment_completed_at": don.payment_completed_at.isoformat() if don.payment_completed_at else None,
"notes": don.notes
})
return {
"user_id": str(user.id),
"user_name": f"{user.first_name} {user.last_name}",
"subscriptions": subscription_list,
"donations": donation_list,
"total_subscription_amount_cents": sum(s["amount_cents"] or 0 for s in subscription_list),
"total_donation_amount_cents": sum(d["amount_cents"] or 0 for d in donation_list)
}
@api_router.put("/admin/users/{user_id}")
async def update_user_profile(
user_id: str,
request: AdminUpdateUserRequest,
db: Session = Depends(get_db),
current_user: User = Depends(require_permission("users.edit"))
):
"""Update user profile fields (admin only)"""
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise HTTPException(status_code=404, detail="User not found")
# Update basic personal information
if request.first_name is not None:
user.first_name = request.first_name
if request.last_name is not None:
user.last_name = request.last_name
if request.phone is not None:
user.phone = request.phone
if request.address is not None:
user.address = request.address
if request.city is not None:
user.city = request.city
if request.state is not None:
user.state = request.state
if request.zipcode is not None:
user.zipcode = request.zipcode
if request.date_of_birth is not None:
user.date_of_birth = request.date_of_birth
# Update member_since (admin only)
if request.member_since is not None:
user.member_since = request.member_since
# Update partner information
if request.partner_first_name is not None:
user.partner_first_name = request.partner_first_name
if request.partner_last_name is not None:
user.partner_last_name = request.partner_last_name
if request.partner_is_member is not None:
user.partner_is_member = request.partner_is_member
if request.partner_plan_to_become_member is not None:
user.partner_plan_to_become_member = request.partner_plan_to_become_member
if request.referred_by_member_name is not None:
user.referred_by_member_name = request.referred_by_member_name
user.updated_at = datetime.now(timezone.utc)
db.commit()
db.refresh(user)
logger.info(f"Admin {current_user.email} updated profile for user {user.email}")
return {
"message": "User profile updated successfully",
"user_id": str(user.id)
}
@api_router.put("/admin/users/{user_id}/validate")
async def validate_user(
user_id: str,
@@ -2713,9 +2476,6 @@ async def activate_payment_manually(
# 6. Activate user
user.status = UserStatus.active
set_user_role(user, UserRole.member, db)
# Set member_since only if not already set (first time activation)
if not user.member_since:
user.member_since = datetime.now(timezone.utc)
user.updated_at = datetime.now(timezone.utc)
# 7. Commit
@@ -4765,17 +4525,8 @@ async def get_all_subscriptions(
"donation_cents": sub.donation_cents,
"payment_method": sub.payment_method,
"stripe_subscription_id": sub.stripe_subscription_id,
"stripe_customer_id": sub.stripe_customer_id,
"created_at": sub.created_at,
"updated_at": sub.updated_at,
# Stripe transaction metadata
"stripe_payment_intent_id": sub.stripe_payment_intent_id,
"stripe_charge_id": sub.stripe_charge_id,
"stripe_invoice_id": sub.stripe_invoice_id,
"payment_completed_at": sub.payment_completed_at.isoformat() if sub.payment_completed_at else None,
"card_last4": sub.card_last4,
"card_brand": sub.card_brand,
"stripe_receipt_url": sub.stripe_receipt_url
"updated_at": sub.updated_at
} for sub in subscriptions]
@api_router.get("/admin/subscriptions/stats")
@@ -5015,15 +4766,7 @@ async def get_donations(
"donor_email": d.donor_email or (d.user.email if d.user else None),
"payment_method": d.payment_method,
"notes": d.notes,
"created_at": d.created_at.isoformat(),
# Stripe transaction metadata
"stripe_payment_intent_id": d.stripe_payment_intent_id,
"stripe_charge_id": d.stripe_charge_id,
"stripe_customer_id": d.stripe_customer_id,
"payment_completed_at": d.payment_completed_at.isoformat() if d.payment_completed_at else None,
"card_last4": d.card_last4,
"card_brand": d.card_brand,
"stripe_receipt_url": d.stripe_receipt_url
"created_at": d.created_at.isoformat()
} for d in donations]
@api_router.get("/admin/donations/stats")
@@ -6337,15 +6080,7 @@ async def create_checkout(
# Create Stripe Checkout Session
import stripe
# Try to get Stripe API key from database first, then fall back to environment
stripe_key = get_setting(db, 'stripe_secret_key', decrypt=True)
if not stripe_key:
stripe_key = os.getenv("STRIPE_SECRET_KEY")
if not stripe_key:
raise HTTPException(status_code=500, detail="Stripe API key not configured")
stripe.api_key = stripe_key
stripe.api_key = os.getenv("STRIPE_SECRET_KEY")
mode = "subscription" if stripe_interval else "payment"
@@ -6420,15 +6155,7 @@ async def create_donation_checkout(
# Create Stripe Checkout Session for one-time payment
import stripe
# Try to get Stripe API key from database first, then fall back to environment
stripe_key = get_setting(db, 'stripe_secret_key', decrypt=True)
if not stripe_key:
stripe_key = os.getenv("STRIPE_SECRET_KEY")
if not stripe_key:
raise HTTPException(status_code=500, detail="Stripe API key not configured")
stripe.api_key = stripe_key
stripe.api_key = os.getenv("STRIPE_SECRET_KEY")
checkout_session = stripe.checkout.Session.create(
payment_method_types=['card'],
@@ -6587,67 +6314,23 @@ async def stripe_webhook(request: Request, db: Session = Depends(get_db)):
donation = db.query(Donation).filter(Donation.id == donation_id).first()
if donation:
# Get Stripe API key from database
import stripe
stripe_key = get_setting(db, 'stripe_secret_key', decrypt=True)
if not stripe_key:
stripe_key = os.getenv("STRIPE_SECRET_KEY")
stripe.api_key = stripe_key
# Extract basic payment info
payment_intent_id = session.get('payment_intent')
donation.status = DonationStatus.completed
donation.stripe_payment_intent_id = payment_intent_id
donation.stripe_customer_id = session.get('customer')
donation.stripe_payment_intent_id = session.get('payment_intent')
donation.payment_method = 'card'
donation.payment_completed_at = datetime.fromtimestamp(session.get('created'), tz=timezone.utc)
# Capture donor email and name from Stripe session if not already set
if not donation.donor_email and session.get('customer_details'):
customer_details = session.get('customer_details')
donation.donor_email = customer_details.get('email')
if not donation.donor_name and customer_details.get('name'):
donation.donor_name = customer_details.get('name')
# Retrieve PaymentIntent to get charge details
try:
if payment_intent_id:
payment_intent = stripe.PaymentIntent.retrieve(payment_intent_id)
# Get charge ID from latest_charge
charge_id = payment_intent.latest_charge if hasattr(payment_intent, 'latest_charge') else None
if charge_id:
# Retrieve the charge to get full details
charge = stripe.Charge.retrieve(charge_id)
donation.stripe_charge_id = charge.id
donation.stripe_receipt_url = charge.receipt_url
# Get card details
if hasattr(charge, 'payment_method_details') and charge.payment_method_details and charge.payment_method_details.card:
card = charge.payment_method_details.card
donation.card_last4 = card.last4
donation.card_brand = card.brand.capitalize() # visa -> Visa
except Exception as e:
logger.error(f"Failed to retrieve Stripe payment details for donation: {str(e)}")
donation.updated_at = datetime.now(timezone.utc)
db.commit()
# Send thank you email only if donor_email exists
if donation.donor_email:
try:
from email_service import send_donation_thank_you_email
donor_first_name = donation.donor_name.split()[0] if donation.donor_name else "Friend"
await send_donation_thank_you_email(
donation.donor_email,
donor_first_name,
donation.amount_cents
)
except Exception as e:
logger.error(f"Failed to send donation thank you email: {str(e)}")
else:
logger.warning(f"Skipping thank you email for donation {donation.id}: no donor email")
# Send thank you email
try:
from email_service import send_donation_thank_you_email
donor_first_name = donation.donor_name.split()[0] if donation.donor_name else "Friend"
await send_donation_thank_you_email(
donation.donor_email,
donor_first_name,
donation.amount_cents
)
except Exception as e:
logger.error(f"Failed to send donation thank you email: {str(e)}")
logger.info(f"Donation completed: ${donation.amount_cents/100:.2f} (ID: {donation.id})")
else:
@@ -6677,26 +6360,15 @@ async def stripe_webhook(request: Request, db: Session = Depends(get_db)):
).first()
if not existing_subscription:
# Get Stripe API key from database
import stripe
stripe_key = get_setting(db, 'stripe_secret_key', decrypt=True)
if not stripe_key:
stripe_key = os.getenv("STRIPE_SECRET_KEY")
stripe.api_key = stripe_key
# Calculate subscription period using custom billing cycle if enabled
from payment_service import calculate_subscription_period
start_date, end_date = calculate_subscription_period(plan)
# Extract basic payment info
payment_intent_id = session.get('payment_intent')
subscription_id = session.get("subscription")
# Create subscription record with donation tracking
subscription = Subscription(
user_id=user.id,
plan_id=plan.id,
stripe_subscription_id=subscription_id,
stripe_subscription_id=session.get("subscription"),
stripe_customer_id=session.get("customer"),
status=SubscriptionStatus.active,
start_date=start_date,
@@ -6704,48 +6376,13 @@ async def stripe_webhook(request: Request, db: Session = Depends(get_db)):
amount_paid_cents=total_amount,
base_subscription_cents=base_amount or plan.minimum_price_cents,
donation_cents=donation_amount,
payment_method="stripe",
stripe_payment_intent_id=payment_intent_id,
payment_completed_at=datetime.fromtimestamp(session.get('created'), tz=timezone.utc)
payment_method="stripe"
)
# Retrieve PaymentIntent and Subscription to get detailed transaction info
try:
if payment_intent_id:
payment_intent = stripe.PaymentIntent.retrieve(payment_intent_id)
# Get charge ID from latest_charge
charge_id = payment_intent.latest_charge if hasattr(payment_intent, 'latest_charge') else None
if charge_id:
# Retrieve the charge to get full details
charge = stripe.Charge.retrieve(charge_id)
subscription.stripe_charge_id = charge.id
subscription.stripe_receipt_url = charge.receipt_url
# Get card details
if hasattr(charge, 'payment_method_details') and charge.payment_method_details and charge.payment_method_details.card:
card = charge.payment_method_details.card
subscription.card_last4 = card.last4
subscription.card_brand = card.brand.capitalize() # visa -> Visa
# Get invoice ID from subscription
if subscription_id:
stripe_subscription = stripe.Subscription.retrieve(subscription_id)
if hasattr(stripe_subscription, 'latest_invoice') and stripe_subscription.latest_invoice:
subscription.stripe_invoice_id = stripe_subscription.latest_invoice
except Exception as e:
logger.error(f"Failed to retrieve Stripe payment details for subscription: {str(e)}")
db.add(subscription)
# Update user status and role
user.status = UserStatus.active
set_user_role(user, UserRole.member, db)
# Set member_since only if not already set (first time activation)
if not user.member_since:
user.member_since = datetime.now(timezone.utc)
user.updated_at = datetime.now(timezone.utc)
db.commit()

10
start.sh Normal file
View File

@@ -0,0 +1,10 @@
#!/usr/bin/env bash
# Exit immediately if a command fails
set -e
# Activate virtual environment
source .venv/bin/activate
# Start the backend
python -m uvicorn server:app --reload --port 8000