8 Commits

Author SHA1 Message Date
8695944ef8 Merge pull request '- Add Dockerfile and .dockerignore- Fix initial DB creation- Fix seed permission' (#26) from dev into dav-prod
Reviewed-on: #26
2026-01-26 13:07:56 +00:00
Koncept Kit
ea87b3f6ee - Add Dockerfile and .dockerignore- Fix initial DB creation- Fix seed permission 2026-01-26 20:06:22 +07:00
7d61eddcef Merge pull request 'dev' (#25) from dev into loaf-prod
Reviewed-on: #25
2026-01-26 11:20:14 +00:00
Koncept Kit
b29bb641f5 Fixes 2026-01-24 23:56:21 +07:00
Koncept Kit
d322d1334f 1. Added member_since to GET Response- - Endpoint: GET /api/admin/users/{user_id}- Now includes: member_since: 2024-03-15T10:30:00Z (or null)2. Created NEW PUT Endpoint for Admin User Profile Updates- Endpoint: PUT /api/admin/users/{user_id}- Permission Required: users.edit (admins and superadmins have this) 2026-01-21 11:35:19 +07:00
Koncept Kit
ece1e62913 Was reading from .env only → NOW FIXED to read from database 2026-01-21 00:10:02 +07:00
Koncept Kit
d3a0cabede - Details Column - Expandable chevron button for each row- Expandable Transaction Details - Click chevron to show/hide details- Payment Information Section:- Stripe Transaction IDs Section- Copy to Clipboard - One-click copy for all transaction IDs- Update Stripe webhook event permission on Stripe Config page. 2026-01-20 23:51:38 +07:00
Koncept Kit
e938baa78e - Add Settings menu for Stripe configuration- In the Member Profile page, Superadmin can assign new Role to the member- Stripe Configuration is now stored with encryption in Database 2026-01-16 19:07:58 +07:00
18 changed files with 1585 additions and 122 deletions

83
.dockerignore Normal file
View File

@@ -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/

View File

@@ -6,6 +6,10 @@ JWT_SECRET=your-secret-key-change-this-in-production
JWT_ALGORITHM=HS256
ACCESS_TOKEN_EXPIRE_MINUTES=30
# Settings Encryption (for database-stored sensitive settings)
# Generate with: python -c "import secrets; print(secrets.token_urlsafe(64))"
SETTINGS_ENCRYPTION_KEY=your-encryption-key-generate-with-command-above
# SMTP Email Configuration (Port 465 - SSL/TLS)
SMTP_HOST=p.konceptkit.com
SMTP_PORT=465
@@ -28,7 +32,14 @@ SMTP_FROM_NAME=LOAF Membership
# Frontend URL
FRONTEND_URL=http://localhost:3000
# Stripe Configuration (for future payment integration)
# Backend URL (for webhook URLs and API references)
# Used to construct Stripe webhook URL shown in Admin Settings
BACKEND_URL=http://localhost:8000
# Stripe Configuration (NOW DATABASE-DRIVEN via Admin Settings page)
# Configure Stripe credentials through the Admin Settings UI (requires SETTINGS_ENCRYPTION_KEY)
# No longer requires .env variables - managed through database for dynamic updates
# Legacy .env variables below are deprecated:
# STRIPE_SECRET_KEY=sk_test_...
# STRIPE_WEBHOOK_SECRET=whsec_...

3
.gitignore vendored
View File

@@ -245,6 +245,9 @@ temp_uploads/
tmp/
temporary/
# Generated SQL files (from scripts)
create_superadmin.sql
# CSV imports
imports/*.csv
!imports/.gitkeep

40
Dockerfile Normal file
View File

@@ -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"]

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,48 @@
"""add_role_audit_fields
Revision ID: 4fa11836f7fd
Revises: 013_sync_permissions
Create Date: 2026-01-16 17:21:40.514605
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects.postgresql import UUID
# revision identifiers, used by Alembic.
revision: str = '4fa11836f7fd'
down_revision: Union[str, None] = '013_sync_permissions'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# Add role audit trail columns
op.add_column('users', sa.Column('role_changed_at', sa.DateTime(timezone=True), nullable=True))
op.add_column('users', sa.Column('role_changed_by', UUID(as_uuid=True), nullable=True))
# Create foreign key constraint to track who changed the role
op.create_foreign_key(
'fk_users_role_changed_by',
'users', 'users',
['role_changed_by'], ['id'],
ondelete='SET NULL'
)
# Create index for efficient querying by role change date
op.create_index('idx_users_role_changed_at', 'users', ['role_changed_at'])
def downgrade() -> None:
# Drop index first
op.drop_index('idx_users_role_changed_at')
# Drop foreign key constraint
op.drop_constraint('fk_users_role_changed_by', 'users', type_='foreignkey')
# Drop columns
op.drop_column('users', 'role_changed_by')
op.drop_column('users', 'role_changed_at')

View File

@@ -0,0 +1,76 @@
"""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

@@ -0,0 +1,68 @@
"""add_system_settings_table
Revision ID: ec4cb4a49cde
Revises: 4fa11836f7fd
Create Date: 2026-01-16 18:16:00.283455
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects.postgresql import UUID
# revision identifiers, used by Alembic.
revision: str = 'ec4cb4a49cde'
down_revision: Union[str, None] = '4fa11836f7fd'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# Create enum for setting types (only if not exists)
op.execute("""
DO $$ BEGIN
CREATE TYPE settingtype AS ENUM ('plaintext', 'encrypted', 'json');
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
""")
# Create system_settings table
op.execute("""
CREATE TABLE system_settings (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
setting_key VARCHAR(100) UNIQUE NOT NULL,
setting_value TEXT,
setting_type settingtype NOT NULL DEFAULT 'plaintext'::settingtype,
description TEXT,
updated_by UUID REFERENCES users(id) ON DELETE SET NULL,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
is_sensitive BOOLEAN NOT NULL DEFAULT FALSE
);
COMMENT ON COLUMN system_settings.setting_key IS 'Unique setting identifier (e.g., stripe_secret_key)';
COMMENT ON COLUMN system_settings.setting_value IS 'Setting value (encrypted if setting_type is encrypted)';
COMMENT ON COLUMN system_settings.setting_type IS 'Type of setting: plaintext, encrypted, or json';
COMMENT ON COLUMN system_settings.description IS 'Human-readable description of the setting';
COMMENT ON COLUMN system_settings.updated_by IS 'User who last updated this setting';
COMMENT ON COLUMN system_settings.is_sensitive IS 'Whether this setting contains sensitive data';
""")
# Create indexes
op.create_index('idx_system_settings_key', 'system_settings', ['setting_key'])
op.create_index('idx_system_settings_updated_at', 'system_settings', ['updated_at'])
def downgrade() -> None:
# Drop indexes
op.drop_index('idx_system_settings_updated_at')
op.drop_index('idx_system_settings_key')
# Drop table
op.drop_table('system_settings')
# Drop enum
op.execute('DROP TYPE IF EXISTS settingtype')

View File

@@ -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,38 +54,93 @@ def main():
sys.exit(1)
print()
print("Generating password hash...")
password_hash = generate_password_hash(password)
print("Creating superadmin user...")
try:
# Import database dependencies
from sqlalchemy import create_engine, text
from passlib.context import CryptContext
# Create password hash
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
password_hash = pwd_context.hash(password)
# Connect to database
engine = create_engine(database_url)
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("✅ Password hash generated")
print()
print("=" * 70)
print("SQL STATEMENT")
print("✅ Superadmin user created successfully!")
print("=" * 70)
sql = generate_sql(email, password_hash, first_name, last_name)
print(sql)
# Save to file
output_file = "create_superadmin.sql"
with open(output_file, 'w') as f:
f.write(sql)
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(f" Email: {email}")
print(f" Name: {first_name} {last_name}")
print(f" Role: superadmin")
print(f" Status: active")
print()
print("Or copy the SQL above and run it directly in psql")
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:
main()
except KeyboardInterrupt:
print("\n\n❌ Cancelled by user")
sys.exit(1)
except Exception as e:
print(f"\n❌ Error: {e}")
sys.exit(1)

122
encryption_service.py Normal file
View File

@@ -0,0 +1,122 @@
"""
Encryption service for sensitive settings stored in database.
Uses Fernet symmetric encryption (AES-128 in CBC mode with HMAC authentication).
The encryption key is derived from a master secret stored in .env.
"""
import os
import base64
from cryptography.fernet import Fernet
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
from cryptography.hazmat.backends import default_backend
class EncryptionService:
"""Service for encrypting and decrypting sensitive configuration values"""
def __init__(self):
# Get master encryption key from environment
# This should be a long, random string (e.g., 64 characters)
# Generate one with: python -c "import secrets; print(secrets.token_urlsafe(64))"
self.master_secret = os.environ.get('SETTINGS_ENCRYPTION_KEY')
if not self.master_secret:
raise ValueError(
"SETTINGS_ENCRYPTION_KEY environment variable not set. "
"Generate one with: python -c \"import secrets; print(secrets.token_urlsafe(64))\""
)
# Derive encryption key from master secret using PBKDF2HMAC
# This adds an extra layer of security
kdf = PBKDF2HMAC(
algorithm=hashes.SHA256(),
length=32,
salt=b'systemsettings', # Fixed salt (OK for key derivation from strong secret)
iterations=100000,
backend=default_backend()
)
key = base64.urlsafe_b64encode(kdf.derive(self.master_secret.encode()))
self.cipher = Fernet(key)
def encrypt(self, plaintext: str) -> str:
"""
Encrypt a plaintext string.
Args:
plaintext: The string to encrypt
Returns:
Base64-encoded encrypted string
"""
if not plaintext:
return ""
encrypted_bytes = self.cipher.encrypt(plaintext.encode())
return encrypted_bytes.decode('utf-8')
def decrypt(self, encrypted: str) -> str:
"""
Decrypt an encrypted string.
Args:
encrypted: The base64-encoded encrypted string
Returns:
Decrypted plaintext string
Raises:
cryptography.fernet.InvalidToken: If decryption fails (wrong key or corrupted data)
"""
if not encrypted:
return ""
decrypted_bytes = self.cipher.decrypt(encrypted.encode())
return decrypted_bytes.decode('utf-8')
def is_encrypted(self, value: str) -> bool:
"""
Check if a value appears to be encrypted (starts with Fernet token format).
This is a heuristic check - not 100% reliable but useful for validation.
Args:
value: String to check
Returns:
True if value looks like a Fernet token
"""
if not value:
return False
# Fernet tokens are base64-encoded and start with version byte (gAAAAA...)
# They're always > 60 characters
try:
return len(value) > 60 and value.startswith('gAAAAA')
except:
return False
# Global encryption service instance
# Initialize on module import so it fails fast if encryption key is missing
try:
encryption_service = EncryptionService()
except ValueError as e:
print(f"WARNING: {e}")
print("Encryption service will not be available.")
encryption_service = None
def get_encryption_service() -> EncryptionService:
"""
Get the global encryption service instance.
Raises:
ValueError: If encryption service is not initialized (missing SETTINGS_ENCRYPTION_KEY)
"""
if encryption_service is None:
raise ValueError(
"Encryption service not initialized. Set SETTINGS_ENCRYPTION_KEY environment variable."
)
return encryption_service

View File

@@ -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);

View File

@@ -137,6 +137,10 @@ class User(Base):
wordpress_user_id = Column(BigInteger, nullable=True, comment="Original WordPress user ID")
wordpress_registered_date = Column(DateTime(timezone=True), nullable=True, comment="Original WordPress registration date")
# Role Change Audit Trail
role_changed_at = Column(DateTime(timezone=True), nullable=True, comment="Timestamp when role was last changed")
role_changed_by = Column(UUID(as_uuid=True), ForeignKey('users.id', ondelete='SET NULL'), nullable=True, comment="Admin who changed the role")
created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc))
updated_at = Column(DateTime, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc))
@@ -145,6 +149,7 @@ class User(Base):
events_created = relationship("Event", back_populates="creator")
rsvps = relationship("EventRSVP", back_populates="user")
subscriptions = relationship("Subscription", back_populates="user", foreign_keys="Subscription.user_id")
role_changer = relationship("User", foreign_keys=[role_changed_by], remote_side="User.id", post_update=True)
class Event(Base):
__tablename__ = "events"
@@ -233,6 +238,15 @@ 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
@@ -264,9 +278,17 @@ class Donation(Base):
# Payment details
stripe_checkout_session_id = Column(String, nullable=True)
stripe_payment_intent_id = Column(String, nullable=True)
stripe_payment_intent_id = Column(String, nullable=True, index=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))
@@ -509,3 +531,36 @@ class ImportRollbackAudit(Base):
# Relationships
import_job = relationship("ImportJob")
admin_user = relationship("User", foreign_keys=[rolled_back_by])
# ============================================================
# System Settings Models
# ============================================================
class SettingType(enum.Enum):
plaintext = "plaintext"
encrypted = "encrypted"
json = "json"
class SystemSettings(Base):
"""System-wide configuration settings stored in database"""
__tablename__ = "system_settings"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
setting_key = Column(String(100), unique=True, nullable=False, index=True)
setting_value = Column(Text, nullable=True)
setting_type = Column(SQLEnum(SettingType), default=SettingType.plaintext, nullable=False)
description = Column(Text, nullable=True)
updated_by = Column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc), nullable=False)
updated_at = Column(DateTime, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc), nullable=False)
is_sensitive = Column(Boolean, default=False, nullable=False)
# Relationships
updater = relationship("User", foreign_keys=[updated_by])
# Index on updated_at for audit queries
__table_args__ = (
Index('idx_system_settings_updated_at', 'updated_at'),
)

View File

@@ -11,11 +11,9 @@ from datetime import datetime, timezone, timedelta
# Load environment variables
load_dotenv()
# Initialize Stripe with secret key
stripe.api_key = os.getenv("STRIPE_SECRET_KEY")
# Stripe webhook secret for signature verification
STRIPE_WEBHOOK_SECRET = os.getenv("STRIPE_WEBHOOK_SECRET")
# NOTE: Stripe credentials are now database-driven
# These .env fallbacks are kept for backward compatibility only
# The actual credentials are loaded dynamically from system_settings table
def create_checkout_session(
user_id: str,
@@ -23,11 +21,15 @@ def create_checkout_session(
plan_id: str,
stripe_price_id: str,
success_url: str,
cancel_url: str
cancel_url: str,
db = None
):
"""
Create a Stripe Checkout session for subscription payment.
Args:
db: Database session (optional, for reading Stripe credentials from database)
Args:
user_id: User's UUID
user_email: User's email address
@@ -39,6 +41,28 @@ def create_checkout_session(
Returns:
dict: Checkout session object with session ID and URL
"""
# Load Stripe API key from database if available
if db:
try:
# Import here to avoid circular dependency
from models import SystemSettings, SettingType
from encryption_service import get_encryption_service
setting = db.query(SystemSettings).filter(
SystemSettings.setting_key == 'stripe_secret_key'
).first()
if setting and setting.setting_value:
encryption_service = get_encryption_service()
stripe.api_key = encryption_service.decrypt(setting.setting_value)
except Exception as e:
# Fallback to .env if database read fails
print(f"Failed to read Stripe key from database: {e}")
stripe.api_key = os.getenv("STRIPE_SECRET_KEY")
else:
# Fallback to .env if no db session
stripe.api_key = os.getenv("STRIPE_SECRET_KEY")
try:
# Create Checkout Session
checkout_session = stripe.checkout.Session.create(
@@ -74,13 +98,14 @@ def create_checkout_session(
raise Exception(f"Stripe error: {str(e)}")
def verify_webhook_signature(payload: bytes, sig_header: str) -> dict:
def verify_webhook_signature(payload: bytes, sig_header: str, db=None) -> dict:
"""
Verify Stripe webhook signature and construct event.
Args:
payload: Raw webhook payload bytes
sig_header: Stripe signature header
db: Database session (optional, for reading webhook secret from database)
Returns:
dict: Verified webhook event
@@ -88,9 +113,32 @@ def verify_webhook_signature(payload: bytes, sig_header: str) -> dict:
Raises:
ValueError: If signature verification fails
"""
# Load webhook secret from database if available
webhook_secret = None
if db:
try:
from models import SystemSettings
from encryption_service import get_encryption_service
setting = db.query(SystemSettings).filter(
SystemSettings.setting_key == 'stripe_webhook_secret'
).first()
if setting and setting.setting_value:
encryption_service = get_encryption_service()
webhook_secret = encryption_service.decrypt(setting.setting_value)
except Exception as e:
print(f"Failed to read webhook secret from database: {e}")
webhook_secret = os.getenv("STRIPE_WEBHOOK_SECRET")
else:
webhook_secret = os.getenv("STRIPE_WEBHOOK_SECRET")
if not webhook_secret:
raise ValueError("STRIPE_WEBHOOK_SECRET not configured")
try:
event = stripe.Webhook.construct_event(
payload, sig_header, STRIPE_WEBHOOK_SECRET
payload, sig_header, webhook_secret
)
return event
except ValueError as e:

View File

@@ -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")

754
server.py
View File

@@ -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
# ============================================================
@@ -227,6 +236,7 @@ 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)
@@ -482,6 +492,31 @@ 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
@@ -514,6 +549,10 @@ class AcceptInvitationRequest(BaseModel):
zipcode: Optional[str] = None
date_of_birth: Optional[datetime] = None
class ChangeRoleRequest(BaseModel):
role: str
role_id: Optional[str] = None # For custom roles
# Auth Routes
@api_router.post("/auth/register")
async def register(request: RegisterRequest, db: Session = Depends(get_db)):
@@ -1712,6 +1751,75 @@ 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)
# ============================================================================
@@ -2270,10 +2378,143 @@ 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,
@@ -2472,6 +2713,9 @@ 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
@@ -2527,6 +2771,102 @@ async def admin_reset_user_password(
return {"message": f"Password reset for {user.email}. Temporary password emailed."}
@api_router.put("/admin/users/{user_id}/role")
async def change_user_role(
user_id: str,
request: ChangeRoleRequest,
current_user: User = Depends(require_permission("users.edit")),
db: Session = Depends(get_db)
):
"""
Change an existing user's role with privilege escalation prevention.
Requires: users.edit permission
Rules:
- Superadmin: Can assign any role (including superadmin)
- Admin: Can assign admin, finance, member, guest, and non-elevated custom roles
- Admin CANNOT assign: superadmin or custom roles with elevated permissions
- Users CANNOT change their own role
"""
# 1. Fetch target user
target_user = db.query(User).filter(User.id == user_id).first()
if not target_user:
raise HTTPException(status_code=404, detail="User not found")
# 2. Prevent self-role-change
if str(target_user.id) == str(current_user.id):
raise HTTPException(
status_code=403,
detail="You cannot change your own role"
)
# 3. Validate new role
if request.role not in ['guest', 'member', 'admin', 'finance', 'superadmin']:
raise HTTPException(status_code=400, detail="Invalid role")
# 4. Privilege escalation check
if current_user.role != 'superadmin':
# Non-superadmin cannot assign superadmin role
if request.role == 'superadmin':
raise HTTPException(
status_code=403,
detail="Only superadmin can assign superadmin role"
)
# Check custom role elevation
if request.role_id:
custom_role = db.query(Role).filter(Role.id == request.role_id).first()
if not custom_role:
raise HTTPException(status_code=404, detail="Custom role not found")
# Check if custom role has elevated permissions
elevated_permissions = ['users.delete', 'roles.create', 'roles.edit',
'roles.delete', 'permissions.edit']
role_perms = db.query(Permission.name).join(RolePermission).filter(
RolePermission.role_id == custom_role.id,
Permission.name.in_(elevated_permissions)
).all()
if role_perms:
raise HTTPException(
status_code=403,
detail=f"Cannot assign role with elevated permissions: {custom_role.name}"
)
# 5. Update role with audit trail
old_role = target_user.role
old_role_id = target_user.role_id
target_user.role = request.role
target_user.role_id = request.role_id if request.role_id else None
target_user.role_changed_at = datetime.now(timezone.utc)
target_user.role_changed_by = current_user.id
target_user.updated_at = datetime.now(timezone.utc)
db.commit()
db.refresh(target_user)
# Log admin action
logger.info(
f"Admin {current_user.email} changed role for user {target_user.email} "
f"from {old_role} to {request.role}"
)
return {
"message": f"Role changed from {old_role} to {request.role}",
"user": {
"id": str(target_user.id),
"email": target_user.email,
"name": f"{target_user.first_name} {target_user.last_name}",
"old_role": old_role,
"new_role": target_user.role,
"changed_by": f"{current_user.first_name} {current_user.last_name}",
"changed_at": target_user.role_changed_at.isoformat()
}
}
@api_router.post("/admin/users/{user_id}/resend-verification")
async def admin_resend_verification(
user_id: str,
@@ -4425,8 +4765,17 @@ 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
"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
} for sub in subscriptions]
@api_router.get("/admin/subscriptions/stats")
@@ -4666,7 +5015,15 @@ 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()
"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
} for d in donations]
@api_router.get("/admin/donations/stats")
@@ -5980,7 +6337,15 @@ async def create_checkout(
# Create Stripe Checkout Session
import stripe
stripe.api_key = os.getenv("STRIPE_SECRET_KEY")
# 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
mode = "subscription" if stripe_interval else "payment"
@@ -6055,7 +6420,15 @@ async def create_donation_checkout(
# Create Stripe Checkout Session for one-time payment
import stripe
stripe.api_key = os.getenv("STRIPE_SECRET_KEY")
# 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
checkout_session = stripe.checkout.Session.create(
payment_method_types=['card'],
@@ -6197,8 +6570,8 @@ async def stripe_webhook(request: Request, db: Session = Depends(get_db)):
raise HTTPException(status_code=400, detail="Missing stripe-signature header")
try:
# Verify webhook signature
event = verify_webhook_signature(payload, sig_header)
# Verify webhook signature (pass db for reading webhook secret from database)
event = verify_webhook_signature(payload, sig_header, db)
except ValueError as e:
logger.error(f"Webhook signature verification failed: {str(e)}")
raise HTTPException(status_code=400, detail=str(e))
@@ -6214,13 +6587,55 @@ 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 = session.get('payment_intent')
donation.stripe_payment_intent_id = payment_intent_id
donation.stripe_customer_id = session.get('customer')
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
# 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"
@@ -6231,6 +6646,8 @@ async def stripe_webhook(request: Request, db: Session = Depends(get_db)):
)
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")
logger.info(f"Donation completed: ${donation.amount_cents/100:.2f} (ID: {donation.id})")
else:
@@ -6260,15 +6677,26 @@ 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=session.get("subscription"),
stripe_subscription_id=subscription_id,
stripe_customer_id=session.get("customer"),
status=SubscriptionStatus.active,
start_date=start_date,
@@ -6276,13 +6704,48 @@ 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"
payment_method="stripe",
stripe_payment_intent_id=payment_intent_id,
payment_completed_at=datetime.fromtimestamp(session.get('created'), tz=timezone.utc)
)
# 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()
@@ -6298,6 +6761,277 @@ async def stripe_webhook(request: Request, db: Session = Depends(get_db)):
return {"status": "success"}
# ============================================================================
# ADMIN SETTINGS ENDPOINTS
# ============================================================================
# Helper functions for system settings
def get_setting(db: Session, key: str, decrypt: bool = False) -> str | None:
"""
Get a system setting value from database.
Args:
db: Database session
key: Setting key to retrieve
decrypt: If True and setting_type is 'encrypted', decrypt the value
Returns:
Setting value or None if not found
"""
from models import SystemSettings, SettingType
from encryption_service import get_encryption_service
setting = db.query(SystemSettings).filter(SystemSettings.setting_key == key).first()
if not setting:
return None
value = setting.setting_value
if decrypt and setting.setting_type == SettingType.encrypted and value:
try:
encryption_service = get_encryption_service()
value = encryption_service.decrypt(value)
except Exception as e:
print(f"Failed to decrypt setting {key}: {e}")
return None
return value
def set_setting(
db: Session,
key: str,
value: str,
user_id: str,
setting_type: str = "plaintext",
description: str = None,
is_sensitive: bool = False,
encrypt: bool = False
) -> None:
"""
Set a system setting value in database.
Args:
db: Database session
key: Setting key
value: Setting value
user_id: ID of user making the change
setting_type: Type of setting (plaintext, encrypted, json)
description: Human-readable description
is_sensitive: Whether this is sensitive data
encrypt: If True, encrypt the value before storing
"""
from models import SystemSettings, SettingType
from encryption_service import get_encryption_service
# Encrypt value if requested
if encrypt and value:
encryption_service = get_encryption_service()
value = encryption_service.encrypt(value)
setting_type = "encrypted"
# Find or create setting
setting = db.query(SystemSettings).filter(SystemSettings.setting_key == key).first()
if setting:
# Update existing
setting.setting_value = value
setting.setting_type = SettingType[setting_type]
setting.updated_by = user_id
setting.updated_at = datetime.now(timezone.utc)
if description:
setting.description = description
setting.is_sensitive = is_sensitive
else:
# Create new
setting = SystemSettings(
setting_key=key,
setting_value=value,
setting_type=SettingType[setting_type],
description=description,
updated_by=user_id,
is_sensitive=is_sensitive
)
db.add(setting)
db.commit()
@api_router.get("/admin/settings/stripe/status")
async def get_stripe_status(
current_user: User = Depends(get_current_superadmin),
db: Session = Depends(get_db)
):
"""
Get Stripe integration status (superadmin only).
Returns:
- configured: Whether credentials exist in database
- secret_key_prefix: First 10 chars of secret key (for verification)
- webhook_configured: Whether webhook secret exists
- environment: test or live (based on key prefix)
- webhook_url: Full webhook URL for Stripe configuration
"""
import os
# Read from database
secret_key = get_setting(db, 'stripe_secret_key', decrypt=True)
webhook_secret = get_setting(db, 'stripe_webhook_secret', decrypt=True)
configured = bool(secret_key)
environment = 'unknown'
if secret_key:
if secret_key.startswith('sk_test_'):
environment = 'test'
elif secret_key.startswith('sk_live_'):
environment = 'live'
# Get backend URL from environment for webhook URL
# Try multiple environment variable patterns for flexibility
backend_url = (
os.environ.get('BACKEND_URL') or
os.environ.get('API_URL') or
f"http://{os.environ.get('HOST', 'localhost')}:{os.environ.get('PORT', '8000')}"
)
webhook_url = f"{backend_url}/api/webhooks/stripe"
return {
"configured": configured,
"secret_key_prefix": secret_key[:10] if secret_key else None,
"secret_key_set": bool(secret_key),
"webhook_secret_set": bool(webhook_secret),
"environment": environment,
"webhook_url": webhook_url,
"instructions": {
"location": "Database (system_settings table)",
"required_settings": [
"stripe_secret_key (sk_test_... or sk_live_...)",
"stripe_webhook_secret (whsec_...)"
],
"restart_required": "No - changes take effect immediately"
}
}
@api_router.post("/admin/settings/stripe/test-connection")
async def test_stripe_connection(
current_user: User = Depends(get_current_superadmin),
db: Session = Depends(get_db)
):
"""
Test Stripe API connection (superadmin only).
Performs a simple API call to verify credentials work.
"""
import stripe
# Read from database
secret_key = get_setting(db, 'stripe_secret_key', decrypt=True)
if not secret_key:
raise HTTPException(
status_code=400,
detail="STRIPE_SECRET_KEY not configured in database. Please configure Stripe settings first."
)
try:
stripe.api_key = secret_key
# Make a simple API call to test connection
balance = stripe.Balance.retrieve()
return {
"success": True,
"message": "Stripe connection successful",
"environment": "test" if secret_key.startswith('sk_test_') else "live",
"balance": {
"available": balance.available,
"pending": balance.pending
}
}
except stripe.error.AuthenticationError as e:
raise HTTPException(
status_code=401,
detail=f"Stripe authentication failed: {str(e)}"
)
except Exception as e:
raise HTTPException(
status_code=500,
detail=f"Stripe connection test failed: {str(e)}"
)
class UpdateStripeSettingsRequest(BaseModel):
"""Request model for updating Stripe settings"""
secret_key: str = Field(..., min_length=1, description="Stripe secret key (sk_test_... or sk_live_...)")
webhook_secret: str = Field(..., min_length=1, description="Stripe webhook secret (whsec_...)")
@api_router.put("/admin/settings/stripe")
async def update_stripe_settings(
request: UpdateStripeSettingsRequest,
current_user: User = Depends(get_current_superadmin),
db: Session = Depends(get_db)
):
"""
Update Stripe integration settings (superadmin only).
Stores Stripe credentials encrypted in the database.
Changes take effect immediately without server restart.
"""
# Validate secret key format
if not (request.secret_key.startswith('sk_test_') or request.secret_key.startswith('sk_live_')):
raise HTTPException(
status_code=400,
detail="Invalid Stripe secret key format. Must start with 'sk_test_' or 'sk_live_'"
)
# Validate webhook secret format
if not request.webhook_secret.startswith('whsec_'):
raise HTTPException(
status_code=400,
detail="Invalid Stripe webhook secret format. Must start with 'whsec_'"
)
try:
# Store secret key (encrypted)
set_setting(
db=db,
key='stripe_secret_key',
value=request.secret_key,
user_id=str(current_user.id),
description='Stripe API secret key for payment processing',
is_sensitive=True,
encrypt=True
)
# Store webhook secret (encrypted)
set_setting(
db=db,
key='stripe_webhook_secret',
value=request.webhook_secret,
user_id=str(current_user.id),
description='Stripe webhook secret for verifying webhook signatures',
is_sensitive=True,
encrypt=True
)
# Determine environment
environment = 'test' if request.secret_key.startswith('sk_test_') else 'live'
return {
"success": True,
"message": "Stripe settings updated successfully",
"environment": environment,
"updated_at": datetime.now(timezone.utc).isoformat(),
"updated_by": f"{current_user.first_name} {current_user.last_name}"
}
except Exception as e:
raise HTTPException(
status_code=500,
detail=f"Failed to update Stripe settings: {str(e)}"
)
# Include the router in the main app
app.include_router(api_router)