13 Commits
dev ... docker

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

View File

@@ -1,83 +0,0 @@
# Git
.git
.gitignore
# Python
__pycache__
*.py[cod]
*$py.class
*.so
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
*.egg-info/
.installed.cfg
*.egg
# Virtual environments
venv/
ENV/
env/
.venv/
# IDE
.idea/
.vscode/
*.swp
*.swo
*~
# Testing
.pytest_cache/
.coverage
htmlcov/
.tox/
.nox/
# Environment files (will be mounted or passed via env vars)
.env
.env.local
.env.*.local
*.env
# Logs
*.log
logs/
# Database
*.db
*.sqlite3
# Alembic
alembic/versions/__pycache__/
# Docker
Dockerfile
docker-compose*.yml
.docker/
# Documentation
*.md
docs/
# Temporary files
tmp/
temp/
*.tmp
# OS files
.DS_Store
Thumbs.db
# Uploads (will be mounted as volume)
uploads/

6
.gitignore vendored
View File

@@ -1,3 +1,5 @@
.env
.venv
# ============================================================================ # ============================================================================
# Python Backend .gitignore # 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

View File

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

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -1,141 +0,0 @@
#!/usr/bin/env python3
"""
Add Directory Permissions Script
This script adds the new directory.view and directory.manage permissions
without clearing existing permissions.
Usage:
python add_directory_permissions.py
"""
import os
import sys
from sqlalchemy import create_engine, text
from sqlalchemy.orm import sessionmaker
from database import Base
from models import Permission, RolePermission, Role, UserRole
from dotenv import load_dotenv
# Load environment variables
load_dotenv()
# Database connection
DATABASE_URL = os.getenv("DATABASE_URL")
if not DATABASE_URL:
print("Error: DATABASE_URL environment variable not set")
sys.exit(1)
engine = create_engine(DATABASE_URL)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
# New directory permissions
NEW_PERMISSIONS = [
{"code": "directory.view", "name": "View Directory Settings", "description": "View member directory field configuration", "module": "directory"},
{"code": "directory.manage", "name": "Manage Directory Fields", "description": "Enable/disable directory fields shown in Profile and Directory pages", "module": "directory"},
]
# Roles that should have these permissions
ROLE_PERMISSION_MAP = {
"directory.view": ["admin", "superadmin"],
"directory.manage": ["admin", "superadmin"],
}
def add_directory_permissions():
"""Add directory permissions and assign to appropriate roles"""
db = SessionLocal()
try:
print("=" * 60)
print("Adding Directory Permissions")
print("=" * 60)
# Step 1: Add permissions if they don't exist
print("\n1. Adding permissions...")
permission_map = {}
for perm_data in NEW_PERMISSIONS:
existing = db.query(Permission).filter(Permission.code == perm_data["code"]).first()
if existing:
print(f" - {perm_data['code']}: Already exists")
permission_map[perm_data["code"]] = existing
else:
permission = Permission(
code=perm_data["code"],
name=perm_data["name"],
description=perm_data["description"],
module=perm_data["module"]
)
db.add(permission)
db.flush() # Get the ID
permission_map[perm_data["code"]] = permission
print(f" - {perm_data['code']}: Created")
db.commit()
# Step 2: Get roles
print("\n2. Fetching 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())}")
# Enum mapping for backward compatibility
role_enum_map = {
'guest': UserRole.guest,
'member': UserRole.member,
'admin': UserRole.admin,
'superadmin': UserRole.superadmin,
'finance': UserRole.finance
}
# Step 3: Assign permissions to roles
print("\n3. Assigning permissions to roles...")
for perm_code, role_codes in ROLE_PERMISSION_MAP.items():
permission = permission_map.get(perm_code)
if not permission:
print(f" Warning: Permission {perm_code} not found")
continue
for role_code in role_codes:
role = role_map.get(role_code)
if not role:
print(f" Warning: Role {role_code} not found")
continue
# Check if mapping already exists
existing_mapping = db.query(RolePermission).filter(
RolePermission.role_id == role.id,
RolePermission.permission_id == permission.id
).first()
if existing_mapping:
print(f" - {role_code} -> {perm_code}: Already assigned")
else:
role_enum = role_enum_map.get(role_code, UserRole.guest)
mapping = RolePermission(
role=role_enum,
role_id=role.id,
permission_id=permission.id
)
db.add(mapping)
print(f" - {role_code} -> {perm_code}: Assigned")
db.commit()
print("\n" + "=" * 60)
print("Directory permissions added successfully!")
print("=" * 60)
except Exception as e:
db.rollback()
print(f"\nError: {str(e)}")
import traceback
traceback.print_exc()
raise
finally:
db.close()
if __name__ == "__main__":
add_directory_permissions()

