Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5ab0038c0a | ||
|
|
38e5f5377a | ||
|
|
e06f18ce17 | ||
|
|
84285861cc | ||
|
|
56d1b97261 | ||
|
|
6b6173bd5b | ||
|
|
cf8d38a4a4 | ||
|
|
09712e52bb | ||
|
|
366245acc7 | ||
|
|
a75bf743f4 | ||
|
|
fb369977d0 | ||
|
|
1ed9aa0994 | ||
|
|
04783f66f1 |
@@ -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
6
.gitignore
vendored
@@ -1,3 +1,5 @@
|
|||||||
|
.env
|
||||||
|
.venv
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# Python Backend .gitignore
|
# Python Backend .gitignore
|
||||||
# For FastAPI + PostgreSQL + Cloudflare R2 + Stripe
|
# For FastAPI + PostgreSQL + Cloudflare R2 + Stripe
|
||||||
@@ -8,6 +10,7 @@
|
|||||||
.env.*
|
.env.*
|
||||||
!.env.example
|
!.env.example
|
||||||
.envrc
|
.envrc
|
||||||
|
.sh
|
||||||
|
|
||||||
# ===== Python =====
|
# ===== Python =====
|
||||||
# Byte-compiled / optimized / DLL files
|
# Byte-compiled / optimized / DLL files
|
||||||
@@ -245,9 +248,6 @@ temp_uploads/
|
|||||||
tmp/
|
tmp/
|
||||||
temporary/
|
temporary/
|
||||||
|
|
||||||
# Generated SQL files (from scripts)
|
|
||||||
create_superadmin.sql
|
|
||||||
|
|
||||||
# CSV imports
|
# CSV imports
|
||||||
imports/*.csv
|
imports/*.csv
|
||||||
!imports/.gitkeep
|
!imports/.gitkeep
|
||||||
|
|||||||
42
Dockerfile
42
Dockerfile
@@ -1,40 +1,20 @@
|
|||||||
# Backend Dockerfile - FastAPI with Python
|
# Use an official Python image (Linux)
|
||||||
FROM python:3.11-slim
|
FROM python:3.12-slim
|
||||||
|
|
||||||
# Set environment variables
|
# Set a working directory
|
||||||
ENV PYTHONDONTWRITEBYTECODE=1
|
|
||||||
ENV PYTHONUNBUFFERED=1
|
|
||||||
ENV PYTHONPATH=/app
|
|
||||||
|
|
||||||
# Set work directory
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Install system dependencies
|
# Copy dependency list
|
||||||
RUN apt-get update && apt-get install -y \
|
|
||||||
gcc \
|
|
||||||
libpq-dev \
|
|
||||||
curl \
|
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
|
||||||
|
|
||||||
# Install Python dependencies
|
|
||||||
COPY requirements.txt .
|
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 . .
|
COPY . .
|
||||||
|
|
||||||
# Create non-root user for security
|
# Expose port (whatever your backend runs on)
|
||||||
RUN adduser --disabled-password --gecos '' appuser && \
|
|
||||||
chown -R appuser:appuser /app
|
|
||||||
USER appuser
|
|
||||||
|
|
||||||
# Expose port
|
|
||||||
EXPOSE 8000
|
EXPOSE 8000
|
||||||
|
|
||||||
# Health check
|
# Run exactly your command
|
||||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
CMD ["python", "-m", "uvicorn", "server:app", "--host", "0.0.0.0", "--port", "8000", "--reload"]
|
||||||
CMD curl -f http://localhost:8000/health || exit 1
|
|
||||||
|
|
||||||
# Run the application
|
|
||||||
CMD ["uvicorn", "server:app", "--host", "0.0.0.0", "--port", "8000"]
|
|
||||||
|
|||||||
BIN
__pycache__/auth.cpython-310.pyc
Normal file
BIN
__pycache__/auth.cpython-310.pyc
Normal file
Binary file not shown.
BIN
__pycache__/calendar_service.cpython-310.pyc
Normal file
BIN
__pycache__/calendar_service.cpython-310.pyc
Normal file
Binary file not shown.
BIN
__pycache__/database.cpython-310.pyc
Normal file
BIN
__pycache__/database.cpython-310.pyc
Normal file
Binary file not shown.
Binary file not shown.
BIN
__pycache__/email_service.cpython-310.pyc
Normal file
BIN
__pycache__/email_service.cpython-310.pyc
Normal file
Binary file not shown.
BIN
__pycache__/models.cpython-310.pyc
Normal file
BIN
__pycache__/models.cpython-310.pyc
Normal file
Binary file not shown.
Binary file not shown.
BIN
__pycache__/payment_service.cpython-310.pyc
Normal file
BIN
__pycache__/payment_service.cpython-310.pyc
Normal file
Binary file not shown.
Binary file not shown.
BIN
__pycache__/r2_storage.cpython-310.pyc
Normal file
BIN
__pycache__/r2_storage.cpython-310.pyc
Normal file
Binary file not shown.
BIN
__pycache__/server.cpython-310.pyc
Normal file
BIN
__pycache__/server.cpython-310.pyc
Normal file
Binary file not shown.
Binary file not shown.
BIN
__pycache__/wordpress_parser.cpython-310.pyc
Normal file
BIN
__pycache__/wordpress_parser.cpython-310.pyc
Normal file
Binary file not shown.
@@ -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')
|
|
||||||
@@ -1,15 +1,38 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""
|
"""
|
||||||
Create Superadmin User Script
|
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 sys
|
||||||
import os
|
import os
|
||||||
from getpass import getpass
|
from getpass import getpass
|
||||||
|
|
||||||
# Add the backend directory to path for imports
|
def generate_password_hash(password: str) -> str:
|
||||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
"""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():
|
def main():
|
||||||
print("=" * 70)
|
print("=" * 70)
|
||||||
@@ -17,15 +40,6 @@ def main():
|
|||||||
print("=" * 70)
|
print("=" * 70)
|
||||||
print()
|
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
|
# Get user input
|
||||||
email = input("Email address: ").strip()
|
email = input("Email address: ").strip()
|
||||||
if not email or '@' not in email:
|
if not email or '@' not in email:
|
||||||
@@ -54,89 +68,31 @@ def main():
|
|||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
print()
|
print()
|
||||||
print("Creating superadmin user...")
|
print("Generating password hash...")
|
||||||
|
password_hash = generate_password_hash(password)
|
||||||
|
|
||||||
try:
|
print("✅ Password hash generated")
|
||||||
# Import database dependencies
|
print()
|
||||||
from sqlalchemy import create_engine, text
|
print("=" * 70)
|
||||||
from passlib.context import CryptContext
|
print("SQL STATEMENT")
|
||||||
|
print("=" * 70)
|
||||||
|
|
||||||
# Create password hash
|
sql = generate_sql(email, password_hash, first_name, last_name)
|
||||||
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
print(sql)
|
||||||
password_hash = pwd_context.hash(password)
|
|
||||||
|
|
||||||
# Connect to database
|
# Save to file
|
||||||
engine = create_engine(database_url)
|
output_file = "create_superadmin.sql"
|
||||||
|
with open(output_file, 'w') as f:
|
||||||
|
f.write(sql)
|
||||||
|
|
||||||
with engine.connect() as conn:
|
print("=" * 70)
|
||||||
# Check if user already exists
|
print(f"✅ SQL saved to: {output_file}")
|
||||||
result = conn.execute(
|
print()
|
||||||
text("SELECT id FROM users WHERE email = :email"),
|
print("Run this command to create the user:")
|
||||||
{"email": email}
|
print(f" psql -U postgres -d loaf_new -f {output_file}")
|
||||||
)
|
print()
|
||||||
if result.fetchone():
|
print("Or copy the SQL above and run it directly in psql")
|
||||||
print(f"❌ User with email '{email}' already exists")
|
print("=" * 70)
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
# Insert superadmin user
|
|
||||||
conn.execute(
|
|
||||||
text("""
|
|
||||||
INSERT INTO users (
|
|
||||||
id, email, password_hash, first_name, last_name,
|
|
||||||
phone, address, city, state, zipcode, date_of_birth,
|
|
||||||
status, role, email_verified,
|
|
||||||
newsletter_subscribed, accepts_tos,
|
|
||||||
created_at, updated_at
|
|
||||||
) VALUES (
|
|
||||||
gen_random_uuid(),
|
|
||||||
:email,
|
|
||||||
:password_hash,
|
|
||||||
:first_name,
|
|
||||||
:last_name,
|
|
||||||
'',
|
|
||||||
'',
|
|
||||||
'',
|
|
||||||
'',
|
|
||||||
'',
|
|
||||||
'1990-01-01',
|
|
||||||
'active',
|
|
||||||
'superadmin',
|
|
||||||
true,
|
|
||||||
false,
|
|
||||||
true,
|
|
||||||
NOW(),
|
|
||||||
NOW()
|
|
||||||
)
|
|
||||||
"""),
|
|
||||||
{
|
|
||||||
"email": email,
|
|
||||||
"password_hash": password_hash,
|
|
||||||
"first_name": first_name,
|
|
||||||
"last_name": last_name
|
|
||||||
}
|
|
||||||
)
|
|
||||||
conn.commit()
|
|
||||||
|
|
||||||
print()
|
|
||||||
print("=" * 70)
|
|
||||||
print("✅ Superadmin user created successfully!")
|
|
||||||
print("=" * 70)
|
|
||||||
print()
|
|
||||||
print(f" Email: {email}")
|
|
||||||
print(f" Name: {first_name} {last_name}")
|
|
||||||
print(f" Role: superadmin")
|
|
||||||
print(f" Status: active")
|
|
||||||
print()
|
|
||||||
print("You can now log in with these credentials.")
|
|
||||||
print("=" * 70)
|
|
||||||
|
|
||||||
except ImportError as e:
|
|
||||||
print(f"❌ Missing dependency: {e}")
|
|
||||||
print(" Run: pip install sqlalchemy psycopg2-binary passlib python-dotenv")
|
|
||||||
sys.exit(1)
|
|
||||||
except Exception as e:
|
|
||||||
print(f"❌ Database error: {e}")
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
try:
|
try:
|
||||||
@@ -144,3 +100,6 @@ if __name__ == "__main__":
|
|||||||
except KeyboardInterrupt:
|
except KeyboardInterrupt:
|
||||||
print("\n\n❌ Cancelled by user")
|
print("\n\n❌ Cancelled by user")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"\n❌ Error: {e}")
|
||||||
|
sys.exit(1)
|
||||||
|
|||||||
14
docker-compose.yml
Normal file
14
docker-compose.yml
Normal 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
|
||||||
|
|
||||||
@@ -94,30 +94,6 @@ BEGIN;
|
|||||||
-- SECTION 2: Create Core Tables
|
-- 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
|
-- Users table
|
||||||
CREATE TABLE IF NOT EXISTS users (
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
@@ -127,7 +103,6 @@ CREATE TABLE IF NOT EXISTS users (
|
|||||||
password_hash VARCHAR NOT NULL,
|
password_hash VARCHAR NOT NULL,
|
||||||
email_verified BOOLEAN NOT NULL DEFAULT FALSE,
|
email_verified BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
email_verification_token VARCHAR UNIQUE,
|
email_verification_token VARCHAR UNIQUE,
|
||||||
email_verification_expires TIMESTAMP WITH TIME ZONE,
|
|
||||||
|
|
||||||
-- Personal Information
|
-- Personal Information
|
||||||
first_name VARCHAR NOT NULL,
|
first_name VARCHAR NOT NULL,
|
||||||
@@ -138,6 +113,7 @@ CREATE TABLE IF NOT EXISTS users (
|
|||||||
state VARCHAR(2),
|
state VARCHAR(2),
|
||||||
zipcode VARCHAR(10),
|
zipcode VARCHAR(10),
|
||||||
date_of_birth DATE,
|
date_of_birth DATE,
|
||||||
|
bio TEXT,
|
||||||
|
|
||||||
-- Profile
|
-- Profile
|
||||||
profile_photo_url VARCHAR,
|
profile_photo_url VARCHAR,
|
||||||
@@ -161,44 +137,20 @@ CREATE TABLE IF NOT EXISTS users (
|
|||||||
-- Status & Role
|
-- Status & Role
|
||||||
status userstatus NOT NULL DEFAULT 'pending_email',
|
status userstatus NOT NULL DEFAULT 'pending_email',
|
||||||
role userrole NOT NULL DEFAULT 'guest',
|
role userrole NOT NULL DEFAULT 'guest',
|
||||||
role_id UUID, -- For dynamic RBAC
|
role_id UUID, -- For dynamic RBAC (added in later migration)
|
||||||
|
|
||||||
-- Newsletter Preferences
|
-- Rejection Tracking
|
||||||
newsletter_subscribed BOOLEAN DEFAULT TRUE,
|
rejection_reason TEXT,
|
||||||
newsletter_publish_name BOOLEAN DEFAULT FALSE NOT NULL,
|
rejected_at TIMESTAMP WITH TIME ZONE,
|
||||||
newsletter_publish_photo BOOLEAN DEFAULT FALSE NOT NULL,
|
rejected_by UUID REFERENCES users(id),
|
||||||
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
|
-- Membership
|
||||||
member_since DATE,
|
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,
|
email_verification_reminders_sent INTEGER DEFAULT 0 NOT NULL,
|
||||||
last_email_verification_reminder_at TIMESTAMP WITH TIME ZONE,
|
last_email_verification_reminder_at TIMESTAMP WITH TIME ZONE,
|
||||||
event_attendance_reminders_sent INTEGER DEFAULT 0 NOT NULL,
|
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,
|
renewal_reminders_sent INTEGER DEFAULT 0 NOT NULL,
|
||||||
last_renewal_reminder_at TIMESTAMP WITH TIME ZONE,
|
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
|
-- WordPress Import Tracking
|
||||||
import_source VARCHAR(50),
|
import_source VARCHAR(50),
|
||||||
import_job_id UUID REFERENCES import_jobs(id),
|
import_job_id UUID REFERENCES import_jobs(id),
|
||||||
wordpress_user_id BIGINT,
|
wordpress_user_id BIGINT,
|
||||||
wordpress_registered_date TIMESTAMP WITH TIME ZONE,
|
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
|
-- Timestamps
|
||||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||||
updated_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,
|
name VARCHAR NOT NULL,
|
||||||
description TEXT,
|
description TEXT,
|
||||||
price_cents INTEGER NOT NULL,
|
price_cents INTEGER NOT NULL,
|
||||||
billing_cycle VARCHAR NOT NULL DEFAULT 'yearly',
|
billing_cycle VARCHAR NOT NULL DEFAULT 'annual',
|
||||||
stripe_price_id VARCHAR, -- Legacy, deprecated
|
|
||||||
|
|
||||||
-- Configuration
|
-- Configuration
|
||||||
active BOOLEAN NOT NULL DEFAULT TRUE,
|
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
|
-- Timestamps
|
||||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||||
@@ -350,21 +281,13 @@ CREATE TABLE IF NOT EXISTS subscriptions (
|
|||||||
status subscriptionstatus DEFAULT 'active',
|
status subscriptionstatus DEFAULT 'active',
|
||||||
start_date TIMESTAMP WITH TIME ZONE NOT NULL,
|
start_date TIMESTAMP WITH TIME ZONE NOT NULL,
|
||||||
end_date TIMESTAMP WITH TIME ZONE,
|
end_date TIMESTAMP WITH TIME ZONE,
|
||||||
|
next_billing_date TIMESTAMP WITH TIME ZONE,
|
||||||
|
|
||||||
-- Payment Details
|
-- Payment Details
|
||||||
amount_paid_cents INTEGER,
|
amount_paid_cents INTEGER,
|
||||||
base_subscription_cents INTEGER NOT NULL,
|
base_subscription_cents INTEGER NOT NULL,
|
||||||
donation_cents INTEGER DEFAULT 0 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 Support
|
||||||
manual_payment BOOLEAN DEFAULT FALSE NOT NULL,
|
manual_payment BOOLEAN DEFAULT FALSE NOT NULL,
|
||||||
manual_payment_notes TEXT,
|
manual_payment_notes TEXT,
|
||||||
@@ -396,14 +319,6 @@ CREATE TABLE IF NOT EXISTS donations (
|
|||||||
stripe_payment_intent_id VARCHAR,
|
stripe_payment_intent_id VARCHAR,
|
||||||
payment_method 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
|
-- Metadata
|
||||||
notes TEXT,
|
notes TEXT,
|
||||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
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
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
|
||||||
);
|
);
|
||||||
|
|
||||||
-- Add FK constraints to import_jobs (now that users table exists)
|
-- Import Jobs table
|
||||||
ALTER TABLE import_jobs
|
CREATE TABLE IF NOT EXISTS import_jobs (
|
||||||
ADD CONSTRAINT fk_import_jobs_rollback_by FOREIGN KEY (rollback_by) REFERENCES users(id),
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
ADD CONSTRAINT fk_import_jobs_started_by FOREIGN KEY (started_by) REFERENCES users(id);
|
|
||||||
|
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)
|
-- Import Rollback Audit table (for tracking rollback operations)
|
||||||
CREATE TABLE IF NOT EXISTS import_rollback_audit (
|
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_plan_id ON subscriptions(plan_id);
|
||||||
CREATE INDEX IF NOT EXISTS idx_subscriptions_status ON subscriptions(status);
|
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_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
|
-- Donations indexes
|
||||||
CREATE INDEX IF NOT EXISTS idx_donation_user ON donations(user_id);
|
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_type ON donations(donation_type);
|
||||||
CREATE INDEX IF NOT EXISTS idx_donation_status ON donations(status);
|
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_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
|
-- Import Jobs indexes
|
||||||
CREATE INDEX IF NOT EXISTS idx_import_jobs_status ON import_jobs(status);
|
CREATE INDEX IF NOT EXISTS idx_import_jobs_status ON import_jobs(status);
|
||||||
|
|||||||
19
models.py
19
models.py
@@ -238,15 +238,6 @@ class Subscription(Base):
|
|||||||
donation_cents = Column(Integer, default=0, nullable=False) # Additional donation amount
|
donation_cents = Column(Integer, default=0, nullable=False) # Additional donation amount
|
||||||
# Note: amount_paid_cents = base_subscription_cents + donation_cents
|
# 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 fields
|
||||||
manual_payment = Column(Boolean, default=False, nullable=False) # Whether this was a manual offline payment
|
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
|
manual_payment_notes = Column(Text, nullable=True) # Admin notes about the payment
|
||||||
@@ -278,17 +269,9 @@ class Donation(Base):
|
|||||||
|
|
||||||
# Payment details
|
# Payment details
|
||||||
stripe_checkout_session_id = Column(String, nullable=True)
|
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.
|
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
|
# Metadata
|
||||||
notes = Column(Text, nullable=True)
|
notes = Column(Text, nullable=True)
|
||||||
created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))
|
created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ motor==3.3.1
|
|||||||
msal==1.27.0
|
msal==1.27.0
|
||||||
mypy==1.18.2
|
mypy==1.18.2
|
||||||
mypy_extensions==1.1.0
|
mypy_extensions==1.1.0
|
||||||
numpy==2.3.5
|
numpy==2.2.6
|
||||||
oauthlib==3.3.1
|
oauthlib==3.3.1
|
||||||
packaging==25.0
|
packaging==25.0
|
||||||
pandas==2.3.3
|
pandas==2.3.3
|
||||||
|
|||||||
@@ -118,40 +118,6 @@ PERMISSIONS = [
|
|||||||
{"code": "permissions.audit", "name": "View Permission Audit Log", "description": "View permission change audit logs", "module": "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 permission assignments for dynamic roles
|
||||||
DEFAULT_ROLE_PERMISSIONS = {
|
DEFAULT_ROLE_PERMISSIONS = {
|
||||||
"guest": [], # Guests have no permissions
|
"guest": [], # Guests have no permissions
|
||||||
@@ -230,34 +196,7 @@ def seed_permissions():
|
|||||||
print(f"\n⚠️ WARNING: Tables not fully cleared! Stopping.")
|
print(f"\n⚠️ WARNING: Tables not fully cleared! Stopping.")
|
||||||
return
|
return
|
||||||
|
|
||||||
# Step 2: Create default system roles
|
# Step 2: Create permissions
|
||||||
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...")
|
print(f"\n📝 Creating {len(PERMISSIONS)} permissions...")
|
||||||
permission_map = {} # Map code to permission object
|
permission_map = {} # Map code to permission object
|
||||||
|
|
||||||
@@ -274,13 +213,13 @@ def seed_permissions():
|
|||||||
db.commit()
|
db.commit()
|
||||||
print(f"✓ Created {len(PERMISSIONS)} permissions")
|
print(f"✓ Created {len(PERMISSIONS)} permissions")
|
||||||
|
|
||||||
# Step 4: Verify roles exist
|
# Step 3: Get all roles from database
|
||||||
print("\n🔍 Verifying dynamic roles...")
|
print("\n🔍 Fetching dynamic roles...")
|
||||||
roles = db.query(Role).all()
|
roles = db.query(Role).all()
|
||||||
role_map = {role.code: role for role in roles}
|
role_map = {role.code: role for role in roles}
|
||||||
print(f"✓ Found {len(roles)} roles: {', '.join(role_map.keys())}")
|
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...")
|
print("\n🔐 Assigning permissions to roles...")
|
||||||
|
|
||||||
from models import UserRole # Import for enum mapping
|
from models import UserRole # Import for enum mapping
|
||||||
@@ -319,7 +258,7 @@ def seed_permissions():
|
|||||||
db.commit()
|
db.commit()
|
||||||
print(f" ✓ {role.name}: Assigned {len(permission_codes)} permissions")
|
print(f" ✓ {role.name}: Assigned {len(permission_codes)} permissions")
|
||||||
|
|
||||||
# Step 6: Summary
|
# Step 5: Summary
|
||||||
print("\n" + "=" * 80)
|
print("\n" + "=" * 80)
|
||||||
print("📊 SEEDING SUMMARY")
|
print("📊 SEEDING SUMMARY")
|
||||||
print("=" * 80)
|
print("=" * 80)
|
||||||
@@ -334,8 +273,7 @@ def seed_permissions():
|
|||||||
for module, count in sorted(modules.items()):
|
for module, count in sorted(modules.items()):
|
||||||
print(f" • {module.capitalize()}: {count} permissions")
|
print(f" • {module.capitalize()}: {count} permissions")
|
||||||
|
|
||||||
print(f"\nTotal system roles created: {len(DEFAULT_ROLES)}")
|
print(f"\nTotal permissions created: {len(PERMISSIONS)}")
|
||||||
print(f"Total permissions created: {len(PERMISSIONS)}")
|
|
||||||
print(f"Total role-permission mappings: {total_assigned}")
|
print(f"Total role-permission mappings: {total_assigned}")
|
||||||
print("\n✅ Permission seeding completed successfully!")
|
print("\n✅ Permission seeding completed successfully!")
|
||||||
print("\nNext step: Restart backend server")
|
print("\nNext step: Restart backend server")
|
||||||
|
|||||||
399
server.py
399
server.py
@@ -97,15 +97,6 @@ logging.basicConfig(
|
|||||||
)
|
)
|
||||||
logger = logging.getLogger(__name__)
|
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
|
# Helper Functions
|
||||||
# ============================================================
|
# ============================================================
|
||||||
@@ -236,7 +227,6 @@ class UserResponse(BaseModel):
|
|||||||
role: str
|
role: str
|
||||||
email_verified: bool
|
email_verified: bool
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
member_since: Optional[datetime] = None # Date when user became active member
|
|
||||||
# Profile
|
# Profile
|
||||||
profile_photo_url: Optional[str] = None
|
profile_photo_url: Optional[str] = None
|
||||||
# Subscription info (optional)
|
# Subscription info (optional)
|
||||||
@@ -492,31 +482,6 @@ class InviteUserRequest(BaseModel):
|
|||||||
last_name: Optional[str] = None
|
last_name: Optional[str] = None
|
||||||
phone: 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):
|
class InvitationResponse(BaseModel):
|
||||||
id: str
|
id: str
|
||||||
email: str
|
email: str
|
||||||
@@ -1751,75 +1716,6 @@ async def get_my_event_activity(
|
|||||||
"total_rsvps": len(rsvps)
|
"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)
|
# Calendar Export Endpoints (Universal iCalendar .ics format)
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
@@ -2378,143 +2274,10 @@ async def get_user_by_id(
|
|||||||
"email_verified": user.email_verified,
|
"email_verified": user.email_verified,
|
||||||
"newsletter_subscribed": user.newsletter_subscribed,
|
"newsletter_subscribed": user.newsletter_subscribed,
|
||||||
"lead_sources": user.lead_sources,
|
"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,
|
"created_at": user.created_at.isoformat() if user.created_at else None,
|
||||||
"updated_at": user.updated_at.isoformat() if user.updated_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")
|
@api_router.put("/admin/users/{user_id}/validate")
|
||||||
async def validate_user(
|
async def validate_user(
|
||||||
user_id: str,
|
user_id: str,
|
||||||
@@ -2713,9 +2476,6 @@ async def activate_payment_manually(
|
|||||||
# 6. Activate user
|
# 6. Activate user
|
||||||
user.status = UserStatus.active
|
user.status = UserStatus.active
|
||||||
set_user_role(user, UserRole.member, db)
|
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)
|
user.updated_at = datetime.now(timezone.utc)
|
||||||
|
|
||||||
# 7. Commit
|
# 7. Commit
|
||||||
@@ -4765,17 +4525,8 @@ async def get_all_subscriptions(
|
|||||||
"donation_cents": sub.donation_cents,
|
"donation_cents": sub.donation_cents,
|
||||||
"payment_method": sub.payment_method,
|
"payment_method": sub.payment_method,
|
||||||
"stripe_subscription_id": sub.stripe_subscription_id,
|
"stripe_subscription_id": sub.stripe_subscription_id,
|
||||||
"stripe_customer_id": sub.stripe_customer_id,
|
|
||||||
"created_at": sub.created_at,
|
"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]
|
} for sub in subscriptions]
|
||||||
|
|
||||||
@api_router.get("/admin/subscriptions/stats")
|
@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),
|
"donor_email": d.donor_email or (d.user.email if d.user else None),
|
||||||
"payment_method": d.payment_method,
|
"payment_method": d.payment_method,
|
||||||
"notes": d.notes,
|
"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]
|
} for d in donations]
|
||||||
|
|
||||||
@api_router.get("/admin/donations/stats")
|
@api_router.get("/admin/donations/stats")
|
||||||
@@ -6337,15 +6080,7 @@ async def create_checkout(
|
|||||||
|
|
||||||
# Create Stripe Checkout Session
|
# Create Stripe Checkout Session
|
||||||
import stripe
|
import stripe
|
||||||
# Try to get Stripe API key from database first, then fall back to environment
|
stripe.api_key = os.getenv("STRIPE_SECRET_KEY")
|
||||||
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"
|
mode = "subscription" if stripe_interval else "payment"
|
||||||
|
|
||||||
@@ -6420,15 +6155,7 @@ async def create_donation_checkout(
|
|||||||
|
|
||||||
# Create Stripe Checkout Session for one-time payment
|
# Create Stripe Checkout Session for one-time payment
|
||||||
import stripe
|
import stripe
|
||||||
# Try to get Stripe API key from database first, then fall back to environment
|
stripe.api_key = os.getenv("STRIPE_SECRET_KEY")
|
||||||
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(
|
checkout_session = stripe.checkout.Session.create(
|
||||||
payment_method_types=['card'],
|
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()
|
donation = db.query(Donation).filter(Donation.id == donation_id).first()
|
||||||
|
|
||||||
if donation:
|
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.status = DonationStatus.completed
|
||||||
donation.stripe_payment_intent_id = payment_intent_id
|
donation.stripe_payment_intent_id = session.get('payment_intent')
|
||||||
donation.stripe_customer_id = session.get('customer')
|
|
||||||
donation.payment_method = 'card'
|
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)
|
donation.updated_at = datetime.now(timezone.utc)
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
# Send thank you email only if donor_email exists
|
# Send thank you email
|
||||||
if donation.donor_email:
|
try:
|
||||||
try:
|
from email_service import send_donation_thank_you_email
|
||||||
from email_service import send_donation_thank_you_email
|
donor_first_name = donation.donor_name.split()[0] if donation.donor_name else "Friend"
|
||||||
donor_first_name = donation.donor_name.split()[0] if donation.donor_name else "Friend"
|
await send_donation_thank_you_email(
|
||||||
await send_donation_thank_you_email(
|
donation.donor_email,
|
||||||
donation.donor_email,
|
donor_first_name,
|
||||||
donor_first_name,
|
donation.amount_cents
|
||||||
donation.amount_cents
|
)
|
||||||
)
|
except Exception as e:
|
||||||
except Exception as e:
|
logger.error(f"Failed to send donation thank you email: {str(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})")
|
logger.info(f"Donation completed: ${donation.amount_cents/100:.2f} (ID: {donation.id})")
|
||||||
else:
|
else:
|
||||||
@@ -6677,26 +6360,15 @@ async def stripe_webhook(request: Request, db: Session = Depends(get_db)):
|
|||||||
).first()
|
).first()
|
||||||
|
|
||||||
if not existing_subscription:
|
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
|
# Calculate subscription period using custom billing cycle if enabled
|
||||||
from payment_service import calculate_subscription_period
|
from payment_service import calculate_subscription_period
|
||||||
start_date, end_date = calculate_subscription_period(plan)
|
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
|
# Create subscription record with donation tracking
|
||||||
subscription = Subscription(
|
subscription = Subscription(
|
||||||
user_id=user.id,
|
user_id=user.id,
|
||||||
plan_id=plan.id,
|
plan_id=plan.id,
|
||||||
stripe_subscription_id=subscription_id,
|
stripe_subscription_id=session.get("subscription"),
|
||||||
stripe_customer_id=session.get("customer"),
|
stripe_customer_id=session.get("customer"),
|
||||||
status=SubscriptionStatus.active,
|
status=SubscriptionStatus.active,
|
||||||
start_date=start_date,
|
start_date=start_date,
|
||||||
@@ -6704,48 +6376,13 @@ async def stripe_webhook(request: Request, db: Session = Depends(get_db)):
|
|||||||
amount_paid_cents=total_amount,
|
amount_paid_cents=total_amount,
|
||||||
base_subscription_cents=base_amount or plan.minimum_price_cents,
|
base_subscription_cents=base_amount or plan.minimum_price_cents,
|
||||||
donation_cents=donation_amount,
|
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)
|
db.add(subscription)
|
||||||
|
|
||||||
# Update user status and role
|
# Update user status and role
|
||||||
user.status = UserStatus.active
|
user.status = UserStatus.active
|
||||||
set_user_role(user, UserRole.member, db)
|
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)
|
user.updated_at = datetime.now(timezone.utc)
|
||||||
|
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|||||||
Reference in New Issue
Block a user