diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..58de578 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,83 @@ +# Git +.git +.gitignore + +# Python +__pycache__ +*.py[cod] +*$py.class +*.so +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg + +# Virtual environments +venv/ +ENV/ +env/ +.venv/ + +# IDE +.idea/ +.vscode/ +*.swp +*.swo +*~ + +# Testing +.pytest_cache/ +.coverage +htmlcov/ +.tox/ +.nox/ + +# Environment files (will be mounted or passed via env vars) +.env +.env.local +.env.*.local +*.env + +# Logs +*.log +logs/ + +# Database +*.db +*.sqlite3 + +# Alembic +alembic/versions/__pycache__/ + +# Docker +Dockerfile +docker-compose*.yml +.docker/ + +# Documentation +*.md +docs/ + +# Temporary files +tmp/ +temp/ +*.tmp + +# OS files +.DS_Store +Thumbs.db + +# Uploads (will be mounted as volume) +uploads/ diff --git a/.gitignore b/.gitignore index fc7287d..7e6ee36 100644 --- a/.gitignore +++ b/.gitignore @@ -245,6 +245,9 @@ temp_uploads/ tmp/ temporary/ +# Generated SQL files (from scripts) +create_superadmin.sql + # CSV imports imports/*.csv !imports/.gitkeep diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..e533c87 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,40 @@ +# Backend Dockerfile - FastAPI with Python +FROM python:3.11-slim + +# Set environment variables +ENV PYTHONDONTWRITEBYTECODE=1 +ENV PYTHONUNBUFFERED=1 +ENV PYTHONPATH=/app + +# Set work directory +WORKDIR /app + +# Install system dependencies +RUN apt-get update && apt-get install -y \ + gcc \ + libpq-dev \ + curl \ + && rm -rf /var/lib/apt/lists/* + +# Install Python dependencies +COPY requirements.txt . +RUN pip install --no-cache-dir --upgrade pip && \ + pip install --no-cache-dir -r requirements.txt + +# Copy application code +COPY . . + +# Create non-root user for security +RUN adduser --disabled-password --gecos '' appuser && \ + chown -R appuser:appuser /app +USER appuser + +# Expose port +EXPOSE 8000 + +# Health check +HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ + CMD curl -f http://localhost:8000/health || exit 1 + +# Run the application +CMD ["uvicorn", "server:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/__pycache__/auth.cpython-312.pyc b/__pycache__/auth.cpython-312.pyc index 6cbc913..87c5b39 100644 Binary files a/__pycache__/auth.cpython-312.pyc and b/__pycache__/auth.cpython-312.pyc differ diff --git a/__pycache__/models.cpython-312.pyc b/__pycache__/models.cpython-312.pyc index 5c0f75b..1527758 100644 Binary files a/__pycache__/models.cpython-312.pyc and b/__pycache__/models.cpython-312.pyc differ diff --git a/__pycache__/r2_storage.cpython-312.pyc b/__pycache__/r2_storage.cpython-312.pyc index 9ffbb17..863f476 100644 Binary files a/__pycache__/r2_storage.cpython-312.pyc and b/__pycache__/r2_storage.cpython-312.pyc differ diff --git a/__pycache__/server.cpython-312.pyc b/__pycache__/server.cpython-312.pyc index d4011c1..0c295b8 100644 Binary files a/__pycache__/server.cpython-312.pyc and b/__pycache__/server.cpython-312.pyc differ diff --git a/add_directory_permissions.py b/add_directory_permissions.py new file mode 100644 index 0000000..2818bd4 --- /dev/null +++ b/add_directory_permissions.py @@ -0,0 +1,141 @@ +#!/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() diff --git a/add_registration_permissions.py b/add_registration_permissions.py new file mode 100644 index 0000000..9fd39f0 --- /dev/null +++ b/add_registration_permissions.py @@ -0,0 +1,141 @@ +#!/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() diff --git a/alembic/versions/014_add_custom_registration_data.py b/alembic/versions/014_add_custom_registration_data.py new file mode 100644 index 0000000..962318e --- /dev/null +++ b/alembic/versions/014_add_custom_registration_data.py @@ -0,0 +1,39 @@ +"""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') diff --git a/alembic/versions/add_payment_methods.py b/alembic/versions/add_payment_methods.py new file mode 100644 index 0000000..9723d1c --- /dev/null +++ b/alembic/versions/add_payment_methods.py @@ -0,0 +1,100 @@ +"""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) diff --git a/auth.py b/auth.py index 3371604..f25a1d6 100644 --- a/auth.py +++ b/auth.py @@ -128,7 +128,7 @@ async def get_current_admin_user(current_user: User = Depends(get_current_user)) return current_user async def get_active_member(current_user: User = Depends(get_current_user)) -> User: - """Require user to be active member with valid payment""" + """Require user to be active member or staff with valid status""" from models import UserStatus 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) - if role_code not in ["member", "admin", "superadmin"]: + if role_code not in ["member", "admin", "superadmin", "finance"]: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="Member access only" diff --git a/create_superadmin.py b/create_superadmin.py index 7644bc9..2b6e68c 100644 --- a/create_superadmin.py +++ b/create_superadmin.py @@ -1,38 +1,15 @@ #!/usr/bin/env python3 """ Create Superadmin User Script -Generates a superadmin user with hashed password for LOAF membership platform +Directly creates a superadmin user in the database for LOAF membership platform """ -import bcrypt import sys import os from getpass import getpass -def generate_password_hash(password: str) -> str: - """Generate bcrypt hash for password""" - return bcrypt.hashpw(password.encode(), bcrypt.gensalt()).decode() - -def generate_sql(email: str, password_hash: str, first_name: str, last_name: str) -> str: - """Generate SQL INSERT statement""" - return f""" --- Create Superadmin User -INSERT INTO users ( - id, email, password_hash, first_name, last_name, - status, role, email_verified, created_at, updated_at -) VALUES ( - gen_random_uuid(), - '{email}', - '{password_hash}', - '{first_name}', - '{last_name}', - 'active', - 'superadmin', - true, - NOW(), - NOW() -); -""" +# Add the backend directory to path for imports +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) def main(): print("=" * 70) @@ -40,6 +17,15 @@ def main(): print("=" * 70) print() + # Check for DATABASE_URL + from dotenv import load_dotenv + load_dotenv() + + database_url = os.getenv("DATABASE_URL") + if not database_url: + print("❌ DATABASE_URL not found in environment or .env file") + sys.exit(1) + # Get user input email = input("Email address: ").strip() if not email or '@' not in email: @@ -68,31 +54,89 @@ def main(): sys.exit(1) print() - print("Generating password hash...") - password_hash = generate_password_hash(password) + print("Creating superadmin user...") - print("✅ Password hash generated") - print() - print("=" * 70) - print("SQL STATEMENT") - print("=" * 70) + try: + # Import database dependencies + from sqlalchemy import create_engine, text + from passlib.context import CryptContext - sql = generate_sql(email, password_hash, first_name, last_name) - print(sql) + # Create password hash + pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") + password_hash = pwd_context.hash(password) - # Save to file - output_file = "create_superadmin.sql" - with open(output_file, 'w') as f: - f.write(sql) + # Connect to database + engine = create_engine(database_url) - print("=" * 70) - print(f"✅ SQL saved to: {output_file}") - print() - print("Run this command to create the user:") - print(f" psql -U postgres -d loaf_new -f {output_file}") - print() - print("Or copy the SQL above and run it directly in psql") - print("=" * 70) + with engine.connect() as conn: + # Check if user already exists + result = conn.execute( + text("SELECT id FROM users WHERE email = :email"), + {"email": email} + ) + if result.fetchone(): + print(f"❌ User with email '{email}' already exists") + sys.exit(1) + + # Insert superadmin user + conn.execute( + text(""" + INSERT INTO users ( + id, email, password_hash, first_name, last_name, + phone, address, city, state, zipcode, date_of_birth, + status, role, email_verified, + newsletter_subscribed, accepts_tos, + created_at, updated_at + ) VALUES ( + gen_random_uuid(), + :email, + :password_hash, + :first_name, + :last_name, + '', + '', + '', + '', + '', + '1990-01-01', + 'active', + 'superadmin', + true, + false, + true, + NOW(), + NOW() + ) + """), + { + "email": email, + "password_hash": password_hash, + "first_name": first_name, + "last_name": last_name + } + ) + conn.commit() + + print() + print("=" * 70) + print("✅ Superadmin user created successfully!") + print("=" * 70) + print() + print(f" Email: {email}") + print(f" Name: {first_name} {last_name}") + print(f" Role: superadmin") + print(f" Status: active") + print() + print("You can now log in with these credentials.") + print("=" * 70) + + except ImportError as e: + print(f"❌ Missing dependency: {e}") + print(" Run: pip install sqlalchemy psycopg2-binary passlib python-dotenv") + sys.exit(1) + except Exception as e: + print(f"❌ Database error: {e}") + sys.exit(1) if __name__ == "__main__": try: @@ -100,6 +144,3 @@ if __name__ == "__main__": except KeyboardInterrupt: print("\n\n❌ Cancelled by user") sys.exit(1) - except Exception as e: - print(f"\n❌ Error: {e}") - sys.exit(1) diff --git a/migrations/000_initial_schema.sql b/migrations/000_initial_schema.sql index 87c5f8a..8ed4677 100644 --- a/migrations/000_initial_schema.sql +++ b/migrations/000_initial_schema.sql @@ -94,6 +94,30 @@ BEGIN; -- SECTION 2: Create Core Tables -- ============================================================================ +-- Import Jobs table (must be created before users due to FK reference) +CREATE TABLE IF NOT EXISTS import_jobs ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + + filename VARCHAR NOT NULL, + status importjobstatus NOT NULL DEFAULT 'processing', + total_rows INTEGER DEFAULT 0, + processed_rows INTEGER DEFAULT 0, + success_count INTEGER DEFAULT 0, + error_count INTEGER DEFAULT 0, + error_log JSONB DEFAULT '[]'::jsonb, + + -- WordPress import enhancements + field_mapping JSONB DEFAULT '{}'::jsonb, + wordpress_metadata JSONB DEFAULT '{}'::jsonb, + imported_user_ids JSONB DEFAULT '[]'::jsonb, + rollback_at TIMESTAMP WITH TIME ZONE, + rollback_by UUID, -- Will be updated with FK after users table exists + + started_by UUID, -- Will be updated with FK after users table exists + started_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + completed_at TIMESTAMP WITH TIME ZONE +); + -- Users table CREATE TABLE IF NOT EXISTS users ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), @@ -103,6 +127,7 @@ CREATE TABLE IF NOT EXISTS users ( password_hash VARCHAR NOT NULL, email_verified BOOLEAN NOT NULL DEFAULT FALSE, email_verification_token VARCHAR UNIQUE, + email_verification_expires TIMESTAMP WITH TIME ZONE, -- Personal Information first_name VARCHAR NOT NULL, @@ -113,7 +138,6 @@ CREATE TABLE IF NOT EXISTS users ( state VARCHAR(2), zipcode VARCHAR(10), date_of_birth DATE, - bio TEXT, -- Profile profile_photo_url VARCHAR, @@ -137,20 +161,44 @@ CREATE TABLE IF NOT EXISTS users ( -- Status & Role status userstatus NOT NULL DEFAULT 'pending_email', role userrole NOT NULL DEFAULT 'guest', - role_id UUID, -- For dynamic RBAC (added in later migration) + role_id UUID, -- For dynamic RBAC - -- Rejection Tracking - rejection_reason TEXT, - rejected_at TIMESTAMP WITH TIME ZONE, - rejected_by UUID REFERENCES users(id), + -- Newsletter Preferences + newsletter_subscribed BOOLEAN DEFAULT TRUE, + newsletter_publish_name BOOLEAN DEFAULT FALSE NOT NULL, + newsletter_publish_photo BOOLEAN DEFAULT FALSE NOT NULL, + newsletter_publish_birthday BOOLEAN DEFAULT FALSE NOT NULL, + newsletter_publish_none BOOLEAN DEFAULT FALSE NOT NULL, + + -- Volunteer Interests + volunteer_interests JSONB DEFAULT '[]'::jsonb, + + -- Scholarship Request + scholarship_requested BOOLEAN DEFAULT FALSE NOT NULL, + scholarship_reason TEXT, + + -- Directory Settings + show_in_directory BOOLEAN DEFAULT FALSE NOT NULL, + directory_email VARCHAR, + directory_bio TEXT, + directory_address VARCHAR, + directory_phone VARCHAR, + directory_dob DATE, + directory_partner_name VARCHAR, + + -- Password Reset + password_reset_token VARCHAR, + password_reset_expires TIMESTAMP WITH TIME ZONE, + force_password_change BOOLEAN DEFAULT FALSE NOT NULL, + + -- Terms of Service + accepts_tos BOOLEAN DEFAULT FALSE NOT NULL, + tos_accepted_at TIMESTAMP WITH TIME ZONE, -- Membership member_since DATE, - accepts_tos BOOLEAN DEFAULT FALSE, - tos_accepted_at TIMESTAMP WITH TIME ZONE, - newsletter_subscribed BOOLEAN DEFAULT TRUE, - -- Reminder Tracking (from migration 004) + -- Reminder Tracking email_verification_reminders_sent INTEGER DEFAULT 0 NOT NULL, last_email_verification_reminder_at TIMESTAMP WITH TIME ZONE, event_attendance_reminders_sent INTEGER DEFAULT 0 NOT NULL, @@ -160,12 +208,21 @@ CREATE TABLE IF NOT EXISTS users ( renewal_reminders_sent INTEGER DEFAULT 0 NOT NULL, last_renewal_reminder_at TIMESTAMP WITH TIME ZONE, + -- Rejection Tracking + rejection_reason TEXT, + rejected_at TIMESTAMP WITH TIME ZONE, + rejected_by UUID REFERENCES users(id), + -- WordPress Import Tracking import_source VARCHAR(50), import_job_id UUID REFERENCES import_jobs(id), wordpress_user_id BIGINT, wordpress_registered_date TIMESTAMP WITH TIME ZONE, + -- Role Change Audit Trail + role_changed_at TIMESTAMP WITH TIME ZONE, + role_changed_by UUID REFERENCES users(id) ON DELETE SET NULL, + -- Timestamps created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP @@ -255,11 +312,23 @@ CREATE TABLE IF NOT EXISTS subscription_plans ( name VARCHAR NOT NULL, description TEXT, price_cents INTEGER NOT NULL, - billing_cycle VARCHAR NOT NULL DEFAULT 'annual', + billing_cycle VARCHAR NOT NULL DEFAULT 'yearly', + stripe_price_id VARCHAR, -- Legacy, deprecated -- Configuration active BOOLEAN NOT NULL DEFAULT TRUE, - features JSONB DEFAULT '[]'::jsonb, + + -- Custom billing cycle fields (for recurring date ranges like Jan 1 - Dec 31) + custom_cycle_enabled BOOLEAN DEFAULT FALSE NOT NULL, + custom_cycle_start_month INTEGER, + custom_cycle_start_day INTEGER, + custom_cycle_end_month INTEGER, + custom_cycle_end_day INTEGER, + + -- Dynamic pricing fields + minimum_price_cents INTEGER DEFAULT 3000 NOT NULL, + suggested_price_cents INTEGER, + allow_donation BOOLEAN DEFAULT TRUE NOT NULL, -- Timestamps created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, @@ -281,13 +350,21 @@ CREATE TABLE IF NOT EXISTS subscriptions ( status subscriptionstatus DEFAULT 'active', start_date TIMESTAMP WITH TIME ZONE NOT NULL, end_date TIMESTAMP WITH TIME ZONE, - next_billing_date TIMESTAMP WITH TIME ZONE, -- Payment Details amount_paid_cents INTEGER, base_subscription_cents INTEGER NOT NULL, donation_cents INTEGER DEFAULT 0 NOT NULL, + -- Stripe transaction metadata (for validation and audit) + stripe_payment_intent_id VARCHAR, + stripe_charge_id VARCHAR, + stripe_invoice_id VARCHAR, + payment_completed_at TIMESTAMP WITH TIME ZONE, + card_last4 VARCHAR(4), + card_brand VARCHAR(20), + stripe_receipt_url VARCHAR, + -- Manual Payment Support manual_payment BOOLEAN DEFAULT FALSE NOT NULL, manual_payment_notes TEXT, @@ -319,6 +396,14 @@ CREATE TABLE IF NOT EXISTS donations ( stripe_payment_intent_id VARCHAR, payment_method VARCHAR, + -- Stripe transaction metadata (for validation and audit) + stripe_charge_id VARCHAR, + stripe_customer_id VARCHAR, + payment_completed_at TIMESTAMP WITH TIME ZONE, + card_last4 VARCHAR(4), + card_brand VARCHAR(20), + stripe_receipt_url VARCHAR, + -- Metadata notes TEXT, created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, @@ -445,7 +530,7 @@ CREATE TABLE IF NOT EXISTS storage_usage ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), total_bytes_used BIGINT NOT NULL DEFAULT 0, - max_bytes_allowed BIGINT NOT NULL DEFAULT 10737418240, -- 10GB + max_bytes_allowed BIGINT NOT NULL DEFAULT 1073741824, -- 1GB last_updated TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP ); @@ -466,29 +551,10 @@ CREATE TABLE IF NOT EXISTS user_invitations ( created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP ); --- Import Jobs table -CREATE TABLE IF NOT EXISTS import_jobs ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - - filename VARCHAR NOT NULL, - status importjobstatus NOT NULL DEFAULT 'processing', - total_rows INTEGER DEFAULT 0, - processed_rows INTEGER DEFAULT 0, - success_count INTEGER DEFAULT 0, - error_count INTEGER DEFAULT 0, - error_log JSONB DEFAULT '[]'::jsonb, - - -- WordPress import enhancements - field_mapping JSONB DEFAULT '{}'::jsonb, - wordpress_metadata JSONB DEFAULT '{}'::jsonb, - imported_user_ids JSONB DEFAULT '[]'::jsonb, - rollback_at TIMESTAMP WITH TIME ZONE, - rollback_by UUID REFERENCES users(id), - - started_by UUID REFERENCES users(id), - started_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, - completed_at TIMESTAMP WITH TIME ZONE -); +-- Add FK constraints to import_jobs (now that users table exists) +ALTER TABLE import_jobs + ADD CONSTRAINT fk_import_jobs_rollback_by FOREIGN KEY (rollback_by) REFERENCES users(id), + ADD CONSTRAINT fk_import_jobs_started_by FOREIGN KEY (started_by) REFERENCES users(id); -- Import Rollback Audit table (for tracking rollback operations) CREATE TABLE IF NOT EXISTS import_rollback_audit ( @@ -542,12 +608,18 @@ CREATE INDEX IF NOT EXISTS idx_subscriptions_user_id ON subscriptions(user_id); CREATE INDEX IF NOT EXISTS idx_subscriptions_plan_id ON subscriptions(plan_id); CREATE INDEX IF NOT EXISTS idx_subscriptions_status ON subscriptions(status); CREATE INDEX IF NOT EXISTS idx_subscriptions_stripe_subscription_id ON subscriptions(stripe_subscription_id); +CREATE INDEX IF NOT EXISTS idx_subscriptions_payment_intent ON subscriptions(stripe_payment_intent_id); +CREATE INDEX IF NOT EXISTS idx_subscriptions_charge_id ON subscriptions(stripe_charge_id); +CREATE INDEX IF NOT EXISTS idx_subscriptions_invoice_id ON subscriptions(stripe_invoice_id); -- Donations indexes CREATE INDEX IF NOT EXISTS idx_donation_user ON donations(user_id); CREATE INDEX IF NOT EXISTS idx_donation_type ON donations(donation_type); CREATE INDEX IF NOT EXISTS idx_donation_status ON donations(status); CREATE INDEX IF NOT EXISTS idx_donation_created ON donations(created_at); +CREATE INDEX IF NOT EXISTS idx_donation_payment_intent ON donations(stripe_payment_intent_id); +CREATE INDEX IF NOT EXISTS idx_donation_charge_id ON donations(stripe_charge_id); +CREATE INDEX IF NOT EXISTS idx_donation_customer_id ON donations(stripe_customer_id); -- Import Jobs indexes CREATE INDEX IF NOT EXISTS idx_import_jobs_status ON import_jobs(status); @@ -587,7 +659,7 @@ INSERT INTO storage_usage (id, total_bytes_used, max_bytes_allowed, last_updated SELECT gen_random_uuid(), 0, - 10737418240, -- 10GB + 1073741824, -- 1GB CURRENT_TIMESTAMP WHERE NOT EXISTS (SELECT 1 FROM storage_usage); diff --git a/models.py b/models.py index 930ce27..f256cd7 100644 --- a/models.py +++ b/models.py @@ -44,6 +44,13 @@ class DonationStatus(enum.Enum): completed = "completed" failed = "failed" + +class PaymentMethodType(enum.Enum): + card = "card" + cash = "cash" + bank_transfer = "bank_transfer" + check = "check" + class User(Base): __tablename__ = "users" @@ -141,6 +148,13 @@ class User(Base): 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") + # 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)) updated_at = Column(DateTime, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc)) @@ -150,6 +164,52 @@ class User(Base): rsvps = relationship("EventRSVP", back_populates="user") subscriptions = relationship("Subscription", back_populates="user", foreign_keys="Subscription.user_id") role_changer = relationship("User", foreign_keys=[role_changed_by], remote_side="User.id", post_update=True) + 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): __tablename__ = "events" diff --git a/permissions_seed.py b/permissions_seed.py index 13e42f6..4206238 100644 --- a/permissions_seed.py +++ b/permissions_seed.py @@ -327,6 +327,38 @@ PERMISSIONS = [ "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 ========== { "code": "settings.view", @@ -453,6 +485,10 @@ DEFAULT_ROLE_PERMISSIONS = { "gallery.edit", "gallery.delete", "gallery.moderate", + "payment_methods.view", + "payment_methods.create", + "payment_methods.delete", + "payment_methods.set_default", "settings.view", "settings.edit", "settings.email_templates", @@ -460,6 +496,36 @@ DEFAULT_ROLE_PERMISSIONS = { "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, # so we don't need to explicitly assign them UserRole.superadmin: [] diff --git a/r2_storage.py b/r2_storage.py index 5d8928b..699d859 100644 --- a/r2_storage.py +++ b/r2_storage.py @@ -35,6 +35,21 @@ class R2Storage: '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): """Initialize R2 client with credentials from environment""" self.account_id = os.getenv('R2_ACCOUNT_ID') diff --git a/seed_permissions_rbac.py b/seed_permissions_rbac.py index 6e0529b..0acc9b1 100755 --- a/seed_permissions_rbac.py +++ b/seed_permissions_rbac.py @@ -2,7 +2,7 @@ """ Permission Seeding Script for Dynamic RBAC System -This script populates the database with 59 granular permissions and assigns them +This script populates the database with 65 granular permissions and assigns them to the appropriate dynamic roles (not the old enum roles). Usage: @@ -33,7 +33,7 @@ engine = create_engine(DATABASE_URL) SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) # ============================================================ -# Permission Definitions (59 permissions across 10 modules) +# Permission Definitions (65 permissions across 11 modules) # ============================================================ PERMISSIONS = [ @@ -116,6 +116,55 @@ 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.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 @@ -136,6 +185,9 @@ DEFAULT_ROLE_PERMISSIONS = { "subscriptions.cancel", "subscriptions.activate", "subscriptions.plans", "subscriptions.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": [ @@ -157,6 +209,13 @@ DEFAULT_ROLE_PERMISSIONS = { "gallery.view", "gallery.upload", "gallery.edit", "gallery.delete", "gallery.moderate", "settings.view", "settings.edit", "settings.email_templates", "settings.storage", "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": [ @@ -196,7 +255,34 @@ def seed_permissions(): print(f"\n⚠️ WARNING: Tables not fully cleared! Stopping.") return - # Step 2: Create permissions + # Step 2: Create default system roles + print(f"\n👤 Creating {len(DEFAULT_ROLES)} system roles...") + role_map = {} + + for role_data in DEFAULT_ROLES: + # Check if role already exists + existing_role = db.query(Role).filter(Role.code == role_data["code"]).first() + if existing_role: + print(f" • {role_data['name']}: Already exists, updating...") + existing_role.name = role_data["name"] + existing_role.description = role_data["description"] + existing_role.is_system_role = role_data["is_system_role"] + role_map[role_data["code"]] = existing_role + else: + print(f" • {role_data['name']}: Creating...") + role = Role( + code=role_data["code"], + name=role_data["name"], + description=role_data["description"], + is_system_role=role_data["is_system_role"] + ) + db.add(role) + role_map[role_data["code"]] = role + + db.commit() + print(f"✓ Created/updated {len(DEFAULT_ROLES)} system roles") + + # Step 3: Create permissions print(f"\n📝 Creating {len(PERMISSIONS)} permissions...") permission_map = {} # Map code to permission object @@ -213,13 +299,13 @@ def seed_permissions(): db.commit() print(f"✓ Created {len(PERMISSIONS)} permissions") - # Step 3: Get all roles from database - print("\n🔍 Fetching dynamic roles...") + # Step 4: Verify roles exist + print("\n🔍 Verifying dynamic roles...") roles = db.query(Role).all() role_map = {role.code: role for role in roles} print(f"✓ Found {len(roles)} roles: {', '.join(role_map.keys())}") - # Step 4: Assign permissions to roles + # Step 5: Assign permissions to roles print("\n🔐 Assigning permissions to roles...") from models import UserRole # Import for enum mapping @@ -258,7 +344,7 @@ def seed_permissions(): db.commit() print(f" ✓ {role.name}: Assigned {len(permission_codes)} permissions") - # Step 5: Summary + # Step 6: Summary print("\n" + "=" * 80) print("📊 SEEDING SUMMARY") print("=" * 80) @@ -273,7 +359,8 @@ def seed_permissions(): for module, count in sorted(modules.items()): print(f" • {module.capitalize()}: {count} permissions") - print(f"\nTotal permissions created: {len(PERMISSIONS)}") + print(f"\nTotal system roles created: {len(DEFAULT_ROLES)}") + print(f"Total permissions created: {len(PERMISSIONS)}") print(f"Total role-permission mappings: {total_assigned}") print("\n✅ Permission seeding completed successfully!") print("\nNext step: Restart backend server") diff --git a/server.py b/server.py index 00676ec..2aaf852 100644 --- a/server.py +++ b/server.py @@ -15,9 +15,10 @@ import uuid import secrets import csv import io +import json from database import engine, get_db, Base -from models import User, Event, EventRSVP, UserStatus, UserRole, RSVPStatus, SubscriptionPlan, Subscription, SubscriptionStatus, StorageUsage, EventGallery, NewsletterArchive, FinancialReport, BylawsDocument, Permission, RolePermission, Role, UserInvitation, InvitationStatus, ImportJob, ImportJobStatus, ImportRollbackAudit, Donation, DonationType, DonationStatus +from models import User, Event, EventRSVP, UserStatus, UserRole, RSVPStatus, SubscriptionPlan, Subscription, SubscriptionStatus, StorageUsage, EventGallery, NewsletterArchive, FinancialReport, BylawsDocument, Permission, RolePermission, Role, UserInvitation, InvitationStatus, ImportJob, ImportJobStatus, ImportRollbackAudit, Donation, DonationType, DonationStatus, SystemSettings, PaymentMethod, PaymentMethodType from auth import ( get_password_hash, verify_password, @@ -97,6 +98,15 @@ logging.basicConfig( ) logger = logging.getLogger(__name__) +# ============================================================ +# Health Check Endpoint (for Kubernetes probes) +# ============================================================ + +@app.get("/health") +async def health_check(): + """Health check endpoint for Kubernetes liveness/readiness probes.""" + return {"status": "healthy", "service": "membership-backend"} + # ============================================================ # Helper Functions # ============================================================ @@ -125,16 +135,23 @@ def set_user_role(user: User, role_enum: UserRole, db: Session): # Pydantic Models # ============================================================ class RegisterRequest(BaseModel): - # Step 1: Personal & Partner Information + """Dynamic registration request - validates against registration schema""" + + # Fixed required fields (always present) first_name: str last_name: str - phone: str - address: str - city: str - state: str - zipcode: str - date_of_birth: datetime - lead_sources: List[str] + email: EmailStr + password: str = Field(min_length=6) + accepts_tos: bool = False + + # Step 1: Personal & Partner Information (optional for dynamic schema) + 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 + lead_sources: Optional[List[str]] = None partner_first_name: Optional[str] = None partner_last_name: Optional[str] = None partner_is_member: Optional[bool] = False @@ -142,16 +159,16 @@ class RegisterRequest(BaseModel): # Step 2: Newsletter, Volunteer & Scholarship referred_by_member_name: Optional[str] = None - newsletter_publish_name: bool - newsletter_publish_photo: bool - newsletter_publish_birthday: bool - newsletter_publish_none: bool - volunteer_interests: List[str] = [] - scholarship_requested: bool = False + newsletter_publish_name: Optional[bool] = False + newsletter_publish_photo: Optional[bool] = False + newsletter_publish_birthday: Optional[bool] = False + newsletter_publish_none: Optional[bool] = False + volunteer_interests: Optional[List[str]] = [] + scholarship_requested: Optional[bool] = False scholarship_reason: Optional[str] = None # Step 3: Directory Settings - show_in_directory: bool = False + show_in_directory: Optional[bool] = False directory_email: Optional[str] = None directory_bio: Optional[str] = None directory_address: Optional[str] = None @@ -159,10 +176,9 @@ class RegisterRequest(BaseModel): directory_dob: Optional[datetime] = None directory_partner_name: Optional[str] = None - # Step 4: Account Credentials - email: EmailStr - password: str = Field(min_length=6) - accepts_tos: bool = False + # Allow extra fields for custom registration data + class Config: + extra = 'allow' @validator('accepts_tos') def tos_must_be_accepted(cls, v): @@ -170,25 +186,6 @@ class RegisterRequest(BaseModel): raise ValueError('You must accept the Terms of Service to register') return v - @validator('newsletter_publish_none') - def validate_newsletter_preferences(cls, v, values): - """At least one newsletter preference must be selected""" - name = values.get('newsletter_publish_name', False) - photo = values.get('newsletter_publish_photo', False) - birthday = values.get('newsletter_publish_birthday', False) - - if not (name or photo or birthday or v): - raise ValueError('At least one newsletter publication preference must be selected') - return v - - @validator('scholarship_reason') - def validate_scholarship_reason(cls, v, values): - """If scholarship requested, reason must be provided""" - requested = values.get('scholarship_requested', False) - if requested and not v: - raise ValueError('Scholarship reason is required when requesting scholarship') - return v - class LoginRequest(BaseModel): email: EmailStr password: str @@ -551,11 +548,28 @@ async def register(request: RegisterRequest, db: Session = Depends(get_db)): existing_user = db.query(User).filter(User.email == request.email).first() if existing_user: raise HTTPException(status_code=400, detail="Email already registered") - + + # Get registration schema for dynamic validation + schema = get_registration_schema(db) + + # Convert request to dict for dynamic validation + request_data = request.dict(exclude_unset=False) + + # Perform dynamic schema validation + is_valid, validation_errors = validate_dynamic_registration(request_data, schema) + if not is_valid: + raise HTTPException( + status_code=400, + detail={"message": "Validation failed", "errors": validation_errors} + ) + + # Split data into User model fields and custom fields + user_data, custom_data = split_registration_data(request_data, schema) + # Generate verification token verification_token = secrets.token_urlsafe(32) - - # Create user + + # Create user with known fields user = User( # Account credentials (Step 4) email=request.email, @@ -564,65 +578,68 @@ async def register(request: RegisterRequest, db: Session = Depends(get_db)): # Personal information (Step 1) first_name=request.first_name, last_name=request.last_name, - phone=request.phone, - address=request.address, - city=request.city, - state=request.state, - zipcode=request.zipcode, - date_of_birth=request.date_of_birth, - lead_sources=request.lead_sources, + phone=user_data.get('phone') or request.phone, + address=user_data.get('address') or request.address, + city=user_data.get('city') or request.city, + state=user_data.get('state') or request.state, + zipcode=user_data.get('zipcode') or request.zipcode, + date_of_birth=user_data.get('date_of_birth') or request.date_of_birth, + lead_sources=user_data.get('lead_sources') or request.lead_sources or [], # Partner information (Step 1) - partner_first_name=request.partner_first_name, - partner_last_name=request.partner_last_name, - partner_is_member=request.partner_is_member, - partner_plan_to_become_member=request.partner_plan_to_become_member, + partner_first_name=user_data.get('partner_first_name') or request.partner_first_name, + partner_last_name=user_data.get('partner_last_name') or request.partner_last_name, + partner_is_member=user_data.get('partner_is_member', request.partner_is_member) or False, + partner_plan_to_become_member=user_data.get('partner_plan_to_become_member', request.partner_plan_to_become_member) or False, # Referral (Step 2) - referred_by_member_name=request.referred_by_member_name, + referred_by_member_name=user_data.get('referred_by_member_name') or request.referred_by_member_name, # Newsletter publication preferences (Step 2) - newsletter_publish_name=request.newsletter_publish_name, - newsletter_publish_photo=request.newsletter_publish_photo, - newsletter_publish_birthday=request.newsletter_publish_birthday, - newsletter_publish_none=request.newsletter_publish_none, + newsletter_publish_name=user_data.get('newsletter_publish_name', request.newsletter_publish_name) or False, + newsletter_publish_photo=user_data.get('newsletter_publish_photo', request.newsletter_publish_photo) or False, + newsletter_publish_birthday=user_data.get('newsletter_publish_birthday', request.newsletter_publish_birthday) or False, + newsletter_publish_none=user_data.get('newsletter_publish_none', request.newsletter_publish_none) or False, # Volunteer interests (Step 2) - volunteer_interests=request.volunteer_interests, + volunteer_interests=user_data.get('volunteer_interests') or request.volunteer_interests or [], # Scholarship (Step 2) - scholarship_requested=request.scholarship_requested, - scholarship_reason=request.scholarship_reason, + scholarship_requested=user_data.get('scholarship_requested', request.scholarship_requested) or False, + scholarship_reason=user_data.get('scholarship_reason') or request.scholarship_reason, # Directory settings (Step 3) - show_in_directory=request.show_in_directory, - directory_email=request.directory_email, - directory_bio=request.directory_bio, - directory_address=request.directory_address, - directory_phone=request.directory_phone, - directory_dob=request.directory_dob, - directory_partner_name=request.directory_partner_name, + show_in_directory=user_data.get('show_in_directory', request.show_in_directory) or False, + directory_email=user_data.get('directory_email') or request.directory_email, + directory_bio=user_data.get('directory_bio') or request.directory_bio, + directory_address=user_data.get('directory_address') or request.directory_address, + directory_phone=user_data.get('directory_phone') or request.directory_phone, + directory_dob=user_data.get('directory_dob') or request.directory_dob, + directory_partner_name=user_data.get('directory_partner_name') or request.directory_partner_name, # Terms of Service acceptance (Step 4) accepts_tos=request.accepts_tos, tos_accepted_at=datetime.now(timezone.utc) if request.accepts_tos else None, + # Custom registration data for dynamic fields + custom_registration_data=custom_data if custom_data else {}, + # Status fields status=UserStatus.pending_email, role=UserRole.guest, email_verified=False, email_verification_token=verification_token ) - + db.add(user) db.commit() db.refresh(user) - + # Send verification email await send_verification_email(user.email, verification_token) - + logger.info(f"User registered: {user.email}") - + return {"message": "Registration successful. Please check your email to verify your account."} @api_router.get("/auth/verify-email") @@ -967,32 +984,53 @@ async def update_profile( # Member Directory Routes @api_router.get("/members/directory") async def get_member_directory( + search: Optional[str] = None, current_user: User = Depends(get_active_member), db: Session = Depends(get_db) ): - """Get list of all members who opted into the directory""" - directory_members = db.query(User).filter( + """ + Get list of all active users (members and staff) who opted into the directory. + + Includes members, admins, finance, and superadmins who have: + - show_in_directory = True + - status = active + """ + query = db.query(User).filter( User.show_in_directory == True, - User.role == UserRole.member, User.status == UserStatus.active - ).all() + ) + + # Optional search filter + if search: + search_term = f"%{search}%" + query = query.filter( + or_( + User.first_name.ilike(search_term), + User.last_name.ilike(search_term), + User.directory_bio.ilike(search_term) + ) + ) + + directory_members = query.order_by(User.first_name, User.last_name).all() return [{ "id": str(member.id), "first_name": member.first_name, "last_name": member.last_name, + "role": member.role.value if member.role else None, "profile_photo_url": member.profile_photo_url, "directory_email": member.directory_email, "directory_bio": member.directory_bio, "directory_address": member.directory_address, "directory_phone": member.directory_phone, - "directory_dob": member.directory_dob, + "directory_dob": member.directory_dob.isoformat() if member.directory_dob else None, "directory_partner_name": member.directory_partner_name, "volunteer_interests": member.volunteer_interests or [], "social_media_facebook": member.social_media_facebook, "social_media_instagram": member.social_media_instagram, "social_media_twitter": member.social_media_twitter, "social_media_linkedin": member.social_media_linkedin, + "member_since": member.member_since.isoformat() if member.member_since else None, "created_at": member.created_at.isoformat() if member.created_at else None } for member in directory_members] @@ -1002,11 +1040,10 @@ async def get_directory_member_profile( current_user: User = Depends(get_active_member), db: Session = Depends(get_db) ): - """Get public directory profile of a specific member""" + """Get public directory profile of a specific member or staff""" member = db.query(User).filter( User.id == user_id, User.show_in_directory == True, - User.role == UserRole.member, User.status == UserStatus.active ).first() @@ -1017,18 +1054,20 @@ async def get_directory_member_profile( "id": str(member.id), "first_name": member.first_name, "last_name": member.last_name, + "role": member.role.value if member.role else None, "profile_photo_url": member.profile_photo_url, "directory_email": member.directory_email, "directory_bio": member.directory_bio, "directory_address": member.directory_address, "directory_phone": member.directory_phone, - "directory_dob": member.directory_dob, + "directory_dob": member.directory_dob.isoformat() if member.directory_dob else None, "directory_partner_name": member.directory_partner_name, "volunteer_interests": member.volunteer_interests or [], "social_media_facebook": member.social_media_facebook, "social_media_instagram": member.social_media_instagram, "social_media_twitter": member.social_media_twitter, "social_media_linkedin": member.social_media_linkedin, + "member_since": member.member_since.isoformat() if member.member_since else None, "created_at": member.created_at.isoformat() if member.created_at else None } @@ -1260,50 +1299,6 @@ async def get_calendar_events( return result -# Members Directory Route -@api_router.get("/members/directory") -async def get_members_directory( - search: Optional[str] = None, - current_user: User = Depends(get_active_member), - db: Session = Depends(get_db) -): - """Get members directory - only shows active members who opted in""" - query = db.query(User).filter( - User.show_in_directory == True, - User.status == UserStatus.active - ) - - if search: - search_term = f"%{search}%" - query = query.filter( - or_( - User.first_name.ilike(search_term), - User.last_name.ilike(search_term), - User.directory_bio.ilike(search_term) - ) - ) - - members = query.order_by(User.first_name, User.last_name).all() - - return [ - { - "id": str(member.id), - "first_name": member.first_name, - "last_name": member.last_name, - "profile_photo_url": member.profile_photo_url, - "directory_email": member.directory_email, - "directory_bio": member.directory_bio, - "directory_address": member.directory_address, - "directory_phone": member.directory_phone, - "directory_partner_name": member.directory_partner_name, - "social_media_facebook": member.social_media_facebook, - "social_media_instagram": member.social_media_instagram, - "social_media_twitter": member.social_media_twitter, - "social_media_linkedin": member.social_media_linkedin - } - for member in members - ] - # Admin Calendar Sync Routes @api_router.post("/admin/calendar/sync/{event_id}") async def sync_event_to_microsoft( @@ -2075,6 +2070,873 @@ async def get_config_limits(): "max_storage_bytes": int(os.getenv('MAX_STORAGE_BYTES', 1073741824)) } +# ============================================================================ +# Registration Form Schema Routes +# ============================================================================ + +# Default registration schema matching current 4-step form +DEFAULT_REGISTRATION_SCHEMA = { + "version": "1.0", + "steps": [ + { + "id": "step_personal", + "title": "Personal Information", + "description": "Please provide your personal details and tell us how you heard about us.", + "order": 1, + "sections": [ + { + "id": "section_personal_info", + "title": "Personal Information", + "order": 1, + "fields": [ + {"id": "first_name", "type": "text", "label": "First Name", "required": True, "is_fixed": True, "mapping": "first_name", "validation": {"minLength": 1, "maxLength": 100}, "width": "half", "order": 1}, + {"id": "last_name", "type": "text", "label": "Last Name", "required": True, "is_fixed": True, "mapping": "last_name", "validation": {"minLength": 1, "maxLength": 100}, "width": "half", "order": 2}, + {"id": "phone", "type": "phone", "label": "Phone", "required": True, "is_fixed": False, "mapping": "phone", "width": "half", "order": 3}, + {"id": "date_of_birth", "type": "date", "label": "Date of Birth", "required": True, "is_fixed": False, "mapping": "date_of_birth", "width": "half", "order": 4}, + {"id": "address", "type": "text", "label": "Address", "required": True, "is_fixed": False, "mapping": "address", "width": "full", "order": 5}, + {"id": "city", "type": "text", "label": "City", "required": True, "is_fixed": False, "mapping": "city", "width": "third", "order": 6}, + {"id": "state", "type": "text", "label": "State", "required": True, "is_fixed": False, "mapping": "state", "width": "third", "order": 7}, + {"id": "zipcode", "type": "text", "label": "Zipcode", "required": True, "is_fixed": False, "mapping": "zipcode", "width": "third", "order": 8} + ] + }, + { + "id": "section_lead_sources", + "title": "How Did You Hear About Us?", + "order": 2, + "fields": [ + {"id": "lead_sources", "type": "multiselect", "label": "How did you hear about us?", "required": True, "is_fixed": False, "mapping": "lead_sources", "width": "full", "order": 1, "options": [ + {"value": "Current member", "label": "Current member"}, + {"value": "Friend", "label": "Friend"}, + {"value": "OutSmart Magazine", "label": "OutSmart Magazine"}, + {"value": "Search engine (Google etc.)", "label": "Search engine (Google etc.)"}, + {"value": "I've known about LOAF for a long time", "label": "I've known about LOAF for a long time"}, + {"value": "Other", "label": "Other"} + ]} + ] + }, + { + "id": "section_partner", + "title": "Partner Information (Optional)", + "order": 3, + "fields": [ + {"id": "partner_first_name", "type": "text", "label": "Partner First Name", "required": False, "is_fixed": False, "mapping": "partner_first_name", "width": "half", "order": 1}, + {"id": "partner_last_name", "type": "text", "label": "Partner Last Name", "required": False, "is_fixed": False, "mapping": "partner_last_name", "width": "half", "order": 2}, + {"id": "partner_is_member", "type": "checkbox", "label": "Is your partner already a member?", "required": False, "is_fixed": False, "mapping": "partner_is_member", "width": "full", "order": 3}, + {"id": "partner_plan_to_become_member", "type": "checkbox", "label": "Does your partner plan to become a member?", "required": False, "is_fixed": False, "mapping": "partner_plan_to_become_member", "width": "full", "order": 4} + ] + } + ] + }, + { + "id": "step_newsletter", + "title": "Newsletter & Volunteer", + "description": "Tell us about your newsletter preferences and volunteer interests.", + "order": 2, + "sections": [ + { + "id": "section_referral", + "title": "Referral", + "order": 1, + "fields": [ + {"id": "referred_by_member_name", "type": "text", "label": "If referred by a current member, please provide their name", "required": False, "is_fixed": False, "mapping": "referred_by_member_name", "width": "full", "order": 1, "placeholder": "Enter member name or email"} + ] + }, + { + "id": "section_newsletter_prefs", + "title": "Newsletter Publication Preferences", + "description": "Select what you would like published in our newsletter.", + "order": 2, + "fields": [ + {"id": "newsletter_publish_name", "type": "checkbox", "label": "Publish my name", "required": False, "is_fixed": False, "mapping": "newsletter_publish_name", "width": "full", "order": 1}, + {"id": "newsletter_publish_photo", "type": "checkbox", "label": "Publish my photo", "required": False, "is_fixed": False, "mapping": "newsletter_publish_photo", "width": "full", "order": 2}, + {"id": "newsletter_publish_birthday", "type": "checkbox", "label": "Publish my birthday", "required": False, "is_fixed": False, "mapping": "newsletter_publish_birthday", "width": "full", "order": 3}, + {"id": "newsletter_publish_none", "type": "checkbox", "label": "Don't publish anything about me", "required": False, "is_fixed": False, "mapping": "newsletter_publish_none", "width": "full", "order": 4} + ], + "validation": {"atLeastOne": True, "message": "Please select at least one newsletter publication preference"} + }, + { + "id": "section_volunteer", + "title": "Volunteer Interests", + "order": 3, + "fields": [ + {"id": "volunteer_interests", "type": "multiselect", "label": "Select areas where you would like to volunteer", "required": False, "is_fixed": False, "mapping": "volunteer_interests", "width": "full", "order": 1, "options": [ + {"value": "Events", "label": "Events"}, + {"value": "Hospitality", "label": "Hospitality"}, + {"value": "Newsletter", "label": "Newsletter"}, + {"value": "Board", "label": "Board"}, + {"value": "Community Outreach", "label": "Community Outreach"}, + {"value": "Other", "label": "Other"} + ]} + ] + }, + { + "id": "section_scholarship", + "title": "Scholarship Request", + "order": 4, + "fields": [ + {"id": "scholarship_requested", "type": "checkbox", "label": "I would like to request a scholarship", "required": False, "is_fixed": False, "mapping": "scholarship_requested", "width": "full", "order": 1}, + {"id": "scholarship_reason", "type": "textarea", "label": "Please explain why you are requesting a scholarship", "required": False, "is_fixed": False, "mapping": "scholarship_reason", "width": "full", "order": 2, "rows": 4} + ] + } + ] + }, + { + "id": "step_directory", + "title": "Member Directory", + "description": "Choose what information to display in the member directory.", + "order": 3, + "sections": [ + { + "id": "section_directory_settings", + "title": "Directory Settings", + "order": 1, + "fields": [ + {"id": "show_in_directory", "type": "checkbox", "label": "Show my profile in the member directory", "required": False, "is_fixed": False, "mapping": "show_in_directory", "width": "full", "order": 1}, + {"id": "directory_email", "type": "email", "label": "Directory Email (if different from account email)", "required": False, "is_fixed": False, "mapping": "directory_email", "width": "full", "order": 2}, + {"id": "directory_bio", "type": "textarea", "label": "Bio for directory", "required": False, "is_fixed": False, "mapping": "directory_bio", "width": "full", "order": 3, "rows": 4}, + {"id": "directory_address", "type": "text", "label": "Address to display in directory", "required": False, "is_fixed": False, "mapping": "directory_address", "width": "full", "order": 4}, + {"id": "directory_phone", "type": "phone", "label": "Phone to display in directory", "required": False, "is_fixed": False, "mapping": "directory_phone", "width": "half", "order": 5}, + {"id": "directory_dob", "type": "date", "label": "Birthday to display in directory", "required": False, "is_fixed": False, "mapping": "directory_dob", "width": "half", "order": 6}, + {"id": "directory_partner_name", "type": "text", "label": "Partner name to display in directory", "required": False, "is_fixed": False, "mapping": "directory_partner_name", "width": "full", "order": 7} + ] + } + ] + }, + { + "id": "step_account", + "title": "Account Setup", + "description": "Create your account credentials and accept the terms of service.", + "order": 4, + "sections": [ + { + "id": "section_credentials", + "title": "Account Credentials", + "order": 1, + "fields": [ + {"id": "email", "type": "email", "label": "Email Address", "required": True, "is_fixed": True, "mapping": "email", "width": "full", "order": 1}, + {"id": "password", "type": "password", "label": "Password", "required": True, "is_fixed": True, "mapping": "password", "validation": {"minLength": 6}, "width": "half", "order": 2}, + {"id": "confirmPassword", "type": "password", "label": "Confirm Password", "required": True, "is_fixed": True, "client_only": True, "width": "half", "order": 3, "validation": {"matchField": "password"}} + ] + }, + { + "id": "section_tos", + "title": "Terms of Service", + "order": 2, + "fields": [ + {"id": "accepts_tos", "type": "checkbox", "label": "I accept the Terms of Service and Privacy Policy", "required": True, "is_fixed": True, "mapping": "accepts_tos", "width": "full", "order": 1} + ] + } + ] + } + ], + "conditional_rules": [ + { + "id": "rule_scholarship_reason", + "trigger_field": "scholarship_requested", + "trigger_operator": "equals", + "trigger_value": True, + "action": "show", + "target_fields": ["scholarship_reason"] + } + ], + "fixed_fields": ["email", "password", "first_name", "last_name", "accepts_tos"] +} + +# Supported field types with their validation options +FIELD_TYPES = { + "text": { + "name": "Text Input", + "validation_options": ["required", "minLength", "maxLength", "pattern"], + "properties": ["placeholder", "width"] + }, + "email": { + "name": "Email Input", + "validation_options": ["required"], + "properties": ["placeholder"] + }, + "phone": { + "name": "Phone Input", + "validation_options": ["required"], + "properties": ["placeholder"] + }, + "date": { + "name": "Date Input", + "validation_options": ["required", "min_date", "max_date"], + "properties": [] + }, + "dropdown": { + "name": "Dropdown Select", + "validation_options": ["required"], + "properties": ["options", "placeholder"] + }, + "checkbox": { + "name": "Checkbox", + "validation_options": ["required"], + "properties": [] + }, + "radio": { + "name": "Radio Group", + "validation_options": ["required"], + "properties": ["options"] + }, + "multiselect": { + "name": "Multi-Select", + "validation_options": ["required", "min_selections", "max_selections"], + "properties": ["options"] + }, + "address_group": { + "name": "Address Group", + "validation_options": ["required"], + "properties": [] + }, + "textarea": { + "name": "Text Area", + "validation_options": ["required", "minLength", "maxLength"], + "properties": ["rows", "placeholder"] + }, + "file_upload": { + "name": "File Upload", + "validation_options": ["required", "file_types", "max_size"], + "properties": ["allowed_types", "max_size_mb"] + }, + "password": { + "name": "Password Input", + "validation_options": ["required", "minLength"], + "properties": [] + } +} + + +class RegistrationSchemaRequest(BaseModel): + """Request model for updating registration schema""" + schema_data: dict + + +def get_registration_schema(db: Session) -> dict: + """Get the current registration form schema from database or return default""" + setting = db.query(SystemSettings).filter( + SystemSettings.setting_key == "registration.form_schema" + ).first() + + if setting and setting.setting_value: + import json + try: + return json.loads(setting.setting_value) + except json.JSONDecodeError: + logger.error("Failed to parse registration schema from database") + return DEFAULT_REGISTRATION_SCHEMA.copy() + + return DEFAULT_REGISTRATION_SCHEMA.copy() + + +def save_registration_schema(db: Session, schema: dict, user_id: Optional[uuid.UUID] = None) -> None: + """Save registration schema to database""" + import json + + setting = db.query(SystemSettings).filter( + SystemSettings.setting_key == "registration.form_schema" + ).first() + + schema_json = json.dumps(schema) + + if setting: + setting.setting_value = schema_json + setting.updated_by = user_id + setting.updated_at = datetime.now(timezone.utc) + else: + from models import SettingType + setting = SystemSettings( + setting_key="registration.form_schema", + setting_value=schema_json, + setting_type=SettingType.json, + description="Dynamic registration form schema defining steps, fields, and validation rules", + updated_by=user_id, + is_sensitive=False + ) + db.add(setting) + + db.commit() + + +def validate_schema(schema: dict) -> tuple[bool, list[str]]: + """Validate registration schema structure""" + errors = [] + + # Check version + if "version" not in schema: + errors.append("Schema must have a version field") + + # Check steps + if "steps" not in schema or not isinstance(schema.get("steps"), list): + errors.append("Schema must have a steps array") + return False, errors + + if len(schema["steps"]) == 0: + errors.append("Schema must have at least one step") + + if len(schema["steps"]) > 10: + errors.append("Schema cannot have more than 10 steps") + + # Check fixed fields exist + fixed_fields = schema.get("fixed_fields", ["email", "password", "first_name", "last_name", "accepts_tos"]) + all_field_ids = set() + + for step in schema.get("steps", []): + if "id" not in step: + errors.append(f"Step missing id") + continue + + if "sections" not in step or not isinstance(step.get("sections"), list): + errors.append(f"Step {step.get('id')} must have sections array") + continue + + for section in step.get("sections", []): + if "fields" not in section or not isinstance(section.get("fields"), list): + errors.append(f"Section {section.get('id')} must have fields array") + continue + + for field in section.get("fields", []): + if "id" not in field: + errors.append(f"Field missing id in section {section.get('id')}") + continue + + all_field_ids.add(field["id"]) + + if "type" not in field: + errors.append(f"Field {field['id']} missing type") + + if field.get("type") not in FIELD_TYPES: + errors.append(f"Field {field['id']} has invalid type: {field.get('type')}") + + # Verify fixed fields are present + for fixed_field in fixed_fields: + if fixed_field not in all_field_ids: + errors.append(f"Fixed field '{fixed_field}' must be present in schema") + + # Field limit check + if len(all_field_ids) > 100: + errors.append("Schema cannot have more than 100 fields") + + return len(errors) == 0, errors + + +def evaluate_conditional_rules(form_data: dict, rules: list) -> set: + """Evaluate conditional rules and return set of visible field IDs""" + visible_fields = set() + + # Start with all fields visible + for rule in rules: + target_fields = rule.get("target_fields", []) + if rule.get("action") == "hide": + visible_fields.update(target_fields) + + # Apply rules + for rule in rules: + trigger_field = rule.get("trigger_field") + trigger_value = rule.get("trigger_value") + trigger_operator = rule.get("trigger_operator", "equals") + action = rule.get("action", "show") + target_fields = rule.get("target_fields", []) + + field_value = form_data.get(trigger_field) + + # Evaluate condition + condition_met = False + if trigger_operator == "equals": + condition_met = field_value == trigger_value + elif trigger_operator == "not_equals": + condition_met = field_value != trigger_value + elif trigger_operator == "contains": + condition_met = trigger_value in (field_value or []) if isinstance(field_value, list) else trigger_value in str(field_value or "") + elif trigger_operator == "not_empty": + condition_met = bool(field_value) + elif trigger_operator == "empty": + condition_met = not bool(field_value) + + # Apply action + if condition_met: + if action == "show": + visible_fields.update(target_fields) + elif action == "hide": + visible_fields -= set(target_fields) + + return visible_fields + + +def validate_field_by_type(field: dict, value) -> list[str]: + """Validate a field value based on its type and validation rules""" + errors = [] + field_type = field.get("type") + validation = field.get("validation", {}) + label = field.get("label", field.get("id")) + + if field_type == "text" or field_type == "textarea": + if not isinstance(value, str): + errors.append(f"{label} must be text") + return errors + if "minLength" in validation and len(value) < validation["minLength"]: + errors.append(f"{label} must be at least {validation['minLength']} characters") + if "maxLength" in validation and len(value) > validation["maxLength"]: + errors.append(f"{label} must be at most {validation['maxLength']} characters") + if "pattern" in validation: + import re + if not re.match(validation["pattern"], value): + errors.append(f"{label} format is invalid") + + elif field_type == "email": + import re + email_pattern = r'^[^\s@]+@[^\s@]+\.[^\s@]+$' + if not re.match(email_pattern, str(value)): + errors.append(f"{label} must be a valid email address") + + elif field_type == "phone": + if not isinstance(value, str) or len(value) < 7: + errors.append(f"{label} must be a valid phone number") + + elif field_type == "date": + # Date validation happens during parsing + pass + + elif field_type == "dropdown" or field_type == "radio": + options = [opt.get("value") for opt in field.get("options", [])] + if value not in options: + errors.append(f"{label} must be one of the available options") + + elif field_type == "multiselect": + if not isinstance(value, list): + errors.append(f"{label} must be a list of selections") + else: + options = [opt.get("value") for opt in field.get("options", [])] + for v in value: + if v not in options: + errors.append(f"{label} contains invalid option: {v}") + if "min_selections" in validation and len(value) < validation["min_selections"]: + errors.append(f"{label} requires at least {validation['min_selections']} selections") + if "max_selections" in validation and len(value) > validation["max_selections"]: + errors.append(f"{label} allows at most {validation['max_selections']} selections") + + elif field_type == "checkbox": + if not isinstance(value, bool): + errors.append(f"{label} must be true or false") + + elif field_type == "password": + if not isinstance(value, str): + errors.append(f"{label} must be text") + elif "minLength" in validation and len(value) < validation["minLength"]: + errors.append(f"{label} must be at least {validation['minLength']} characters") + + return errors + + +def validate_dynamic_registration(data: dict, schema: dict) -> tuple[bool, list[str]]: + """Validate registration data against dynamic schema""" + errors = [] + conditional_rules = schema.get("conditional_rules", []) + + # Get all fields and their visibility based on conditional rules + hidden_fields = set() + for rule in conditional_rules: + if rule.get("action") == "show": + # Fields are hidden by default if they have a "show" rule + hidden_fields.update(rule.get("target_fields", [])) + + # Evaluate which hidden fields should now be visible + visible_conditional_fields = evaluate_conditional_rules(data, conditional_rules) + hidden_fields -= visible_conditional_fields + + for step in schema.get("steps", []): + for section in step.get("sections", []): + # Check section-level validation + section_validation = section.get("validation", {}) + if section_validation.get("atLeastOne"): + field_ids = [f["id"] for f in section.get("fields", [])] + has_value = any(data.get(fid) for fid in field_ids) + if not has_value: + errors.append(section_validation.get("message", f"At least one field in {section.get('title', 'this section')} is required")) + + for field in section.get("fields", []): + field_id = field.get("id") + + # Skip hidden fields + if field_id in hidden_fields: + continue + + # Skip client-only fields (like confirmPassword) + if field.get("client_only"): + continue + + value = data.get(field_id) + + # Required check + if field.get("required"): + if value is None or value == "" or (isinstance(value, list) and len(value) == 0): + errors.append(f"{field.get('label', field_id)} is required") + continue + + # Type-specific validation + if value is not None and value != "": + field_errors = validate_field_by_type(field, value) + errors.extend(field_errors) + + return len(errors) == 0, errors + + +def split_registration_data(data: dict, schema: dict) -> tuple[dict, dict]: + """Split registration data into User model fields and custom fields""" + user_data = {} + custom_data = {} + + # Get field mappings from schema + field_mappings = {} + for step in schema.get("steps", []): + for section in step.get("sections", []): + for field in section.get("fields", []): + if field.get("mapping"): + field_mappings[field["id"]] = field["mapping"] + + # User model fields that have direct column mappings + user_model_fields = { + "email", "password", "first_name", "last_name", "phone", "address", + "city", "state", "zipcode", "date_of_birth", "lead_sources", + "partner_first_name", "partner_last_name", "partner_is_member", + "partner_plan_to_become_member", "referred_by_member_name", + "newsletter_publish_name", "newsletter_publish_photo", + "newsletter_publish_birthday", "newsletter_publish_none", + "volunteer_interests", "scholarship_requested", "scholarship_reason", + "show_in_directory", "directory_email", "directory_bio", + "directory_address", "directory_phone", "directory_dob", + "directory_partner_name", "accepts_tos" + } + + for field_id, value in data.items(): + mapping = field_mappings.get(field_id, field_id) + + # Skip client-only fields + if field_id == "confirmPassword": + continue + + if mapping in user_model_fields: + user_data[mapping] = value + else: + custom_data[field_id] = value + + return user_data, custom_data + + +# Public endpoint - returns schema for registration form +@api_router.get("/registration/schema") +async def get_public_registration_schema(db: Session = Depends(get_db)): + """Get registration form schema for public registration page""" + schema = get_registration_schema(db) + # Return a clean version without internal metadata + return { + "version": schema.get("version"), + "steps": schema.get("steps", []), + "conditional_rules": schema.get("conditional_rules", []), + "fixed_fields": schema.get("fixed_fields", []) + } + + +# Admin endpoint - returns schema with metadata +@api_router.get("/admin/registration/schema") +async def get_admin_registration_schema( + current_user: User = Depends(require_permission("registration.view")), + db: Session = Depends(get_db) +): + """Get registration form schema with admin metadata""" + schema = get_registration_schema(db) + + # Get version info + setting = db.query(SystemSettings).filter( + SystemSettings.setting_key == "registration.form_schema" + ).first() + + return { + "schema": schema, + "metadata": { + "last_updated": setting.updated_at.isoformat() if setting else None, + "updated_by": str(setting.updated_by) if setting and setting.updated_by else None, + "is_default": setting is None + } + } + + +# Admin endpoint - update schema +@api_router.put("/admin/registration/schema") +async def update_registration_schema( + request: RegistrationSchemaRequest, + current_user: User = Depends(require_permission("registration.manage")), + db: Session = Depends(get_db) +): + """Update registration form schema""" + schema = request.schema_data + + # Validate schema structure + is_valid, errors = validate_schema(schema) + if not is_valid: + raise HTTPException( + status_code=400, + detail={"message": "Invalid schema", "errors": errors} + ) + + # Save schema + save_registration_schema(db, schema, current_user.id) + + logger.info(f"Registration schema updated by user {current_user.email}") + + return {"message": "Registration schema updated successfully"} + + +# Admin endpoint - validate schema without saving +@api_router.post("/admin/registration/schema/validate") +async def validate_registration_schema_endpoint( + request: RegistrationSchemaRequest, + current_user: User = Depends(require_permission("registration.manage")), + db: Session = Depends(get_db) +): + """Validate registration form schema without saving""" + schema = request.schema_data + is_valid, errors = validate_schema(schema) + + return { + "valid": is_valid, + "errors": errors + } + + +# Admin endpoint - reset schema to default +@api_router.post("/admin/registration/schema/reset") +async def reset_registration_schema( + current_user: User = Depends(require_permission("registration.manage")), + db: Session = Depends(get_db) +): + """Reset registration form schema to default""" + save_registration_schema(db, DEFAULT_REGISTRATION_SCHEMA.copy(), current_user.id) + + logger.info(f"Registration schema reset to default by user {current_user.email}") + + return {"message": "Registration schema reset to default"} + + +# Admin endpoint - get available field types +@api_router.get("/admin/registration/field-types") +async def get_field_types( + current_user: User = Depends(require_permission("registration.view")), + db: Session = Depends(get_db) +): + """Get available field types for registration form builder""" + return FIELD_TYPES + + +# ============================================================================ +# Directory Configuration Endpoints +# ============================================================================ + +# Default directory configuration - defines which fields are shown in Profile and Directory +DEFAULT_DIRECTORY_CONFIG = { + "version": "1.0", + "fields": { + "show_in_directory": { + "enabled": True, + "label": "Show in Directory", + "description": "Allow members to opt-in to the member directory", + "required": False, + "editable": True # Whether this field can be disabled by admin + }, + "directory_email": { + "enabled": True, + "label": "Directory Email", + "description": "Email address visible to other members", + "required": False, + "editable": True + }, + "directory_bio": { + "enabled": True, + "label": "Bio", + "description": "Short biography for the directory", + "required": False, + "editable": True + }, + "directory_address": { + "enabled": True, + "label": "Address", + "description": "Address visible to other members", + "required": False, + "editable": True + }, + "directory_phone": { + "enabled": True, + "label": "Phone", + "description": "Phone number visible to other members", + "required": False, + "editable": True + }, + "directory_dob": { + "enabled": True, + "label": "Birthday", + "description": "Birthday visible to other members", + "required": False, + "editable": True + }, + "directory_partner_name": { + "enabled": True, + "label": "Partner Name", + "description": "Partner name visible to other members", + "required": False, + "editable": True + }, + "volunteer_interests": { + "enabled": True, + "label": "Volunteer Interests", + "description": "Volunteer interests shown in directory profile", + "required": False, + "editable": True + }, + "social_media": { + "enabled": True, + "label": "Social Media Links", + "description": "Social media links (Facebook, Instagram, Twitter, LinkedIn)", + "required": False, + "editable": True + } + } +} + + +def get_directory_config(db: Session) -> dict: + """Get directory configuration from database or return default""" + setting = db.query(SystemSettings).filter( + SystemSettings.setting_key == "directory.config" + ).first() + + if setting and setting.setting_value: + try: + return json.loads(setting.setting_value) + except json.JSONDecodeError: + return DEFAULT_DIRECTORY_CONFIG + return DEFAULT_DIRECTORY_CONFIG + + +def save_directory_config(db: Session, config: dict, user_id) -> dict: + """Save directory configuration to database""" + setting = db.query(SystemSettings).filter( + SystemSettings.setting_key == "directory.config" + ).first() + + config_json = json.dumps(config) + + if setting: + setting.setting_value = config_json + setting.updated_by = user_id + setting.updated_at = datetime.now(timezone.utc) + else: + from models import SettingType + setting = SystemSettings( + setting_key="directory.config", + setting_value=config_json, + setting_type=SettingType.json, + description="Member directory field configuration", + updated_by=user_id + ) + db.add(setting) + + db.commit() + db.refresh(setting) + return json.loads(setting.setting_value) + + +# Public endpoint - get directory field configuration +@api_router.get("/directory/config") +async def get_public_directory_config(db: Session = Depends(get_db)): + """Get directory field configuration (public endpoint for Profile page)""" + config = get_directory_config(db) + # Return only the fields and their enabled status for frontend + return { + "fields": { + field_id: { + "enabled": field_data.get("enabled", True), + "label": field_data.get("label", field_id), + "required": field_data.get("required", False) + } + for field_id, field_data in config.get("fields", {}).items() + } + } + + +# Admin endpoint - get full directory configuration with metadata +@api_router.get("/admin/directory/config") +async def get_admin_directory_config( + current_user: User = Depends(require_permission("directory.view")), + db: Session = Depends(get_db) +): + """Get full directory configuration for admin""" + config = get_directory_config(db) + + # Get metadata + setting = db.query(SystemSettings).filter( + SystemSettings.setting_key == "directory.config" + ).first() + + return { + "config": config, + "metadata": { + "updated_at": setting.updated_at.isoformat() if setting and setting.updated_at else None, + "updated_by": setting.updated_by if setting else None, + "is_default": setting is None + } + } + + +# Admin endpoint - update directory configuration +@api_router.put("/admin/directory/config") +async def update_directory_config( + request: Request, + current_user: User = Depends(require_permission("directory.manage")), + db: Session = Depends(get_db) +): + """Update directory field configuration""" + try: + body = await request.json() + config = body.get("config", {}) + + # Validate config structure + if "fields" not in config: + raise HTTPException(status_code=400, detail="Config must contain 'fields' object") + + # Ensure show_in_directory is always enabled (core functionality) + if "show_in_directory" in config["fields"]: + config["fields"]["show_in_directory"]["enabled"] = True + + # Add version if not present + if "version" not in config: + config["version"] = "1.0" + + saved_config = save_directory_config(db, config, current_user.id) + + return { + "message": "Directory configuration updated successfully", + "config": saved_config + } + except Exception as e: + logger.error(f"Error updating directory config: {str(e)}") + raise HTTPException(status_code=400, detail=str(e)) + + +# Admin endpoint - reset directory configuration to default +@api_router.post("/admin/directory/config/reset") +async def reset_directory_config( + current_user: User = Depends(require_permission("directory.manage")), + db: Session = Depends(get_db) +): + """Reset directory configuration to default""" + saved_config = save_directory_config(db, DEFAULT_DIRECTORY_CONFIG, current_user.id) + + return { + "message": "Directory configuration reset to default", + "config": saved_config + } + + # ============================================================================ # Admin Routes # ============================================================================ @@ -6461,6 +7323,693 @@ async def create_donation_checkout( logger.error(f"Error creating donation checkout: {str(e)}") raise HTTPException(status_code=500, detail="Failed to create donation checkout") +# ============================================================ +# Payment Method Management API Endpoints +# ============================================================ + +class PaymentMethodResponse(BaseModel): + id: str + card_brand: Optional[str] = None + card_last4: Optional[str] = None + card_exp_month: Optional[int] = None + card_exp_year: Optional[int] = None + card_funding: Optional[str] = None + payment_type: str + is_default: bool + is_manual: bool + manual_notes: Optional[str] = None + created_at: datetime + + model_config = {"from_attributes": True} + +class PaymentMethodSaveRequest(BaseModel): + stripe_payment_method_id: str + set_as_default: bool = False + +class AdminManualPaymentMethodRequest(BaseModel): + payment_type: Literal["cash", "bank_transfer", "check"] + manual_notes: Optional[str] = None + set_as_default: bool = False + +class AdminRevealRequest(BaseModel): + password: str + + +def get_or_create_stripe_customer(user: User, db: Session) -> str: + """Get existing or create new Stripe customer for user.""" + import stripe + + # Get Stripe API 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 + + if user.stripe_customer_id: + # Verify customer still exists in Stripe + try: + customer = stripe.Customer.retrieve(user.stripe_customer_id) + # Check if customer was deleted using getattr (Stripe SDK doesn't expose 'deleted' directly) + if getattr(customer, 'deleted', False) or customer.get('deleted', False): + # Customer was deleted, create a new one + user.stripe_customer_id = None + else: + return user.stripe_customer_id + except stripe.error.InvalidRequestError: + # Customer doesn't exist, create a new one + user.stripe_customer_id = None + except Exception as e: + logger.warning(f"Error retrieving Stripe customer {user.stripe_customer_id}: {str(e)}") + user.stripe_customer_id = None + + # Create new Stripe customer + customer = stripe.Customer.create( + email=user.email, + name=f"{user.first_name} {user.last_name}", + metadata={"user_id": str(user.id)} + ) + + user.stripe_customer_id = customer.id + db.commit() + logger.info(f"Created Stripe customer {customer.id} for user {user.id}") + + return customer.id + + +@api_router.get("/payment-methods") +async def list_payment_methods( + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """List current user's saved payment methods.""" + methods = db.query(PaymentMethod).filter( + PaymentMethod.user_id == current_user.id, + PaymentMethod.is_active == True + ).order_by(PaymentMethod.is_default.desc(), PaymentMethod.created_at.desc()).all() + + return [{ + "id": str(m.id), + "card_brand": m.card_brand, + "card_last4": m.card_last4, + "card_exp_month": m.card_exp_month, + "card_exp_year": m.card_exp_year, + "card_funding": m.card_funding, + "payment_type": m.payment_type.value, + "is_default": m.is_default, + "is_manual": m.is_manual, + "manual_notes": m.manual_notes if m.is_manual else None, + "created_at": m.created_at.isoformat() + } for m in methods] + + +@api_router.post("/payment-methods/setup-intent") +async def create_setup_intent( + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """Create a Stripe SetupIntent for adding a new payment method.""" + import stripe + + # Get or create Stripe customer + customer_id = get_or_create_stripe_customer(current_user, db) + + # Get Stripe API key + 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 + + # Create SetupIntent + setup_intent = stripe.SetupIntent.create( + customer=customer_id, + payment_method_types=["card"], + metadata={"user_id": str(current_user.id)} + ) + + logger.info(f"Created SetupIntent for user {current_user.id}") + + return { + "client_secret": setup_intent.client_secret, + "setup_intent_id": setup_intent.id + } + + +@api_router.post("/payment-methods") +async def save_payment_method( + request: PaymentMethodSaveRequest, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """Save a payment method after successful SetupIntent confirmation.""" + import stripe + + # Get Stripe API key + 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 + + # Refresh user from DB to get latest stripe_customer_id + db.refresh(current_user) + + # Retrieve payment method from Stripe + try: + pm = stripe.PaymentMethod.retrieve(request.stripe_payment_method_id) + except stripe.error.InvalidRequestError as e: + logger.error(f"Invalid payment method ID: {request.stripe_payment_method_id}, error: {str(e)}") + raise HTTPException(status_code=400, detail="Invalid payment method ID") + + # Verify ownership - payment method must be attached to user's customer + pm_customer = pm.customer if hasattr(pm, 'customer') else None + logger.info(f"Verifying PM ownership: pm.customer={pm_customer}, user.stripe_customer_id={current_user.stripe_customer_id}") + + if not current_user.stripe_customer_id: + raise HTTPException(status_code=403, detail="User does not have a Stripe customer ID") + + if not pm_customer: + raise HTTPException(status_code=403, detail="Payment method is not attached to any customer") + + if pm_customer != current_user.stripe_customer_id: + raise HTTPException(status_code=403, detail="Payment method not owned by user") + + # Check for duplicate + existing = db.query(PaymentMethod).filter( + PaymentMethod.stripe_payment_method_id == request.stripe_payment_method_id, + PaymentMethod.is_active == True + ).first() + + if existing: + raise HTTPException(status_code=400, detail="Payment method already saved") + + # Handle default setting - unset others if setting this as default + if request.set_as_default: + db.query(PaymentMethod).filter( + PaymentMethod.user_id == current_user.id, + PaymentMethod.is_active == True + ).update({"is_default": False}) + + # Extract card details + card = pm.card if pm.type == "card" else None + + # Create payment method record + payment_method = PaymentMethod( + user_id=current_user.id, + stripe_payment_method_id=request.stripe_payment_method_id, + card_brand=card.brand if card else None, + card_last4=card.last4 if card else None, + card_exp_month=card.exp_month if card else None, + card_exp_year=card.exp_year if card else None, + card_funding=card.funding if card else None, + payment_type=PaymentMethodType.card, + is_default=request.set_as_default, + is_active=True, + is_manual=False + ) + + db.add(payment_method) + db.commit() + db.refresh(payment_method) + + logger.info(f"Saved payment method {payment_method.id} for user {current_user.id}") + + return { + "id": str(payment_method.id), + "card_brand": payment_method.card_brand, + "card_last4": payment_method.card_last4, + "card_exp_month": payment_method.card_exp_month, + "card_exp_year": payment_method.card_exp_year, + "is_default": payment_method.is_default, + "message": "Payment method saved successfully" + } + + +@api_router.put("/payment-methods/{payment_method_id}/default") +async def set_default_payment_method( + payment_method_id: str, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """Set a payment method as the default for auto-renewals.""" + try: + pm_uuid = uuid.UUID(payment_method_id) + except ValueError: + raise HTTPException(status_code=400, detail="Invalid payment method ID") + + payment_method = db.query(PaymentMethod).filter( + PaymentMethod.id == pm_uuid, + PaymentMethod.user_id == current_user.id, + PaymentMethod.is_active == True + ).first() + + if not payment_method: + raise HTTPException(status_code=404, detail="Payment method not found") + + # Unset all other defaults + db.query(PaymentMethod).filter( + PaymentMethod.user_id == current_user.id, + PaymentMethod.is_active == True + ).update({"is_default": False}) + + # Set this one as default + payment_method.is_default = True + db.commit() + + logger.info(f"Set default payment method {payment_method_id} for user {current_user.id}") + + return {"message": "Default payment method updated", "id": str(payment_method.id)} + + +@api_router.delete("/payment-methods/{payment_method_id}") +async def delete_payment_method( + payment_method_id: str, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """Delete (soft-delete) a saved payment method.""" + import stripe + + try: + pm_uuid = uuid.UUID(payment_method_id) + except ValueError: + raise HTTPException(status_code=400, detail="Invalid payment method ID") + + payment_method = db.query(PaymentMethod).filter( + PaymentMethod.id == pm_uuid, + PaymentMethod.user_id == current_user.id, + PaymentMethod.is_active == True + ).first() + + if not payment_method: + raise HTTPException(status_code=404, detail="Payment method not found") + + # Detach from Stripe if it's a Stripe payment method + if payment_method.stripe_payment_method_id: + try: + 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 + + stripe.PaymentMethod.detach(payment_method.stripe_payment_method_id) + logger.info(f"Detached Stripe payment method {payment_method.stripe_payment_method_id}") + except stripe.error.StripeError as e: + logger.warning(f"Failed to detach Stripe payment method: {str(e)}") + + # Soft delete + payment_method.is_active = False + payment_method.is_default = False + db.commit() + + logger.info(f"Deleted payment method {payment_method_id} for user {current_user.id}") + + return {"message": "Payment method deleted"} + + +# ============================================================ +# Admin Payment Method Management Endpoints +# ============================================================ + +@api_router.get("/admin/users/{user_id}/payment-methods") +async def admin_list_user_payment_methods( + user_id: str, + current_user: User = Depends(require_permission("payment_methods.view")), + db: Session = Depends(get_db) +): + """Admin: List a user's payment methods (masked).""" + try: + user_uuid = uuid.UUID(user_id) + except ValueError: + raise HTTPException(status_code=400, detail="Invalid user ID") + + user = db.query(User).filter(User.id == user_uuid).first() + if not user: + raise HTTPException(status_code=404, detail="User not found") + + methods = db.query(PaymentMethod).filter( + PaymentMethod.user_id == user_uuid, + PaymentMethod.is_active == True + ).order_by(PaymentMethod.is_default.desc(), PaymentMethod.created_at.desc()).all() + + return [{ + "id": str(m.id), + "card_brand": m.card_brand, + "card_last4": m.card_last4, + "card_exp_month": m.card_exp_month, + "card_exp_year": m.card_exp_year, + "card_funding": m.card_funding, + "payment_type": m.payment_type.value, + "is_default": m.is_default, + "is_manual": m.is_manual, + "manual_notes": m.manual_notes if m.is_manual else None, + "created_at": m.created_at.isoformat(), + # Sensitive data masked + "stripe_payment_method_id": None + } for m in methods] + + +@api_router.post("/admin/users/{user_id}/payment-methods/reveal") +async def admin_reveal_payment_details( + user_id: str, + request: AdminRevealRequest, + current_user: User = Depends(require_permission("payment_methods.view_sensitive")), + db: Session = Depends(get_db) +): + """Admin: Reveal full payment method details (requires password confirmation).""" + try: + user_uuid = uuid.UUID(user_id) + except ValueError: + raise HTTPException(status_code=400, detail="Invalid user ID") + + # Verify admin's password + if not verify_password(request.password, current_user.password_hash): + logger.warning(f"Admin {current_user.email} failed password verification for payment reveal") + raise HTTPException(status_code=401, detail="Invalid password") + + user = db.query(User).filter(User.id == user_uuid).first() + if not user: + raise HTTPException(status_code=404, detail="User not found") + + methods = db.query(PaymentMethod).filter( + PaymentMethod.user_id == user_uuid, + PaymentMethod.is_active == True + ).order_by(PaymentMethod.is_default.desc(), PaymentMethod.created_at.desc()).all() + + # Log sensitive access + logger.info(f"Admin {current_user.email} revealed payment details for user {user_id}") + + return [{ + "id": str(m.id), + "card_brand": m.card_brand, + "card_last4": m.card_last4, + "card_exp_month": m.card_exp_month, + "card_exp_year": m.card_exp_year, + "card_funding": m.card_funding, + "payment_type": m.payment_type.value, + "is_default": m.is_default, + "is_manual": m.is_manual, + "manual_notes": m.manual_notes, + "created_at": m.created_at.isoformat(), + "stripe_payment_method_id": m.stripe_payment_method_id + } for m in methods] + + +@api_router.post("/admin/users/{user_id}/payment-methods/setup-intent") +async def admin_create_setup_intent_for_user( + user_id: str, + current_user: User = Depends(require_permission("payment_methods.create")), + db: Session = Depends(get_db) +): + """Admin: Create a SetupIntent for adding a card on behalf of a user.""" + import stripe + + try: + user_uuid = uuid.UUID(user_id) + except ValueError: + raise HTTPException(status_code=400, detail="Invalid user ID") + + user = db.query(User).filter(User.id == user_uuid).first() + if not user: + raise HTTPException(status_code=404, detail="User not found") + + # Get or create Stripe customer for the target user + customer_id = get_or_create_stripe_customer(user, db) + + # Get Stripe API key + 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 + + # Create SetupIntent + setup_intent = stripe.SetupIntent.create( + customer=customer_id, + payment_method_types=["card"], + metadata={ + "user_id": str(user.id), + "created_by_admin": str(current_user.id) + } + ) + + logger.info(f"Admin {current_user.email} created SetupIntent for user {user_id}") + + return { + "client_secret": setup_intent.client_secret, + "setup_intent_id": setup_intent.id + } + + +@api_router.post("/admin/users/{user_id}/payment-methods") +async def admin_save_payment_method_for_user( + user_id: str, + request: PaymentMethodSaveRequest, + current_user: User = Depends(require_permission("payment_methods.create")), + db: Session = Depends(get_db) +): + """Admin: Save a payment method on behalf of a user.""" + import stripe + + try: + user_uuid = uuid.UUID(user_id) + except ValueError: + raise HTTPException(status_code=400, detail="Invalid user ID") + + user = db.query(User).filter(User.id == user_uuid).first() + if not user: + raise HTTPException(status_code=404, detail="User not found") + + # Refresh user to get latest data + db.refresh(user) + + # Get Stripe API key + 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 + + # Retrieve payment method from Stripe + try: + pm = stripe.PaymentMethod.retrieve(request.stripe_payment_method_id) + except stripe.error.InvalidRequestError as e: + logger.error(f"Invalid payment method ID: {request.stripe_payment_method_id}, error: {str(e)}") + raise HTTPException(status_code=400, detail="Invalid payment method ID") + + # Verify ownership - payment method must be attached to user's customer + pm_customer = pm.customer if hasattr(pm, 'customer') else None + logger.info(f"Admin verifying PM ownership: pm.customer={pm_customer}, user.stripe_customer_id={user.stripe_customer_id}") + + if not user.stripe_customer_id: + raise HTTPException(status_code=403, detail="User does not have a Stripe customer ID") + + if not pm_customer: + raise HTTPException(status_code=403, detail="Payment method is not attached to any customer") + + if pm_customer != user.stripe_customer_id: + raise HTTPException(status_code=403, detail="Payment method not attached to user's Stripe customer") + + # Check for duplicate + existing = db.query(PaymentMethod).filter( + PaymentMethod.stripe_payment_method_id == request.stripe_payment_method_id, + PaymentMethod.is_active == True + ).first() + + if existing: + raise HTTPException(status_code=400, detail="Payment method already saved") + + # Handle default setting + if request.set_as_default: + db.query(PaymentMethod).filter( + PaymentMethod.user_id == user.id, + PaymentMethod.is_active == True + ).update({"is_default": False}) + + # Extract card details + card = pm.card if pm.type == "card" else None + + # Create payment method record + payment_method = PaymentMethod( + user_id=user.id, + stripe_payment_method_id=request.stripe_payment_method_id, + card_brand=card.brand if card else None, + card_last4=card.last4 if card else None, + card_exp_month=card.exp_month if card else None, + card_exp_year=card.exp_year if card else None, + card_funding=card.funding if card else None, + payment_type=PaymentMethodType.card, + is_default=request.set_as_default, + is_active=True, + is_manual=False, + created_by=current_user.id + ) + + db.add(payment_method) + db.commit() + db.refresh(payment_method) + + logger.info(f"Admin {current_user.email} saved payment method {payment_method.id} for user {user_id}") + + return { + "id": str(payment_method.id), + "card_brand": payment_method.card_brand, + "card_last4": payment_method.card_last4, + "message": "Payment method saved successfully" + } + + +@api_router.post("/admin/users/{user_id}/payment-methods/manual") +async def admin_record_manual_payment_method( + user_id: str, + request: AdminManualPaymentMethodRequest, + current_user: User = Depends(require_permission("payment_methods.create")), + db: Session = Depends(get_db) +): + """Admin: Record a manual payment method (cash, check, bank transfer).""" + try: + user_uuid = uuid.UUID(user_id) + except ValueError: + raise HTTPException(status_code=400, detail="Invalid user ID") + + user = db.query(User).filter(User.id == user_uuid).first() + if not user: + raise HTTPException(status_code=404, detail="User not found") + + # Map payment type string to enum + payment_type_map = { + "cash": PaymentMethodType.cash, + "bank_transfer": PaymentMethodType.bank_transfer, + "check": PaymentMethodType.check + } + payment_type = payment_type_map.get(request.payment_type) + if not payment_type: + raise HTTPException(status_code=400, detail="Invalid payment type") + + # Handle default setting + if request.set_as_default: + db.query(PaymentMethod).filter( + PaymentMethod.user_id == user.id, + PaymentMethod.is_active == True + ).update({"is_default": False}) + + # Create manual payment method record + payment_method = PaymentMethod( + user_id=user.id, + stripe_payment_method_id=None, + payment_type=payment_type, + is_default=request.set_as_default, + is_active=True, + is_manual=True, + manual_notes=request.manual_notes, + created_by=current_user.id + ) + + db.add(payment_method) + db.commit() + db.refresh(payment_method) + + logger.info(f"Admin {current_user.email} recorded manual payment method {payment_method.id} ({payment_type.value}) for user {user_id}") + + return { + "id": str(payment_method.id), + "payment_type": payment_method.payment_type.value, + "message": "Manual payment method recorded successfully" + } + + +@api_router.put("/admin/users/{user_id}/payment-methods/{payment_method_id}/default") +async def admin_set_default_payment_method( + user_id: str, + payment_method_id: str, + current_user: User = Depends(require_permission("payment_methods.set_default")), + db: Session = Depends(get_db) +): + """Admin: Set a user's payment method as default.""" + try: + user_uuid = uuid.UUID(user_id) + pm_uuid = uuid.UUID(payment_method_id) + except ValueError: + raise HTTPException(status_code=400, detail="Invalid ID format") + + user = db.query(User).filter(User.id == user_uuid).first() + if not user: + raise HTTPException(status_code=404, detail="User not found") + + payment_method = db.query(PaymentMethod).filter( + PaymentMethod.id == pm_uuid, + PaymentMethod.user_id == user_uuid, + PaymentMethod.is_active == True + ).first() + + if not payment_method: + raise HTTPException(status_code=404, detail="Payment method not found") + + # Unset all other defaults + db.query(PaymentMethod).filter( + PaymentMethod.user_id == user_uuid, + PaymentMethod.is_active == True + ).update({"is_default": False}) + + # Set this one as default + payment_method.is_default = True + db.commit() + + logger.info(f"Admin {current_user.email} set default payment method {payment_method_id} for user {user_id}") + + return {"message": "Default payment method updated", "id": str(payment_method.id)} + + +@api_router.delete("/admin/users/{user_id}/payment-methods/{payment_method_id}") +async def admin_delete_payment_method( + user_id: str, + payment_method_id: str, + current_user: User = Depends(require_permission("payment_methods.delete")), + db: Session = Depends(get_db) +): + """Admin: Delete a user's payment method.""" + import stripe + + try: + user_uuid = uuid.UUID(user_id) + pm_uuid = uuid.UUID(payment_method_id) + except ValueError: + raise HTTPException(status_code=400, detail="Invalid ID format") + + user = db.query(User).filter(User.id == user_uuid).first() + if not user: + raise HTTPException(status_code=404, detail="User not found") + + payment_method = db.query(PaymentMethod).filter( + PaymentMethod.id == pm_uuid, + PaymentMethod.user_id == user_uuid, + PaymentMethod.is_active == True + ).first() + + if not payment_method: + raise HTTPException(status_code=404, detail="Payment method not found") + + # Detach from Stripe if it's a Stripe payment method + if payment_method.stripe_payment_method_id: + try: + 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 + + stripe.PaymentMethod.detach(payment_method.stripe_payment_method_id) + logger.info(f"Detached Stripe payment method {payment_method.stripe_payment_method_id}") + except stripe.error.StripeError as e: + logger.warning(f"Failed to detach Stripe payment method: {str(e)}") + + # Soft delete + payment_method.is_active = False + payment_method.is_default = False + db.commit() + + logger.info(f"Admin {current_user.email} deleted payment method {payment_method_id} for user {user_id}") + + return {"message": "Payment method deleted"} + + @api_router.post("/contact") async def submit_contact_form( request: ContactFormRequest, @@ -6846,6 +8395,29 @@ def set_setting( db.commit() +@api_router.get("/config/stripe") +async def get_stripe_public_config(db: Session = Depends(get_db)): + """ + Get Stripe publishable key for frontend (public endpoint). + + This endpoint provides the publishable key needed for Stripe.js + to initialize payment forms. No authentication required since + publishable keys are meant to be public. + """ + publishable_key = get_setting(db, 'stripe_publishable_key', decrypt=False) + + if not publishable_key: + raise HTTPException( + status_code=503, + detail="Stripe is not configured. Please contact the administrator." + ) + + return { + "publishable_key": publishable_key, + "environment": "test" if publishable_key.startswith('pk_test_') else "live" + } + + @api_router.get("/admin/settings/stripe/status") async def get_stripe_status( current_user: User = Depends(get_current_superadmin), @@ -6856,6 +8428,7 @@ async def get_stripe_status( Returns: - configured: Whether credentials exist in database + - publishable_key_prefix: First 12 chars of publishable key - secret_key_prefix: First 10 chars of secret key (for verification) - webhook_configured: Whether webhook secret exists - environment: test or live (based on key prefix) @@ -6864,10 +8437,11 @@ async def get_stripe_status( import os # Read from database + publishable_key = get_setting(db, 'stripe_publishable_key', decrypt=False) secret_key = get_setting(db, 'stripe_secret_key', decrypt=True) webhook_secret = get_setting(db, 'stripe_webhook_secret', decrypt=True) - configured = bool(secret_key) + configured = bool(secret_key) and bool(publishable_key) environment = 'unknown' if secret_key: @@ -6887,6 +8461,8 @@ async def get_stripe_status( return { "configured": configured, + "publishable_key_prefix": publishable_key[:12] if publishable_key else None, + "publishable_key_set": bool(publishable_key), "secret_key_prefix": secret_key[:10] if secret_key else None, "secret_key_set": bool(secret_key), "webhook_secret_set": bool(webhook_secret), @@ -6895,6 +8471,7 @@ async def get_stripe_status( "instructions": { "location": "Database (system_settings table)", "required_settings": [ + "stripe_publishable_key (pk_test_... or pk_live_...)", "stripe_secret_key (sk_test_... or sk_live_...)", "stripe_webhook_secret (whsec_...)" ], @@ -6952,6 +8529,7 @@ async def test_stripe_connection( class UpdateStripeSettingsRequest(BaseModel): """Request model for updating Stripe settings""" + publishable_key: str = Field(..., min_length=1, description="Stripe publishable key (pk_test_... or pk_live_...)") secret_key: str = Field(..., min_length=1, description="Stripe secret key (sk_test_... or sk_live_...)") webhook_secret: str = Field(..., min_length=1, description="Stripe webhook secret (whsec_...)") @@ -6968,6 +8546,13 @@ async def update_stripe_settings( Stores Stripe credentials encrypted in the database. Changes take effect immediately without server restart. """ + # Validate publishable key format + if not (request.publishable_key.startswith('pk_test_') or request.publishable_key.startswith('pk_live_')): + raise HTTPException( + status_code=400, + detail="Invalid Stripe publishable key format. Must start with 'pk_test_' or 'pk_live_'" + ) + # Validate secret key format if not (request.secret_key.startswith('sk_test_') or request.secret_key.startswith('sk_live_')): raise HTTPException( @@ -6982,7 +8567,27 @@ async def update_stripe_settings( detail="Invalid Stripe webhook secret format. Must start with 'whsec_'" ) + # Validate key environment consistency (publishable and secret should match) + pk_is_live = request.publishable_key.startswith('pk_live_') + sk_is_live = request.secret_key.startswith('sk_live_') + if pk_is_live != sk_is_live: + raise HTTPException( + status_code=400, + detail="Publishable key and Secret key must be from the same environment (both test or both live)" + ) + try: + # Store publishable key (NOT encrypted - it's meant to be public) + set_setting( + db=db, + key='stripe_publishable_key', + value=request.publishable_key, + user_id=str(current_user.id), + description='Stripe publishable key for frontend payment forms', + is_sensitive=False, + encrypt=False + ) + # Store secret key (encrypted) set_setting( db=db, @@ -7023,6 +8628,711 @@ async def update_stripe_settings( ) +# ============================================================================ +# Member Tiers Settings +# ============================================================================ + +# Default tier configuration +DEFAULT_MEMBER_TIERS = { + "tiers": [ + { + "id": "new_member", + "label": "New Member", + "minYears": 0, + "maxYears": 0.999, + "iconKey": "sparkle", + "badgeClass": "bg-blue-100 text-blue-800 border-blue-200" + }, + { + "id": "member_1_year", + "label": "1 Year Member", + "minYears": 1, + "maxYears": 2.999, + "iconKey": "star", + "badgeClass": "bg-green-100 text-green-800 border-green-200" + }, + { + "id": "member_3_year", + "label": "3+ Year Member", + "minYears": 3, + "maxYears": 4.999, + "iconKey": "award", + "badgeClass": "bg-purple-100 text-purple-800 border-purple-200" + }, + { + "id": "veteran", + "label": "Veteran Member", + "minYears": 5, + "maxYears": 999, + "iconKey": "crown", + "badgeClass": "bg-amber-100 text-amber-800 border-amber-200" + } + ] +} + + +class MemberTier(BaseModel): + """Single tier definition""" + id: str = Field(..., min_length=1, max_length=50) + label: str = Field(..., min_length=1, max_length=100) + minYears: float = Field(..., ge=0) + maxYears: float = Field(..., gt=0) + iconKey: str = Field(..., min_length=1, max_length=50) + badgeClass: str = Field(..., min_length=1, max_length=200) + + +class MemberTiersConfig(BaseModel): + """Member tiers configuration""" + tiers: List[MemberTier] = Field(..., min_length=1, max_length=10) + + @validator('tiers') + def validate_tiers_no_overlap(cls, tiers): + """Ensure tiers are sorted and don't overlap""" + sorted_tiers = sorted(tiers, key=lambda t: t.minYears) + + for i in range(len(sorted_tiers) - 1): + current = sorted_tiers[i] + next_tier = sorted_tiers[i + 1] + if current.maxYears >= next_tier.minYears: + raise ValueError( + f"Tier '{current.label}' (max: {current.maxYears}) overlaps with " + f"'{next_tier.label}' (min: {next_tier.minYears})" + ) + + return sorted_tiers + + +@api_router.get("/settings/member-tiers") +async def get_member_tiers_public( + current_user: User = Depends(get_active_member), + db: Session = Depends(get_db) +): + """ + Get member tier configuration (for active members/staff). + + Returns the tier definitions used to display membership badges. + """ + import json + + tiers_json = get_setting(db, 'member_tiers') + + if tiers_json: + try: + return json.loads(tiers_json) + except json.JSONDecodeError: + # Fall back to default if stored JSON is invalid + return DEFAULT_MEMBER_TIERS + + return DEFAULT_MEMBER_TIERS + + +@api_router.get("/admin/settings/member-tiers") +async def get_member_tiers_admin( + current_user: User = Depends(get_current_admin_user), + db: Session = Depends(get_db) +): + """ + Get member tier configuration (admin view). + + Returns the tier definitions along with metadata about last update. + """ + import json + + tiers_json = get_setting(db, 'member_tiers') + + # Get the setting record for metadata + setting = db.query(SystemSettings).filter( + SystemSettings.setting_key == 'member_tiers' + ).first() + + config = DEFAULT_MEMBER_TIERS + if tiers_json: + try: + config = json.loads(tiers_json) + except json.JSONDecodeError: + pass + + return { + "config": config, + "is_default": tiers_json is None, + "updated_at": setting.updated_at.isoformat() if setting else None, + "updated_by": f"{setting.updater.first_name} {setting.updater.last_name}" if setting and setting.updater else None + } + + +@api_router.put("/admin/settings/member-tiers") +async def update_member_tiers( + request: MemberTiersConfig, + current_user: User = Depends(get_current_admin_user), + db: Session = Depends(get_db) +): + """ + Update member tier configuration (admin only). + + Validates tier definitions to ensure: + - No overlapping year ranges + - All required fields present + - Tiers are sorted by minYears + """ + import json + + try: + # Convert to dict for JSON storage + tiers_dict = {"tiers": [tier.dict() for tier in request.tiers]} + tiers_json = json.dumps(tiers_dict) + + # Store using set_setting helper + set_setting( + db=db, + key='member_tiers', + value=tiers_json, + user_id=str(current_user.id), + setting_type='json', + description='Member tier badge configuration', + is_sensitive=False + ) + + return { + "success": True, + "message": "Member tiers updated successfully", + "config": tiers_dict, + "updated_at": datetime.now(timezone.utc).isoformat(), + "updated_by": f"{current_user.first_name} {current_user.last_name}" + } + + except Exception as e: + raise HTTPException( + status_code=500, + detail=f"Failed to update member tiers: {str(e)}" + ) + + +@api_router.post("/admin/settings/member-tiers/reset") +async def reset_member_tiers( + current_user: User = Depends(get_current_superadmin), + db: Session = Depends(get_db) +): + """ + Reset member tiers to default configuration (superadmin only). + """ + # Delete the setting to revert to defaults + db.query(SystemSettings).filter( + SystemSettings.setting_key == 'member_tiers' + ).delete() + db.commit() + + return { + "success": True, + "message": "Member tiers reset to defaults", + "config": DEFAULT_MEMBER_TIERS + } + + +# ============================================================================ +# Theme Settings +# ============================================================================ + +# Default theme configuration +DEFAULT_THEME_CONFIG = { + "site_name": "LOAF - Lesbians Over Age Fifty", + "site_short_name": "LOAF", + "site_description": "A community organization for lesbians over age fifty in Houston and surrounding areas.", + "logo_url": None, + "favicon_url": None, + "colors": { + "primary": "280 47% 27%", + "primary_foreground": "0 0% 100%", + "accent": "24 86% 55%", + "brand_purple": "256 35% 47%", + "brand_orange": "24 86% 55%", + "brand_lavender": "262 46% 80%" + }, + "meta_theme_color": "#664fa3" +} + +# Simple in-memory cache for theme config +_theme_cache = { + "config": None, + "expires_at": None +} +THEME_CACHE_TTL_SECONDS = 300 # 5 minutes + + +def get_theme_config_cached(db: Session) -> dict: + """Get theme config with caching.""" + import json + from datetime import datetime, timezone + + now = datetime.now(timezone.utc) + + # Check cache + if _theme_cache["config"] and _theme_cache["expires_at"] and _theme_cache["expires_at"] > now: + return _theme_cache["config"] + + # Build config from settings + config = dict(DEFAULT_THEME_CONFIG) + + # Fetch all theme.* settings + theme_settings = db.query(SystemSettings).filter( + SystemSettings.setting_key.like('theme.%') + ).all() + + for setting in theme_settings: + key = setting.setting_key.replace('theme.', '') + value = setting.setting_value + + if key == 'colors' and value: + try: + config['colors'] = json.loads(value) + except json.JSONDecodeError: + pass + elif key in config: + config[key] = value + + # Update cache + _theme_cache["config"] = config + _theme_cache["expires_at"] = now + timedelta(seconds=THEME_CACHE_TTL_SECONDS) + + return config + + +def invalidate_theme_cache(): + """Invalidate the theme config cache.""" + _theme_cache["config"] = None + _theme_cache["expires_at"] = None + + +@api_router.get("/config/theme") +async def get_theme_config(db: Session = Depends(get_db)): + """ + Get public theme configuration. + + This endpoint is public (no authentication required) and returns + the theme configuration for frontend initialization. + + Returns cached config with 5-minute TTL for performance. + """ + return get_theme_config_cached(db) + + +@api_router.get("/admin/settings/theme") +async def get_theme_settings_admin( + current_user: User = Depends(require_permission("settings.view")), + db: Session = Depends(get_db) +): + """ + Get theme settings with metadata (admin view). + + Returns the full theme configuration along with: + - Whether using default values + - Last update timestamp + - Who made the last update + """ + import json + + config = dict(DEFAULT_THEME_CONFIG) + is_default = True + updated_at = None + updated_by = None + + # Fetch all theme.* settings + theme_settings = db.query(SystemSettings).filter( + SystemSettings.setting_key.like('theme.%') + ).all() + + if theme_settings: + is_default = False + + # Find the most recent update + latest_setting = max(theme_settings, key=lambda s: s.updated_at or s.created_at) + updated_at = latest_setting.updated_at or latest_setting.created_at + if latest_setting.updater: + updated_by = f"{latest_setting.updater.first_name} {latest_setting.updater.last_name}" + + for setting in theme_settings: + key = setting.setting_key.replace('theme.', '') + value = setting.setting_value + + if key == 'colors' and value: + try: + config['colors'] = json.loads(value) + except json.JSONDecodeError: + pass + elif key in config: + config[key] = value + + return { + "config": config, + "is_default": is_default, + "updated_at": updated_at.isoformat() if updated_at else None, + "updated_by": updated_by + } + + +class ThemeSettingsUpdate(BaseModel): + """Request model for updating theme settings""" + site_name: Optional[str] = Field(None, max_length=200, description="Full site name") + site_short_name: Optional[str] = Field(None, max_length=50, description="Short name for PWA") + site_description: Optional[str] = Field(None, max_length=500, description="Site description for SEO meta tag") + colors: Optional[dict] = Field(None, description="Color scheme as HSL values") + meta_theme_color: Optional[str] = Field(None, pattern=r'^#[0-9A-Fa-f]{6}$', description="PWA theme color (hex)") + + +@api_router.put("/admin/settings/theme") +async def update_theme_settings( + request: ThemeSettingsUpdate, + current_user: User = Depends(require_permission("settings.edit")), + db: Session = Depends(get_db) +): + """ + Update theme settings (admin only). + + Updates one or more theme settings. Only provided fields are updated. + Changes are applied immediately and the cache is invalidated. + """ + import json + + updates = {} + + if request.site_name is not None: + set_setting( + db=db, + key='theme.site_name', + value=request.site_name, + user_id=str(current_user.id), + setting_type='plaintext', + description='Site name displayed in title and navigation', + is_sensitive=False + ) + updates['site_name'] = request.site_name + + if request.site_short_name is not None: + set_setting( + db=db, + key='theme.site_short_name', + value=request.site_short_name, + user_id=str(current_user.id), + setting_type='plaintext', + description='Short site name for PWA manifest', + is_sensitive=False + ) + updates['site_short_name'] = request.site_short_name + + if request.site_description is not None: + set_setting( + db=db, + key='theme.site_description', + value=request.site_description, + user_id=str(current_user.id), + setting_type='plaintext', + description='Site description for SEO meta tag', + is_sensitive=False + ) + updates['site_description'] = request.site_description + + if request.colors is not None: + set_setting( + db=db, + key='theme.colors', + value=json.dumps(request.colors), + user_id=str(current_user.id), + setting_type='json', + description='Theme color scheme as HSL values', + is_sensitive=False + ) + updates['colors'] = request.colors + + if request.meta_theme_color is not None: + set_setting( + db=db, + key='theme.meta_theme_color', + value=request.meta_theme_color, + user_id=str(current_user.id), + setting_type='plaintext', + description='PWA theme-color meta tag value', + is_sensitive=False + ) + updates['meta_theme_color'] = request.meta_theme_color + + # Invalidate cache + invalidate_theme_cache() + + return { + "success": True, + "message": "Theme settings updated successfully", + "updated_fields": list(updates.keys()), + "updated_at": datetime.now(timezone.utc).isoformat(), + "updated_by": f"{current_user.first_name} {current_user.last_name}" + } + + +@api_router.post("/admin/settings/theme/logo") +async def upload_theme_logo( + file: UploadFile = File(...), + current_user: User = Depends(require_permission("settings.edit")), + db: Session = Depends(get_db) +): + """ + Upload organization logo (admin only). + + Accepts PNG, JPEG, WebP, or SVG images. + Replaces any existing logo. + """ + r2 = get_r2_storage() + + # Get current logo key for deletion + old_logo_key = get_setting(db, 'theme.logo_key') + + # Delete old logo if exists + if old_logo_key: + try: + await r2.delete_file(old_logo_key) + except Exception as e: + print(f"Warning: Failed to delete old logo: {e}") + + # Upload new logo + try: + public_url, object_key, file_size = await r2.upload_file( + file=file, + folder="branding", + allowed_types=r2.ALLOWED_BRANDING_TYPES, + max_size_bytes=5 * 1024 * 1024 # 5MB limit for logos + ) + + # Store URL and key in settings + set_setting( + db=db, + key='theme.logo_url', + value=public_url, + user_id=str(current_user.id), + setting_type='plaintext', + description='Organization logo URL', + is_sensitive=False + ) + + set_setting( + db=db, + key='theme.logo_key', + value=object_key, + user_id=str(current_user.id), + setting_type='plaintext', + description='R2 object key for logo (for deletion)', + is_sensitive=False + ) + + # Invalidate cache + invalidate_theme_cache() + + return { + "success": True, + "message": "Logo uploaded successfully", + "logo_url": public_url, + "file_size": file_size + } + + except HTTPException: + raise + except Exception as e: + raise HTTPException( + status_code=500, + detail=f"Failed to upload logo: {str(e)}" + ) + + +@api_router.post("/admin/settings/theme/favicon") +async def upload_theme_favicon( + file: UploadFile = File(...), + current_user: User = Depends(require_permission("settings.edit")), + db: Session = Depends(get_db) +): + """ + Upload site favicon (admin only). + + Accepts ICO, PNG, or SVG images. + Replaces any existing favicon. + """ + r2 = get_r2_storage() + + # Get current favicon key for deletion + old_favicon_key = get_setting(db, 'theme.favicon_key') + + # Delete old favicon if exists + if old_favicon_key: + try: + await r2.delete_file(old_favicon_key) + except Exception as e: + print(f"Warning: Failed to delete old favicon: {e}") + + # Upload new favicon + try: + public_url, object_key, file_size = await r2.upload_file( + file=file, + folder="branding", + allowed_types=r2.ALLOWED_FAVICON_TYPES, + max_size_bytes=1 * 1024 * 1024 # 1MB limit for favicons + ) + + # Store URL and key in settings + set_setting( + db=db, + key='theme.favicon_url', + value=public_url, + user_id=str(current_user.id), + setting_type='plaintext', + description='Site favicon URL', + is_sensitive=False + ) + + set_setting( + db=db, + key='theme.favicon_key', + value=object_key, + user_id=str(current_user.id), + setting_type='plaintext', + description='R2 object key for favicon (for deletion)', + is_sensitive=False + ) + + # Invalidate cache + invalidate_theme_cache() + + return { + "success": True, + "message": "Favicon uploaded successfully", + "favicon_url": public_url, + "file_size": file_size + } + + except HTTPException: + raise + except Exception as e: + raise HTTPException( + status_code=500, + detail=f"Failed to upload favicon: {str(e)}" + ) + + +@api_router.delete("/admin/settings/theme/logo") +async def delete_theme_logo( + current_user: User = Depends(require_permission("settings.edit")), + db: Session = Depends(get_db) +): + """ + Delete organization logo (admin only). + + Removes the logo from R2 storage and clears the settings, + reverting to the default logo. + """ + r2 = get_r2_storage() + + # Get current logo key for deletion + logo_key = get_setting(db, 'theme.logo_key') + + if logo_key: + try: + await r2.delete_file(logo_key) + except Exception as e: + print(f"Warning: Failed to delete logo from R2: {e}") + + # Delete the settings + db.query(SystemSettings).filter( + SystemSettings.setting_key.in_(['theme.logo_url', 'theme.logo_key']) + ).delete(synchronize_session=False) + db.commit() + + # Invalidate cache + invalidate_theme_cache() + + return { + "success": True, + "message": "Logo deleted successfully" + } + + +@api_router.delete("/admin/settings/theme/favicon") +async def delete_theme_favicon( + current_user: User = Depends(require_permission("settings.edit")), + db: Session = Depends(get_db) +): + """ + Delete site favicon (admin only). + + Removes the favicon from R2 storage and clears the settings, + reverting to the default favicon. + """ + r2 = get_r2_storage() + + # Get current favicon key for deletion + favicon_key = get_setting(db, 'theme.favicon_key') + + if favicon_key: + try: + await r2.delete_file(favicon_key) + except Exception as e: + print(f"Warning: Failed to delete favicon from R2: {e}") + + # Delete the settings + db.query(SystemSettings).filter( + SystemSettings.setting_key.in_(['theme.favicon_url', 'theme.favicon_key']) + ).delete(synchronize_session=False) + db.commit() + + # Invalidate cache + invalidate_theme_cache() + + return { + "success": True, + "message": "Favicon deleted successfully" + } + + +@api_router.post("/admin/settings/theme/reset") +async def reset_theme_settings( + current_user: User = Depends(get_current_superadmin), + db: Session = Depends(get_db) +): + """ + Reset all theme settings to defaults (superadmin only). + + Deletes all theme.* settings from the database and removes + any uploaded logo/favicon from R2 storage. + """ + r2 = get_r2_storage() + + # Get keys for uploaded files + logo_key = get_setting(db, 'theme.logo_key') + favicon_key = get_setting(db, 'theme.favicon_key') + + # Delete files from R2 + if logo_key: + try: + await r2.delete_file(logo_key) + except Exception as e: + print(f"Warning: Failed to delete logo from R2: {e}") + + if favicon_key: + try: + await r2.delete_file(favicon_key) + except Exception as e: + print(f"Warning: Failed to delete favicon from R2: {e}") + + # Delete all theme settings + deleted_count = db.query(SystemSettings).filter( + SystemSettings.setting_key.like('theme.%') + ).delete(synchronize_session=False) + db.commit() + + # Invalidate cache + invalidate_theme_cache() + + return { + "success": True, + "message": "Theme settings reset to defaults", + "deleted_settings_count": deleted_count, + "config": DEFAULT_THEME_CONFIG + } + + # Include the router in the main app app.include_router(api_router)