View File

@@ -1,141 +0,0 @@
#!/usr/bin/env python3
"""
Add Registration Permissions Script
This script adds the new registration.view and registration.manage permissions
without clearing existing permissions.
Usage:
python add_registration_permissions.py
"""
import os
import sys
from sqlalchemy import create_engine, text
from sqlalchemy.orm import sessionmaker
from database import Base
from models import Permission, RolePermission, Role, UserRole
from dotenv import load_dotenv
# Load environment variables
load_dotenv()
# Database connection
DATABASE_URL = os.getenv("DATABASE_URL")
if not DATABASE_URL:
print("Error: DATABASE_URL environment variable not set")
sys.exit(1)
engine = create_engine(DATABASE_URL)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
# New registration permissions
NEW_PERMISSIONS = [
{"code": "registration.view", "name": "View Registration Settings", "description": "View registration form schema and settings", "module": "registration"},
{"code": "registration.manage", "name": "Manage Registration Form", "description": "Edit registration form schema, steps, and fields", "module": "registration"},
]
# Roles that should have these permissions
ROLE_PERMISSION_MAP = {
"registration.view": ["admin", "superadmin"],
"registration.manage": ["admin", "superadmin"],
}
def add_registration_permissions():
"""Add registration permissions and assign to appropriate roles"""
db = SessionLocal()
try:
print("=" * 60)
print("Adding Registration Permissions")
print("=" * 60)
# Step 1: Add permissions if they don't exist
print("\n1. Adding permissions...")
permission_map = {}
for perm_data in NEW_PERMISSIONS:
existing = db.query(Permission).filter(Permission.code == perm_data["code"]).first()
if existing:
print(f" - {perm_data['code']}: Already exists")
permission_map[perm_data["code"]] = existing
else:
permission = Permission(
code=perm_data["code"],
name=perm_data["name"],
description=perm_data["description"],
module=perm_data["module"]
)
db.add(permission)
db.flush() # Get the ID
permission_map[perm_data["code"]] = permission
print(f" - {perm_data['code']}: Created")
db.commit()
# Step 2: Get roles
print("\n2. Fetching 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())}")
# Enum mapping for backward compatibility
role_enum_map = {
'guest': UserRole.guest,
'member': UserRole.member,
'admin': UserRole.admin,
'superadmin': UserRole.superadmin,
'finance': UserRole.finance
}
# Step 3: Assign permissions to roles
print("\n3. Assigning permissions to roles...")
for perm_code, role_codes in ROLE_PERMISSION_MAP.items():
permission = permission_map.get(perm_code)
if not permission:
print(f" Warning: Permission {perm_code} not found")
continue
for role_code in role_codes:
role = role_map.get(role_code)
if not role:
print(f" Warning: Role {role_code} not found")
continue
# Check if mapping already exists
existing_mapping = db.query(RolePermission).filter(
RolePermission.role_id == role.id,
RolePermission.permission_id == permission.id
).first()
if existing_mapping:
print(f" - {role_code} -> {perm_code}: Already assigned")
else:
role_enum = role_enum_map.get(role_code, UserRole.guest)
mapping = RolePermission(
role=role_enum,
role_id=role.id,
permission_id=permission.id
)
db.add(mapping)
print(f" - {role_code} -> {perm_code}: Assigned")
db.commit()
print("\n" + "=" * 60)
print("Registration permissions added successfully!")
print("=" * 60)
except Exception as e:
db.rollback()
print(f"\nError: {str(e)}")
import traceback
traceback.print_exc()
raise
finally:
db.close()
if __name__ == "__main__":
add_registration_permissions()

View File

@@ -1,39 +0,0 @@
"""add_custom_registration_data
Revision ID: 014_custom_registration
Revises: a1b2c3d4e5f6
Create Date: 2026-02-01 10:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '014_custom_registration'
down_revision: Union[str, None] = 'a1b2c3d4e5f6'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# Add custom_registration_data column to users table
# This stores dynamic registration field responses as JSON
op.add_column('users', sa.Column(
'custom_registration_data',
sa.JSON,
nullable=False,
server_default='{}'
))
# Add comment for documentation
op.execute("""
COMMENT ON COLUMN users.custom_registration_data IS
'Dynamic registration field responses stored as JSON for custom form fields';
""")
def downgrade() -> None:
op.drop_column('users', 'custom_registration_data')

View File

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

View File

@@ -1,100 +0,0 @@
"""add_payment_methods
Revision ID: a1b2c3d4e5f6
Revises: 956ea1628264
Create Date: 2026-01-30 10:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision: str = 'a1b2c3d4e5f6'
down_revision: Union[str, None] = '956ea1628264'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
conn = op.get_bind()
# Create PaymentMethodType enum
paymentmethodtype = postgresql.ENUM(
'card', 'cash', 'bank_transfer', 'check',
name='paymentmethodtype',
create_type=False
)
paymentmethodtype.create(conn, checkfirst=True)
# Check if stripe_customer_id column exists on users table
result = conn.execute(sa.text("""
SELECT column_name FROM information_schema.columns
WHERE table_name = 'users' AND column_name = 'stripe_customer_id'
"""))
if result.fetchone() is None:
# Add stripe_customer_id to users table
op.add_column('users', sa.Column(
'stripe_customer_id',
sa.String(),
nullable=True,
comment='Stripe Customer ID for payment method management'
))
op.create_index('ix_users_stripe_customer_id', 'users', ['stripe_customer_id'])
# Check if payment_methods table exists
result = conn.execute(sa.text("""
SELECT table_name FROM information_schema.tables
WHERE table_name = 'payment_methods'
"""))
if result.fetchone() is None:
# Create payment_methods table
op.create_table(
'payment_methods',
sa.Column('id', postgresql.UUID(as_uuid=True), primary_key=True),
sa.Column('user_id', postgresql.UUID(as_uuid=True), sa.ForeignKey('users.id', ondelete='CASCADE'), nullable=False),
sa.Column('stripe_payment_method_id', sa.String(), nullable=True, unique=True, comment='Stripe pm_xxx reference'),
sa.Column('card_brand', sa.String(20), nullable=True, comment='Card brand: visa, mastercard, amex, etc.'),
sa.Column('card_last4', sa.String(4), nullable=True, comment='Last 4 digits of card'),
sa.Column('card_exp_month', sa.Integer(), nullable=True, comment='Card expiration month'),
sa.Column('card_exp_year', sa.Integer(), nullable=True, comment='Card expiration year'),
sa.Column('card_funding', sa.String(20), nullable=True, comment='Card funding type: credit, debit, prepaid'),
sa.Column('payment_type', paymentmethodtype, nullable=False, server_default='card'),
sa.Column('is_default', sa.Boolean(), nullable=False, server_default='false', comment='Whether this is the default payment method for auto-renewals'),
sa.Column('is_active', sa.Boolean(), nullable=False, server_default='true', comment='Soft delete flag - False means removed'),
sa.Column('is_manual', sa.Boolean(), nullable=False, server_default='false', comment='True for manually recorded methods (cash/check)'),
sa.Column('manual_notes', sa.Text(), nullable=True, comment='Admin notes for manual payment methods'),
sa.Column('created_by', postgresql.UUID(as_uuid=True), sa.ForeignKey('users.id', ondelete='SET NULL'), nullable=True, comment='Admin who added this on behalf of user'),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now(), onupdate=sa.func.now()),
)
# Create indexes
op.create_index('ix_payment_methods_user_id', 'payment_methods', ['user_id'])
op.create_index('ix_payment_methods_stripe_pm_id', 'payment_methods', ['stripe_payment_method_id'])
op.create_index('idx_payment_method_user_default', 'payment_methods', ['user_id', 'is_default'])
op.create_index('idx_payment_method_active', 'payment_methods', ['user_id', 'is_active'])
def downgrade() -> None:
# Drop indexes
op.drop_index('idx_payment_method_active', table_name='payment_methods')
op.drop_index('idx_payment_method_user_default', table_name='payment_methods')
op.drop_index('ix_payment_methods_stripe_pm_id', table_name='payment_methods')
op.drop_index('ix_payment_methods_user_id', table_name='payment_methods')
# Drop payment_methods table
op.drop_table('payment_methods')
# Drop stripe_customer_id from users
op.drop_index('ix_users_stripe_customer_id', table_name='users')
op.drop_column('users', 'stripe_customer_id')
# Drop PaymentMethodType enum
paymentmethodtype = postgresql.ENUM(
'card', 'cash', 'bank_transfer', 'check',
name='paymentmethodtype'
)
paymentmethodtype.drop(op.get_bind(), checkfirst=True)

View File

@@ -128,7 +128,7 @@ async def get_current_admin_user(current_user: User = Depends(get_current_user))
return current_user return current_user
async def get_active_member(current_user: User = Depends(get_current_user)) -> User: async def get_active_member(current_user: User = Depends(get_current_user)) -> User:
"""Require user to be active member or staff with valid status""" """Require user to be active member with valid payment"""
from models import UserStatus from models import UserStatus
if current_user.status != UserStatus.active: if current_user.status != UserStatus.active:
@@ -138,7 +138,7 @@ async def get_active_member(current_user: User = Depends(get_current_user)) -> U
) )
role_code = get_user_role_code(current_user) role_code = get_user_role_code(current_user)
if role_code not in ["member", "admin", "superadmin", "finance"]: if role_code not in ["member", "admin", "superadmin"]:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, status_code=status.HTTP_403_FORBIDDEN,
detail="Member access only" detail="Member access only"

View File

@@ -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:
# 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()
print("=" * 70) print("=" * 70)
print("✅ Superadmin user created successfully!") print("SQL STATEMENT")
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) print("=" * 70)
except ImportError as e: sql = generate_sql(email, password_hash, first_name, last_name)
print(f"❌ Missing dependency: {e}") print(sql)
print(" Run: pip install sqlalchemy psycopg2-binary passlib python-dotenv")
sys.exit(1) # Save to file
except Exception as e: output_file = "create_superadmin.sql"
print(f"❌ Database error: {e}") with open(output_file, 'w') as f:
sys.exit(1) 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()
print("Or copy the SQL above and run it directly in psql")
print("=" * 70)
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
View File

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

View File

@@ -94,30 +94,6 @@ BEGIN;
-- SECTION 2: Create Core Tables -- 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,
@@ -530,7 +445,7 @@ CREATE TABLE IF NOT EXISTS storage_usage (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
total_bytes_used BIGINT NOT NULL DEFAULT 0, total_bytes_used BIGINT NOT NULL DEFAULT 0,
max_bytes_allowed BIGINT NOT NULL DEFAULT 1073741824, -- 1GB max_bytes_allowed BIGINT NOT NULL DEFAULT 10737418240, -- 10GB
last_updated TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP last_updated 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);
@@ -659,7 +587,7 @@ INSERT INTO storage_usage (id, total_bytes_used, max_bytes_allowed, last_updated
SELECT SELECT
gen_random_uuid(), gen_random_uuid(),
0, 0,
1073741824, -- 1GB 10737418240, -- 10GB
CURRENT_TIMESTAMP CURRENT_TIMESTAMP
WHERE NOT EXISTS (SELECT 1 FROM storage_usage); WHERE NOT EXISTS (SELECT 1 FROM storage_usage);

View File

@@ -44,13 +44,6 @@ class DonationStatus(enum.Enum):
completed = "completed" completed = "completed"
failed = "failed" failed = "failed"
class PaymentMethodType(enum.Enum):
card = "card"
cash = "cash"
bank_transfer = "bank_transfer"
check = "check"
class User(Base): class User(Base):
__tablename__ = "users" __tablename__ = "users"
@@ -148,13 +141,6 @@ class User(Base):
role_changed_at = Column(DateTime(timezone=True), nullable=True, comment="Timestamp when role was last changed") 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") role_changed_by = Column(UUID(as_uuid=True), ForeignKey('users.id', ondelete='SET NULL'), nullable=True, comment="Admin who changed the role")
# Stripe Customer ID - Centralized for payment method management
stripe_customer_id = Column(String, nullable=True, index=True, comment="Stripe Customer ID for payment method management")
# Dynamic Registration Form - Custom field responses
custom_registration_data = Column(JSON, default=dict, nullable=False,
comment="Dynamic registration field responses stored as JSON for custom form fields")
created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc)) 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)) updated_at = Column(DateTime, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc))
@@ -164,52 +150,6 @@ class User(Base):
rsvps = relationship("EventRSVP", back_populates="user") rsvps = relationship("EventRSVP", back_populates="user")
subscriptions = relationship("Subscription", back_populates="user", foreign_keys="Subscription.user_id") 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) role_changer = relationship("User", foreign_keys=[role_changed_by], remote_side="User.id", post_update=True)
payment_methods = relationship("PaymentMethod", back_populates="user", foreign_keys="PaymentMethod.user_id")
class PaymentMethod(Base):
"""Stored payment methods for users (Stripe or manual records)"""
__tablename__ = "payment_methods"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
user_id = Column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
# Stripe payment method reference
stripe_payment_method_id = Column(String, nullable=True, unique=True, index=True, comment="Stripe pm_xxx reference")
# Card details (stored for display purposes - PCI compliant)
card_brand = Column(String(20), nullable=True, comment="Card brand: visa, mastercard, amex, etc.")
card_last4 = Column(String(4), nullable=True, comment="Last 4 digits of card")
card_exp_month = Column(Integer, nullable=True, comment="Card expiration month")
card_exp_year = Column(Integer, nullable=True, comment="Card expiration year")
card_funding = Column(String(20), nullable=True, comment="Card funding type: credit, debit, prepaid")
# Payment type classification
payment_type = Column(SQLEnum(PaymentMethodType), default=PaymentMethodType.card, nullable=False)
# Status flags
is_default = Column(Boolean, default=False, nullable=False, comment="Whether this is the default payment method for auto-renewals")
is_active = Column(Boolean, default=True, nullable=False, comment="Soft delete flag - False means removed")
is_manual = Column(Boolean, default=False, nullable=False, comment="True for manually recorded methods (cash/check)")
# Manual payment notes (for cash/check records)
manual_notes = Column(Text, nullable=True, comment="Admin notes for manual payment methods")
# Audit trail
created_by = Column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True, comment="Admin who added this on behalf of user")
created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), nullable=False)
updated_at = Column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc), nullable=False)
# Relationships
user = relationship("User", back_populates="payment_methods", foreign_keys=[user_id])
creator = relationship("User", foreign_keys=[created_by])
# Composite index for efficient queries
__table_args__ = (
Index('idx_payment_method_user_default', 'user_id', 'is_default'),
Index('idx_payment_method_active', 'user_id', 'is_active'),
)
class Event(Base): class Event(Base):
__tablename__ = "events" __tablename__ = "events"
@@ -298,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
@@ -338,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))

View File

@@ -327,38 +327,6 @@ PERMISSIONS = [
"module": "gallery" "module": "gallery"
}, },
# ========== PAYMENT METHODS MODULE ==========
{
"code": "payment_methods.view",
"name": "View Payment Methods",
"description": "View user payment methods (masked)",
"module": "payment_methods"
},
{
"code": "payment_methods.view_sensitive",
"name": "View Sensitive Payment Details",
"description": "View full payment method details including Stripe IDs (requires password)",
"module": "payment_methods"
},
{
"code": "payment_methods.create",
"name": "Create Payment Methods",
"description": "Add payment methods on behalf of users",
"module": "payment_methods"
},
{
"code": "payment_methods.delete",
"name": "Delete Payment Methods",
"description": "Delete user payment methods",
"module": "payment_methods"
},
{
"code": "payment_methods.set_default",
"name": "Set Default Payment Method",
"description": "Set a user's default payment method",
"module": "payment_methods"
},
# ========== SETTINGS MODULE ========== # ========== SETTINGS MODULE ==========
{ {
"code": "settings.view", "code": "settings.view",
@@ -485,10 +453,6 @@ DEFAULT_ROLE_PERMISSIONS = {
"gallery.edit", "gallery.edit",
"gallery.delete", "gallery.delete",
"gallery.moderate", "gallery.moderate",
"payment_methods.view",
"payment_methods.create",
"payment_methods.delete",
"payment_methods.set_default",
"settings.view", "settings.view",
"settings.edit", "settings.edit",
"settings.email_templates", "settings.email_templates",
@@ -496,36 +460,6 @@ DEFAULT_ROLE_PERMISSIONS = {
"settings.logs", "settings.logs",
], ],
UserRole.finance: [
# Finance role has all admin permissions plus sensitive payment access
"users.view",
"users.export",
"events.view",
"events.rsvps",
"events.calendar_export",
"subscriptions.view",
"subscriptions.create",
"subscriptions.edit",
"subscriptions.cancel",
"subscriptions.activate",
"subscriptions.plans",
"financials.view",
"financials.create",
"financials.edit",
"financials.delete",
"financials.export",
"financials.payments",
"newsletters.view",
"bylaws.view",
"gallery.view",
"payment_methods.view",
"payment_methods.view_sensitive", # Finance can view sensitive payment details
"payment_methods.create",
"payment_methods.delete",
"payment_methods.set_default",
"settings.view",
],
# Superadmin gets all permissions automatically in code, # Superadmin gets all permissions automatically in code,
# so we don't need to explicitly assign them # so we don't need to explicitly assign them
UserRole.superadmin: [] UserRole.superadmin: []

View File

@@ -35,21 +35,6 @@ class R2Storage:
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': ['.xlsx'] 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': ['.xlsx']
} }
# Branding assets (logo and favicon)
ALLOWED_BRANDING_TYPES = {
'image/jpeg': ['.jpg', '.jpeg'],
'image/png': ['.png'],
'image/webp': ['.webp'],
'image/svg+xml': ['.svg']
}
ALLOWED_FAVICON_TYPES = {
'image/x-icon': ['.ico'],
'image/vnd.microsoft.icon': ['.ico'],
'image/png': ['.png'],
'image/svg+xml': ['.svg']
}
def __init__(self): def __init__(self):
"""Initialize R2 client with credentials from environment""" """Initialize R2 client with credentials from environment"""
self.account_id = os.getenv('R2_ACCOUNT_ID') self.account_id = os.getenv('R2_ACCOUNT_ID')

View File

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

View File

@@ -2,7 +2,7 @@
""" """
Permission Seeding Script for Dynamic RBAC System Permission Seeding Script for Dynamic RBAC System
This script populates the database with 65 granular permissions and assigns them This script populates the database with 59 granular permissions and assigns them
to the appropriate dynamic roles (not the old enum roles). to the appropriate dynamic roles (not the old enum roles).
Usage: Usage:
@@ -33,7 +33,7 @@ engine = create_engine(DATABASE_URL)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
# ============================================================ # ============================================================
# Permission Definitions (65 permissions across 11 modules) # Permission Definitions (59 permissions across 10 modules)
# ============================================================ # ============================================================
PERMISSIONS = [ PERMISSIONS = [
@@ -116,55 +116,6 @@ PERMISSIONS = [
{"code": "permissions.assign", "name": "Assign Permissions", "description": "Assign permissions to roles", "module": "permissions"}, {"code": "permissions.assign", "name": "Assign Permissions", "description": "Assign permissions to roles", "module": "permissions"},
{"code": "permissions.manage_roles", "name": "Manage Roles", "description": "Create and manage user roles", "module": "permissions"}, {"code": "permissions.manage_roles", "name": "Manage Roles", "description": "Create and manage user roles", "module": "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"},
# ========== PAYMENT METHODS MODULE (5) ==========
{"code": "payment_methods.view", "name": "View Payment Methods", "description": "View user payment methods (masked)", "module": "payment_methods"},
{"code": "payment_methods.view_sensitive", "name": "View Sensitive Payment Details", "description": "View full Stripe payment method IDs (requires password)", "module": "payment_methods"},
{"code": "payment_methods.create", "name": "Create Payment Methods", "description": "Add payment methods on behalf of users", "module": "payment_methods"},
{"code": "payment_methods.delete", "name": "Delete Payment Methods", "description": "Remove user payment methods", "module": "payment_methods"},
{"code": "payment_methods.set_default", "name": "Set Default Payment Method", "description": "Set default payment method for users", "module": "payment_methods"},
# ========== REGISTRATION MODULE (2) ==========
{"code": "registration.view", "name": "View Registration Settings", "description": "View registration form schema and settings", "module": "registration"},
{"code": "registration.manage", "name": "Manage Registration Form", "description": "Edit registration form schema, steps, and fields", "module": "registration"},
# ========== DIRECTORY MODULE (2) ==========
{"code": "directory.view", "name": "View Directory Settings", "description": "View member directory field configuration", "module": "directory"},
{"code": "directory.manage", "name": "Manage Directory Fields", "description": "Enable/disable directory fields shown in Profile and Directory pages", "module": "directory"},
]
# 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
@@ -185,9 +136,6 @@ DEFAULT_ROLE_PERMISSIONS = {
"subscriptions.cancel", "subscriptions.activate", "subscriptions.plans", "subscriptions.cancel", "subscriptions.activate", "subscriptions.plans",
"subscriptions.export", "subscriptions.export",
"donations.view", "donations.export", "donations.view", "donations.export",
# Payment methods - finance can view sensitive details
"payment_methods.view", "payment_methods.view_sensitive",
"payment_methods.create", "payment_methods.delete", "payment_methods.set_default",
], ],
"admin": [ "admin": [
@@ -209,13 +157,6 @@ DEFAULT_ROLE_PERMISSIONS = {
"gallery.view", "gallery.upload", "gallery.edit", "gallery.delete", "gallery.moderate", "gallery.view", "gallery.upload", "gallery.edit", "gallery.delete", "gallery.moderate",
"settings.view", "settings.edit", "settings.email_templates", "settings.storage", "settings.view", "settings.edit", "settings.email_templates", "settings.storage",
"settings.logs", "settings.logs",
# Payment methods - admin can manage but not view sensitive details
"payment_methods.view", "payment_methods.create",
"payment_methods.delete", "payment_methods.set_default",
# Registration form management
"registration.view", "registration.manage",
# Directory configuration
"directory.view", "directory.manage",
], ],
"superadmin": [ "superadmin": [
@@ -255,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
@@ -299,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
@@ -344,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)
@@ -359,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")

2918
server.py

File diff suppressed because it is too large Load Diff

10
start.sh Normal file
View File

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